initial commit
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
DATABASE_URL="file:./dev.db"
|
||||
COURSES_ROOT="./My_Courses"
|
||||
NEXT_PUBLIC_APP_URL="http://localhost:6767"
|
||||
QUIZAPI_KEY=""
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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.*
|
||||
@@ -0,0 +1,414 @@
|
||||
# OfflineAcademy
|
||||
|
||||
<p align="center">
|
||||
<strong>A self-hosted, LAN-first video course library for private learning.</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
Turn a local folder of course videos into a clean learning dashboard with progress tracking, bookmarks, tags, categories, and optional AI-generated quizzes.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Next.js" src="https://img.shields.io/badge/Next.js-14-black?logo=nextdotjs" />
|
||||
<img alt="React" src="https://img.shields.io/badge/React-18-61DAFB?logo=react&logoColor=black" />
|
||||
<img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5-3178C6?logo=typescript&logoColor=white" />
|
||||
<img alt="Prisma" src="https://img.shields.io/badge/Prisma-SQLite-2D3748?logo=prisma" />
|
||||
<img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?logo=docker&logoColor=white" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||

|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
@@ -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<string, number>
|
||||
completionRate: number
|
||||
lessonCompletionRate: number
|
||||
}
|
||||
|
||||
async function getAnalyticsData(): Promise<AnalyticsData> {
|
||||
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<string, React.ReactNode> = {
|
||||
VIDEO: <Film className="h-4 w-4 text-red-400" />,
|
||||
AUDIO: <Music className="h-4 w-4 text-purple-400" />,
|
||||
PDF: <FileText className="h-4 w-4 text-red-500" />,
|
||||
MARKDOWN: <FileText className="h-4 w-4 text-blue-400" />,
|
||||
HTML: <FileText className="h-4 w-4 text-orange-400" />,
|
||||
IMAGE: <ImageIcon className="h-4 w-4 text-green-400" />,
|
||||
OTHER: <FileText className="h-4 w-4 text-muted-foreground" />,
|
||||
}
|
||||
|
||||
const lessonTypeColors: Record<string, string> = {
|
||||
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 (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header backHref="/" backLabel="Dashboard" coffeeUrl="https://ko-fi.com/nicetry247" />
|
||||
|
||||
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
||||
<section className="mb-8">
|
||||
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight mb-2 text-gradient-primary">
|
||||
Learning Analytics
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Your personal learning insights and progress overview.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Overview Cards */}
|
||||
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 mb-8">
|
||||
<Card className="glass-card-elevated">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<BookOpen className="h-4 w-4 text-accent" />
|
||||
Total Courses
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-end justify-between">
|
||||
<div>
|
||||
<p className="text-3xl font-bold">{data.totalCourses}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{data.completedCourses} completed · {data.inProgressCourses} in progress
|
||||
</p>
|
||||
<Progress
|
||||
value={data.totalCourses > 0 ? Math.round((data.completedCourses / data.totalCourses) * 100) : 0}
|
||||
className="h-1.5 mt-2 gradient-progress"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-primary/15 flex items-center justify-center">
|
||||
<Award className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="glass-card-elevated">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
Lessons Completed
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-end justify-between">
|
||||
<div>
|
||||
<p className="text-3xl font-bold">{data.completedLessons}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{data.lessonCompletionRate}% completion rate
|
||||
</p>
|
||||
<Progress value={data.lessonCompletionRate} className="h-1.5 mt-2 gradient-progress" />
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-green-500/15 flex items-center justify-center">
|
||||
<CheckCircle2 className="h-6 w-6 text-green-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="glass-card-elevated">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-blue-400" />
|
||||
Total Time Watched
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-end justify-between">
|
||||
<div>
|
||||
<p className="text-3xl font-bold">
|
||||
{totalWatchedHours}h {remainingMinutes}m
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{data.totalWatchedMinutes} minutes total</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-blue-500/15 flex items-center justify-center">
|
||||
<Clock className="h-6 w-6 text-blue-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="glass-card-elevated">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<TrendingUp className="h-4 w-4 text-emerald-400" />
|
||||
This Week
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-end justify-between">
|
||||
<div>
|
||||
<p className="text-3xl font-bold">
|
||||
{data.weeklyWatchedHours}h
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{data.weeklyCompletedLessons} lessons completed
|
||||
</p>
|
||||
<Progress
|
||||
value={Math.min(100, Math.round((data.weeklyWatchedHours / 10) * 100))}
|
||||
className="h-1.5 mt-2 gradient-progress-accent"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-emerald-500/15 flex items-center justify-center">
|
||||
<TrendingUp className="h-6 w-6 text-emerald-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Detailed Breakdown */}
|
||||
<section className="grid gap-6 lg:grid-cols-2 mb-8">
|
||||
{/* Lessons by Type */}
|
||||
<Card className="glass-card-elevated">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Film className="h-5 w-5 text-primary" />
|
||||
Lessons by Type
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{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 (
|
||||
<div key={type} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`flex h-8 w-8 items-center justify-center rounded-lg ${lessonTypeColors[type] || lessonTypeColors.OTHER}`}>
|
||||
{Icon}
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium capitalize">{type.toLowerCase()}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{completed} / {_count} completed
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{percentage}%
|
||||
</Badge>
|
||||
</div>
|
||||
<Progress value={percentage} className="h-2 gradient-progress" />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Course Progress Distribution */}
|
||||
<Card className="glass-card-elevated">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BookOpen className="h-5 w-5 text-accent" />
|
||||
Course Progress Distribution
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full bg-green-500" />
|
||||
Completed
|
||||
</span>
|
||||
<Badge variant="secondary">{data.completedCourses}</Badge>
|
||||
</div>
|
||||
<Progress
|
||||
value={data.totalCourses > 0 ? Math.round((data.completedCourses / data.totalCourses) * 100) : 0}
|
||||
className="h-2.5 gradient-progress"
|
||||
/>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full bg-blue-400" />
|
||||
In Progress
|
||||
</span>
|
||||
<Badge variant="secondary">{data.inProgressCourses}</Badge>
|
||||
</div>
|
||||
<Progress
|
||||
value={data.totalCourses > 0 ? Math.round((data.inProgressCourses / data.totalCourses) * 100) : 0}
|
||||
className="h-2.5 gradient-progress-accent"
|
||||
/>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full bg-muted" />
|
||||
Not Started
|
||||
</span>
|
||||
<Badge variant="secondary">
|
||||
{data.totalCourses - data.completedCourses - data.inProgressCourses}
|
||||
</Badge>
|
||||
</div>
|
||||
<Progress
|
||||
value={data.totalCourses > 0
|
||||
? Math.round(((data.totalCourses - data.completedCourses - data.inProgressCourses) / data.totalCourses) * 100)
|
||||
: 0}
|
||||
className="h-2.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t">
|
||||
<div className="flex items-center justify-between text-sm mb-2">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full bg-green-500" />
|
||||
Lesson Completion
|
||||
</span>
|
||||
<Badge variant="secondary">{data.lessonCompletionRate}%</Badge>
|
||||
</div>
|
||||
<Progress value={data.lessonCompletionRate} className="h-3 gradient-progress" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Time Stats */}
|
||||
<section className="grid gap-6 lg:grid-cols-3">
|
||||
<Card className="glass-card-elevated">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Clock className="h-5 w-5 text-blue-400" />
|
||||
Total Watch Time
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Hours watched</span>
|
||||
<span className="font-semibold text-lg">{totalWatchedHours}h {remainingMinutes}m</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Minutes total</span>
|
||||
<span className="font-semibold">{data.totalWatchedMinutes} min</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Average per lesson</span>
|
||||
<span className="font-semibold">
|
||||
{data.completedLessons > 0
|
||||
? `${Math.round(data.totalWatchedMinutes / data.completedLessons)} min`
|
||||
: '—'}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="glass-card-elevated">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5 text-emerald-400" />
|
||||
This Week Activity
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Hours watched</span>
|
||||
<span className="font-semibold text-lg">{data.weeklyWatchedHours}h</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Minutes</span>
|
||||
<span className="font-semibold">{data.weeklyWatchedMinutes} min</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Lessons completed</span>
|
||||
<span className="font-semibold text-green-500">{data.weeklyCompletedLessons}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="glass-card-elevated">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Play className="h-5 w-5 text-primary" />
|
||||
Lesson Status
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2 text-sm">
|
||||
<span className="w-3 h-3 rounded-full bg-green-500" />
|
||||
Completed
|
||||
</span>
|
||||
<span className="font-semibold text-green-500">{data.completedLessons}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2 text-sm">
|
||||
<span className="w-3 h-3 rounded-full bg-blue-400" />
|
||||
In Progress
|
||||
</span>
|
||||
<span className="font-semibold text-blue-400">{data.inProgressLessons}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2 text-sm">
|
||||
<span className="w-3 h-3 rounded-full bg-muted" />
|
||||
Not Started
|
||||
</span>
|
||||
<span className="font-semibold text-muted-foreground">{data.notStartedLessons}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2 text-sm">
|
||||
<Bookmark className="h-3 w-3" />
|
||||
Total
|
||||
</span>
|
||||
<span className="font-semibold">{data.totalLessons}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<string, number>()
|
||||
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<string>()
|
||||
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<string, {
|
||||
courseId: string
|
||||
courseName: string
|
||||
courseSlug: string
|
||||
completedLessons: number
|
||||
totalLessons: number
|
||||
percentage: number
|
||||
lastWatched: Date
|
||||
}>()
|
||||
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>) : 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 })
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown> = {}
|
||||
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -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:<tagId>
|
||||
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<string, string>()
|
||||
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<string, number>()
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<string | null> => {
|
||||
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<string, string> = {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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<string, number> = {}
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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<QuizSource> {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
@@ -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<string, string> = {}
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -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<string | null>(null)
|
||||
const [quizStatus, setQuizStatus] = React.useState<'idle' | 'running' | 'success' | 'error'>('idle')
|
||||
const [quizMessage, setQuizMessage] = React.useState<string | null>(null)
|
||||
const [quizResults, setQuizResults] = React.useState<{ ok: number; skipped: number } | null>(null)
|
||||
|
||||
const refreshLibrary = async () => {
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
const patchCourse = async (payload: Record<string, unknown>) => {
|
||||
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<string>('')
|
||||
|
||||
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 (
|
||||
<header className="border-b bg-card/50 backdrop-blur-sm">
|
||||
<div className="container mx-auto flex items-center justify-between gap-4 px-4 py-3">
|
||||
<Link href="/" className="flex items-center gap-2 text-muted-foreground transition-colors hover:text-foreground">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
<span className="hidden sm:inline ml-1">Back to Library</span>
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="gap-1">
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Rescan
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="gap-1">
|
||||
<Share2 className="h-4 w-4" />
|
||||
Share
|
||||
</Button>
|
||||
<Link href="/settings">
|
||||
<Button variant="ghost" size="sm" className="gap-1" aria-label="Settings">
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="https://ko-fi.com/nicetry247" target="_blank" rel="noreferrer noopener" className="header-control p-1.5" aria-label="Support on Ko-fi">
|
||||
<img src="/ko-fi-icon.gif" alt="Support on Ko-fi" className="h-8 w-8 object-contain" />
|
||||
</Link>
|
||||
|
||||
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-9 w-9" aria-label={`${course.name} course actions`}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="5" r="1" />
|
||||
<circle cx="12" cy="12" r="1" />
|
||||
<circle cx="12" cy="19" r="1" />
|
||||
</svg>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuItem onSelect={handleToggleFavorite} className="gap-2">
|
||||
<Star className={course.favorited ? 'h-4 w-4 fill-amber-400 text-amber-400' : 'h-4 w-4'} />
|
||||
{course.favorited ? 'Unpin favorite' : 'Pin favorite'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={(event) => { event.preventDefault(); startQuickAction('tag') }} className="gap-2">
|
||||
<Tags className="h-4 w-4" />
|
||||
Add tag
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={(event) => { event.preventDefault(); startQuickAction('category') }} className="gap-2">
|
||||
<FolderPlus className="h-4 w-4" />
|
||||
Add category
|
||||
</DropdownMenuItem>
|
||||
{quickAction && (
|
||||
<div className="px-2 py-2" onClick={(event) => event.stopPropagation()}>
|
||||
<label className="mb-1 block text-xs font-medium text-muted-foreground">
|
||||
{quickAction === 'tag' ? 'New tag' : 'New category'}
|
||||
</label>
|
||||
<input
|
||||
value={quickActionValue}
|
||||
onChange={(event) => 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 && <p className="mb-2 text-xs text-destructive">{quickActionError}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setQuickAction(null)}>Cancel</Button>
|
||||
<Button type="button" size="sm" onClick={submitQuickAction}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DropdownMenuItem onSelect={(event) => { event.preventDefault(); setClearQuizMode(value => !value) }} disabled={quizStatus === 'running'} className="gap-2">
|
||||
{quizStatus === 'running' ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
||||
{clearQuizMode ? 'Cancel' : 'Clear Quiz'}
|
||||
</DropdownMenuItem>
|
||||
{clearQuizMode && (
|
||||
<div className="px-2 py-2" onClick={(event) => event.stopPropagation()}>
|
||||
<label className="mb-1 block text-xs font-medium text-muted-foreground">Module to clear</label>
|
||||
<select
|
||||
value={clearQuizModule}
|
||||
onChange={(event) => setClearQuizModule(event.target.value)}
|
||||
className="mb-2 h-9 w-full rounded-md border border-input bg-background px-2 text-sm outline-none focus:ring-2 focus:ring-primary/40"
|
||||
autoFocus
|
||||
>
|
||||
<option value="">Select module...</option>
|
||||
{modules.map((module: any) => (
|
||||
<option key={module.path || module.name} value={module.name}>
|
||||
{module.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => { setClearQuizMode(false); setClearQuizModule('') }}>Cancel</Button>
|
||||
<Button type="button" size="sm" onClick={clearQuiz} disabled={!clearQuizModule}>Clear</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={handleRegenerateQuiz} disabled={quizStatus === 'running'} className="gap-2">
|
||||
{quizStatus === 'running' ? <Loader2 className="h-4 w-4 animate-spin" /> : <BrainCircuit className="h-4 w-4" />}
|
||||
{quizStatus === 'running' ? 'Regenerating...' : 'Regenerate Quiz'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={handleRename} className="gap-2">
|
||||
<PencilLine className="h-4 w-4" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => handleDelete('library')} className="gap-2 text-destructive focus:text-destructive">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete from library
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => handleDelete('disk')} className="gap-2 text-destructive focus:text-destructive">
|
||||
<Bomb className="h-4 w-4" />
|
||||
Delete from disk
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{status ? <span className={`text-xs ${status.kind === 'success' ? 'text-primary' : 'text-destructive'}`}>{status.text}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(quizStatus === 'success' || quizStatus === 'error') && quizMessage && (
|
||||
<div
|
||||
className={`fixed bottom-4 right-4 z-50 animate-slide-in flex items-center gap-3 px-4 py-3 rounded-lg border shadow-lg min-w-[300px] max-w-md ${
|
||||
quizStatus === 'success'
|
||||
? 'bg-green-500/10 border-green-500/30 text-green-400'
|
||||
: 'bg-red-500/10 border-red-500/30 text-red-400'
|
||||
}`}
|
||||
role="alert"
|
||||
>
|
||||
<span className="flex-1 text-sm">{quizMessage}</span>
|
||||
{quizResults && (
|
||||
<Badge variant="outline" className="gap-1 text-xs">
|
||||
{quizResults.ok} generated
|
||||
{quizResults.skipped > 0 && <span>·</span>}
|
||||
{quizResults.skipped > 0 && <span>{quizResults.skipped} skipped</span>}
|
||||
</Badge>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => { setQuizStatus('idle'); setQuizMessage(null); setQuizResults(null); }}
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading || quizStatus === 'running' ? <Progress value={70} className="h-1 rounded-none gradient-progress" /> : null}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="min-h-screen bg-background">
|
||||
<CourseActions course={course} modules={modules} />
|
||||
|
||||
<section className="border-b bg-card/50 backdrop-blur-sm">
|
||||
<div className="container mx-auto px-4 sm:py-12">
|
||||
<div className="flex flex-col gap-8 lg:flex-row lg:items-center">
|
||||
<div className="aspect-video w-full overflow-hidden rounded-lg bg-muted lg:w-64 lg:flex-shrink-0">
|
||||
{course.thumbnail ? (
|
||||
<img src={course.thumbnail} alt={courseTitle} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-primary/20 to-primary/5">
|
||||
<BookOpen className="h-16 w-16 text-primary/50" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">{modules.length} modules</Badge>
|
||||
<Badge variant="secondary">{course.stats.totalLessons} lessons</Badge>
|
||||
<Badge variant="outline">{formatDuration(course.stats.totalDuration)}</Badge>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold sm:text-4xl">{courseTitle}</h1>
|
||||
{course.description && <p className="text-muted-foreground line-clamp-3">{course.description}</p>}
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span>{course.stats.completedLessons} / {course.stats.totalLessons} lessons completed</span>
|
||||
<span className="font-semibold text-primary">{course.stats.percentage}%</span>
|
||||
</div>
|
||||
<Progress value={course.stats.percentage} className="mt-2 h-3" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<main className="container mx-auto grid grid-cols-1 gap-6 px-4 py-8 lg:grid-cols-4">
|
||||
<Card className="h-fit lg:sticky lg:top-20">
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<BookOpen className="h-5 w-5" />
|
||||
Course Content
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="max-h-[calc(100vh-12rem)] overflow-y-auto p-0">
|
||||
<div className="p-4">
|
||||
<ModuleAccordion courseId={course.id} modules={modules} currentLessonId={course.stats.lastLessonId || undefined} courseSlug={course.slug} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<section className="lg:col-span-3">
|
||||
<Card>
|
||||
<CardContent className="space-y-4 py-10 text-center">
|
||||
{course.stats.lastLessonId ? (
|
||||
<>
|
||||
<Play className="mx-auto h-12 w-12 text-primary/50" />
|
||||
<h3 className="text-lg font-semibold">Ready to continue?</h3>
|
||||
<p className="text-sm text-muted-foreground">You left off at <strong>Lesson {(modules.flatMap((module: any) => module.lessons).find((lesson: any) => lesson.id === course.stats.lastLessonId)?.order ?? 0) + 1}</strong></p>
|
||||
<Link href={`/watch/${course.stats.lastLessonId}?course=${course.slug}`}>
|
||||
<Button className="gap-2">
|
||||
<Play className="h-5 w-5" />
|
||||
Continue Watching
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<BookOpen className="mx-auto h-12 w-12 text-primary/50" />
|
||||
<h3 className="text-lg font-semibold">Start Learning</h3>
|
||||
<p className="text-sm text-muted-foreground">Select a lesson from the sidebar to begin.</p>
|
||||
{modules[0]?.lessons[0] && (
|
||||
<Link href={`/watch/${modules[0].lessons[0].id}?course=${course.slug}`}>
|
||||
<Button className="gap-2">
|
||||
<Play className="h-5 w-5" />
|
||||
Start First Lesson
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function HealthPage() {
|
||||
redirect('/progress')
|
||||
}
|
||||
@@ -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 (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<link rel="icon" href="/favicon16x16.ico" sizes="16x16" type="image/x-icon" />
|
||||
<link rel="icon" href="/icon-192.png" sizes="192x192" type="image/png" />
|
||||
<link rel="icon" href="/icon-512.png" sizes="512x512" type="image/png" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
|
||||
<meta name="theme-color" content="#10b981" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
|
||||
</head>
|
||||
<body className={`${inter.variable} font-sans antialiased min-h-screen bg-background`}>
|
||||
<Providers>{children}</Providers>
|
||||
<Script id="sw-registration" strategy="lazyOnload">
|
||||
{`if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js')
|
||||
.then(reg => console.log('SW registered:', reg.scope))
|
||||
.catch(err => console.log('SW registration failed:', err))
|
||||
}`}
|
||||
</Script>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -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<any[]>([])
|
||||
const [completedCourses, setCompletedCourses] = React.useState<any[]>([])
|
||||
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 (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header showSearchFilter={false} showScanButtons={true} coffeeUrl="https://ko-fi.com/nicetry247" />
|
||||
<main className="container mx-auto px-4 py-6">
|
||||
<div className="flex items-center justify-center min-h-[40vh]">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-10 h-10 border-3 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-muted-foreground">Loading courses...</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (courses.length === 0) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header showSearchFilter={false} showScanButtons={true} coffeeUrl="https://ko-fi.com/nicetry247" />
|
||||
<main className="container mx-auto px-4 py-6">
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center">
|
||||
<div className="mb-6 p-8 bg-muted/50 rounded-full glass-card">
|
||||
<BookOpen className="h-16 w-16 text-muted-foreground/50 mx-auto" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold mb-2">No courses found</h2>
|
||||
<p className="text-muted-foreground mb-6 max-w-md">
|
||||
Add course folders to your <code className="bg-muted px-1.5 py-0.5 rounded">My_Courses/</code> directory, following the scan page folder structure, then scan to populate your library.
|
||||
</p>
|
||||
<Link href="/scan">
|
||||
<Button size="lg" className="gap-2 btn-premium-primary">
|
||||
<Plus className="h-5 w-5" />
|
||||
Scan for Courses
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header showSearchFilter={false} showScanButtons={true} coffeeUrl="https://ko-fi.com/nicetry247" />
|
||||
|
||||
{/* Stats Bar */}
|
||||
<div className="border-b border-white/5">
|
||||
<div className="container mx-auto px-4 py-3">
|
||||
<div className="flex flex-wrap items-center gap-6 text-sm">
|
||||
{/* Search/Filter bar */}
|
||||
<div className="flex items-center gap-2 flex-1 max-w-md">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search courses..."
|
||||
value={search}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<Select value={filter} onValueChange={setFilter}>
|
||||
<SelectTrigger className="bg-background border border-white/10 rounded-lg px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 w-[160px]">
|
||||
<SelectValue placeholder="All Courses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Courses</SelectItem>
|
||||
<SelectItem value="favorites">Pinned Favorites</SelectItem>
|
||||
<SelectItem value="in-progress">In Progress</SelectItem>
|
||||
<SelectItem value="completed">Completed</SelectItem>
|
||||
<SelectItem value="not-started">Not Started</SelectItem>
|
||||
{categoryTags.length > 0 && (
|
||||
<>
|
||||
{categoryTags.map((tag: any) => (
|
||||
<SelectItem key={tag.id} value={`tag:${tag.id}`}>
|
||||
Category: {tag.name.replace(/^category:/, '')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{plainTags.length > 0 && (
|
||||
<>
|
||||
{plainTags.map((tag: any) => (
|
||||
<SelectItem key={tag.id} value={`tag:${tag.id}`}>
|
||||
Tag: {tag.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Sort dropdown */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
<SelectTrigger className="bg-background border border-white/10 rounded-lg px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 w-[160px]">
|
||||
<SelectValue placeholder="Sort" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="updatedAt">Recently Updated</SelectItem>
|
||||
<SelectItem value="name">Name A-Z</SelectItem>
|
||||
<SelectItem value="progress">Progress</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="header-control"
|
||||
onClick={toggleSortOrder}
|
||||
aria-label="Sort"
|
||||
>
|
||||
{sortOrder === 'asc' ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex flex-wrap items-center gap-4 ml-auto">
|
||||
<div className="stat-badge">
|
||||
<span className="stat-badge-icon"><BarChart3 className="h-4 w-4" /></span>
|
||||
<span>{pagination.total} courses</span>
|
||||
</div>
|
||||
<div className="stat-badge">
|
||||
<span className="stat-badge-icon-accent"><Zap className="h-4 w-4" /></span>
|
||||
<span>{courses.reduce((sum, c) => sum + c._count.lessons, 0)} lessons</span>
|
||||
</div>
|
||||
<div className="stat-badge">
|
||||
<span className="stat-badge-icon-green"><CheckCircle className="h-4 w-4" /></span>
|
||||
<span className="text-green-400">{courses.filter(c => c.progress.percentage === 100 && c._count.lessons > 0).length} completed</span>
|
||||
</div>
|
||||
<div className="stat-badge">
|
||||
<span className="stat-badge-icon-blue"><Play className="h-4 w-4" /></span>
|
||||
<span className="text-blue-400">{courses.filter(c => c.progress.percentage > 0 && c.progress.percentage < 100).length} in progress</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="container mx-auto px-4 py-6">
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-destructive/10 border border-destructive/20 rounded-lg text-destructive flex items-center justify-between">
|
||||
<span>{error}</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Continue Watching */}
|
||||
{!cwLoading && continueWatching.length > 0 && search.trim() === '' && (
|
||||
<section className="mb-8" aria-labelledby="continue-watching-heading">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 id="continue-watching-heading" className="text-xl font-semibold flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/15 flex items-center justify-center">
|
||||
<Play className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
Continue Watching
|
||||
</h2>
|
||||
</div>
|
||||
<ScrollArea type="always" className="h-auto">
|
||||
<div className="flex gap-4 pb-4 -mx-4 px-4" style={{ scrollSnapType: 'x mandatory' }} role="list">
|
||||
{continueWatching.slice(0, 1).map((item, index) => {
|
||||
const courseTitle = getCourseDisplayName(item.course)
|
||||
const thumbClass = getVideoThumb(item.lesson.type, courseTitle)
|
||||
return (
|
||||
<Link
|
||||
key={item.lesson.id + '-' + index}
|
||||
href={'/watch/' + item.lesson.id + '?course=' + item.course.slug}
|
||||
className="flex-none w-72 sm:w-80 snap-start group"
|
||||
>
|
||||
<Card className="glass-card-elevated h-full overflow-hidden transition-all hover:glow-primary group">
|
||||
<div className="relative aspect-video bg-muted overflow-hidden">
|
||||
<div className={'video-thumb ' + thumbClass + ' w-full h-full transition-transform duration-500 group-hover:scale-105'} />
|
||||
<div className="thumb-overlay">
|
||||
<button className="play-button-glow" aria-label="Resume">
|
||||
<Play className="h-8 w-8 ml-1" />
|
||||
</button>
|
||||
</div>
|
||||
{item.progress.position && item.lesson.duration && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-black/50">
|
||||
<div
|
||||
className="h-full bg-primary transition-all"
|
||||
style={{ width: Math.min(100, (item.progress.position / item.lesson.duration) * 100) + '%' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<p className="text-xs text-muted-foreground mb-1 truncate">{courseTitle}</p>
|
||||
<h3 className="font-semibold text-sm mb-2 line-clamp-1 text-foreground">{item.lesson.title}</h3>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{item.lesson.duration
|
||||
? formatTime(item.progress.position || 0) + ' / ' + formatTime(item.lesson.duration)
|
||||
: formatTime(item.progress.position || 0)}
|
||||
</span>
|
||||
{item.lesson.duration && (
|
||||
<Progress
|
||||
value={Math.min(100, ((item.progress.position || 0) / item.lesson.duration) * 100)}
|
||||
className="h-1.5 flex-1 gradient-progress"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Completed Courses */}
|
||||
{!ccLoading && completedCourses.length > 0 && (
|
||||
<section className="mb-8" aria-labelledby="completed-heading">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 id="completed-heading" className="text-xl font-semibold flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-green-500/15 flex items-center justify-center">
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
</div>
|
||||
Completed Courses
|
||||
</h2>
|
||||
</div>
|
||||
<ScrollArea type="always" className="h-auto">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{completedCourses.map((course) => {
|
||||
const courseTitle = getCourseDisplayName(course)
|
||||
return (
|
||||
<Link key={course.id} href={'/course/' + course.slug} className="group">
|
||||
<Card className="glass-card border-green-500/30 bg-green-500/5 hover:border-green-500/50 hover:bg-green-500/10 transition-all">
|
||||
<CardContent className="py-3 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-500 flex-shrink-0" />
|
||||
<span className="font-medium text-sm truncate max-w-[200px]">{courseTitle}</span>
|
||||
<span className="ml-auto text-xs text-green-500 font-medium">100%</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Your Library */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-accent/15 flex items-center justify-center">
|
||||
<BookOpen className="h-5 w-5 text-accent" />
|
||||
</div>
|
||||
Your Library
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={String(pagination.limit)} onValueChange={(e: string) => changeLimit(parseInt(e))}>
|
||||
<SelectTrigger className="bg-background border border-white/10 rounded-lg px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="10">10 per page</SelectItem>
|
||||
<SelectItem value="20">20 per page</SelectItem>
|
||||
<SelectItem value="50">50 per page</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-auto">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5 pb-4">
|
||||
{courses.map((course) => (
|
||||
<CourseCard key={course.id} course={course} onChanged={refetch} />
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{pagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 mt-8">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={prevPage}
|
||||
disabled={!pagination.hasPrev}
|
||||
className="disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-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 (
|
||||
<Button
|
||||
key={pageNum}
|
||||
variant={pageNum === pagination.page ? 'default' : 'ghost'}
|
||||
size="icon"
|
||||
onClick={() => goToPage(pageNum)}
|
||||
className="w-8 h-8 rounded-lg"
|
||||
>
|
||||
{pageNum}
|
||||
</Button>
|
||||
)
|
||||
})
|
||||
return pages
|
||||
})()}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={nextPage}
|
||||
disabled={!pagination.hasNext}
|
||||
className="disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
aria-label="Next page"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-center text-sm text-muted-foreground mt-4">
|
||||
Page {pagination.page} of {pagination.totalPages} — {pagination.total} courses total
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<main className="min-h-screen bg-background text-foreground">
|
||||
<div className="container mx-auto px-4 py-8 sm:py-10 max-w-7xl">
|
||||
<section className="glass-card rounded-2xl p-6 sm:p-8 mb-6">
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="max-w-3xl">
|
||||
<div className="flex flex-wrap items-center gap-2 mb-4">
|
||||
<Badge variant="secondary" className="gap-1.5">
|
||||
<ShieldCheck className="h-3.5 w-3.5" />
|
||||
Internal route
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="gap-1.5">
|
||||
<Database className="h-3.5 w-3.5" />
|
||||
Local-only
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="gap-1.5">
|
||||
<Zap className="h-3.5 w-3.5" />
|
||||
Fast stack
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl sm:text-5xl font-bold tracking-tight mb-3 text-gradient-primary">
|
||||
OfflineAcademy Progress
|
||||
</h1>
|
||||
<p className="text-base sm:text-lg text-muted-foreground max-w-2xl">
|
||||
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 <code className="bg-muted px-1.5 py-0.5 rounded">/progress</code>.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mt-5">
|
||||
{stackBadges.map((item) => (
|
||||
<Badge key={item} variant="outline" className="bg-secondary/60">
|
||||
{item}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="w-full lg:w-[360px] glass-card-elevated">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Gauge className="h-5 w-5 text-primary" />
|
||||
Current Build Progress
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-sm mb-2">
|
||||
<span className="text-muted-foreground">Live core complete</span>
|
||||
<span className="font-semibold text-primary">{currentCompletion}%</span>
|
||||
</div>
|
||||
<Progress value={currentCompletion} className="h-3 gradient-progress" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="rounded-xl border border-white/5 bg-secondary/30 p-3">
|
||||
<div className="text-muted-foreground">Live feature groups</div>
|
||||
<div className="text-2xl font-bold mt-1">{liveGroups.length}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/5 bg-secondary/30 p-3">
|
||||
<div className="text-muted-foreground">Roadmap phases</div>
|
||||
<div className="text-2xl font-bold mt-1">{roadmapPhases.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 lg:grid-cols-2">
|
||||
<Card className="glass-card-elevated">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xl">
|
||||
<CheckCircle2 className="h-5 w-5 text-primary" />
|
||||
What’s Live Today
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-2">
|
||||
{liveGroups.map((group) => {
|
||||
const Icon = group.icon
|
||||
return (
|
||||
<div key={group.title} className="rounded-xl border border-white/5 bg-secondary/30 p-4">
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/15 text-primary">
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="font-semibold">{group.title}</h3>
|
||||
<p className="text-xs text-muted-foreground">{group.status}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="secondary">{group.completion}%</Badge>
|
||||
</div>
|
||||
<Progress value={group.completion} className="h-2 gradient-progress" />
|
||||
<ul className="mt-3 space-y-2 text-sm text-muted-foreground">
|
||||
{group.items.map((item) => (
|
||||
<li key={item} className="flex gap-2">
|
||||
<BadgeCheck className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="glass-card-elevated">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xl">
|
||||
<Layers className="h-5 w-5 text-accent" />
|
||||
Roadmap Phases
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{roadmapPhases.map((phase) => (
|
||||
<div key={phase.title} className="rounded-xl border border-white/5 bg-secondary/30 p-4">
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.22em] text-muted-foreground">{phase.phase}</div>
|
||||
<h3 className="font-semibold text-lg">{phase.title}</h3>
|
||||
</div>
|
||||
<Badge variant="outline">{phase.completion}%</Badge>
|
||||
</div>
|
||||
<Progress value={phase.completion} className="h-2 gradient-progress-accent" />
|
||||
<ul className="mt-3 space-y-2 text-sm text-muted-foreground">
|
||||
{phase.items.map((item) => (
|
||||
<li key={item.label} className="flex gap-2">
|
||||
{item.done ? (
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
|
||||
) : (
|
||||
<Clock3 className="mt-0.5 h-4 w-4 shrink-0 text-accent" />
|
||||
)}
|
||||
<span className={item.done ? 'text-foreground' : ''}>{item.label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="mt-6 grid gap-6 lg:grid-cols-2">
|
||||
<Card className="glass-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xl">
|
||||
<Tag className="h-5 w-5 text-primary" />
|
||||
Future Ideas
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 sm:grid-cols-2">
|
||||
{futureIdeas.map((idea) => {
|
||||
const Icon = idea.icon
|
||||
return (
|
||||
<div key={idea.title} className="rounded-xl border border-white/5 bg-secondary/25 p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent/15 text-accent">
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
<h3 className="font-semibold">{idea.title}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{idea.note}</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="glass-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xl">
|
||||
<BookOpen className="h-5 w-5 text-primary" />
|
||||
Product Constraints
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm text-muted-foreground">
|
||||
<p className="flex gap-2"><CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-primary" />No authentication, no accounts, no user profiles.</p>
|
||||
<p className="flex gap-2"><CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-primary" />Everything stays local: folder scanning, SQLite, and file serving.</p>
|
||||
<p className="flex gap-2"><CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-primary" />The app is built to stay fast as more courses are added.</p>
|
||||
<p className="flex gap-2"><CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-primary" />This route is a direct internal progress page, not a dashboard menu item.</p>
|
||||
<div className="pt-3 flex flex-wrap gap-2">
|
||||
<Badge variant="secondary" className="gap-1.5"><Zap className="h-3.5 w-3.5" />Playback speed & shortcuts: Live</Badge>
|
||||
<Badge variant="secondary" className="gap-1.5"><PictureInPicture2 className="h-3.5 w-3.5" />PiP: Live</Badge>
|
||||
<Badge variant="secondary" className="gap-1.5"><HelpCircle className="h-3.5 w-3.5" />Quizzes: Live</Badge>
|
||||
<Badge variant="secondary" className="gap-1.5"><Download className="h-3.5 w-3.5" />Export planned</Badge>
|
||||
<Badge variant="secondary" className="gap-1.5"><Upload className="h-3.5 w-3.5" />Import planned</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<ThemeProvider>
|
||||
<ToastProvider>
|
||||
{showSplash && (
|
||||
<SplashScreen onComplete={() => setShowSplash(false)} minDuration={1500} />
|
||||
)}
|
||||
{!showSplash && children}
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
@@ -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<string[]>([])
|
||||
|
||||
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 (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header showScanButtons={true} onQuickScan={handleScan} coffeeUrl="https://ko-fi.com/nicetry247" />
|
||||
<main className="container mx-auto px-4 py-8 max-w-3xl">
|
||||
<div className="text-center mb-8">
|
||||
<div className="mx-auto mb-4 p-3 bg-primary/10 rounded-full w-fit">
|
||||
<Database className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold mb-2">Scan Courses</h1>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto">
|
||||
Scans your <code className="bg-muted px-1.5 py-0.5 rounded">My_Courses/</code> directory for new content.
|
||||
Courses are detected from the folder tree below.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FolderOpen className="h-5 w-5" />
|
||||
Start Scan
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Recursively scans for courses, modules, and lessons. Updates existing records, adds new ones, and keeps renamed display titles.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
onClick={handleScan}
|
||||
disabled={scanning}
|
||||
className="w-full gap-2"
|
||||
size="lg"
|
||||
>
|
||||
{scanning ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
Scanning...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="h-5 w-5" />
|
||||
Start Scan
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground mt-2 text-center">
|
||||
Quick scan skips video metadata for speed. For full scan with metadata, use the Scan page.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{result && (
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{result.errors.length > 0 ? (
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
) : (
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
)}
|
||||
Scan Results
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div className="p-4 bg-muted rounded-lg">
|
||||
<p className="text-3xl font-bold text-primary">{result.coursesCreated + result.coursesUpdated}</p>
|
||||
<p className="text-sm text-muted-foreground">Courses</p>
|
||||
</div>
|
||||
<div className="p-4 bg-muted rounded-lg">
|
||||
<p className="text-3xl font-bold text-primary">{result.modulesCreated + result.modulesUpdated}</p>
|
||||
<p className="text-sm text-muted-foreground">Modules</p>
|
||||
</div>
|
||||
<div className="p-4 bg-muted rounded-lg">
|
||||
<p className="text-3xl font-bold text-primary">{result.lessonsCreated + result.lessonsUpdated}</p>
|
||||
<p className="text-sm text-muted-foreground">Lessons</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Completed in {result.duration}ms
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{logs.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Play className="h-5 w-5" />
|
||||
Scan Logs
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="bg-muted p-4 rounded font-mono text-sm max-h-64 overflow-y-auto">
|
||||
{logs.map((log, i) => (
|
||||
<div key={i} className="text-muted-foreground mb-1">{log}</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="mt-6 border-dashed">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FolderOpen className="h-5 w-5" />
|
||||
Expected Folder Structure
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="bg-muted p-4 rounded text-sm overflow-x-auto text-left leading-6 whitespace-pre">
|
||||
{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
|
||||
</pre>
|
||||
<div className="mt-3 space-y-2 text-sm text-muted-foreground">
|
||||
<p><strong>How the scanner reads it:</strong> course folder → module folder → lesson files.</p>
|
||||
<p><strong>Matching rule:</strong> subtitle files should share the same base name as the video, like <code className="bg-background px-1.5 py-0.5 rounded">lesson.mp4</code> + <code className="bg-background px-1.5 py-0.5 rounded">lesson.srt</code>.</p>
|
||||
<p><strong>Sorting:</strong> numeric prefixes like <code className="bg-background px-1.5 py-0.5 rounded">01</code>, <code className="bg-background px-1.5 py-0.5 rounded">02</code>, <code className="bg-background px-1.5 py-0.5 rounded">10</code> keep lessons in learning order.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header coffeeUrl="https://ko-fi.com/nicetry247" />
|
||||
<main className="container mx-auto px-4 py-8 max-w-2xl">
|
||||
<div className="text-center mb-8">
|
||||
<div className="mx-auto mb-4 p-3 bg-primary/10 rounded-full w-fit">
|
||||
<FolderOpen className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold mb-2">Settings</h1>
|
||||
<p className="text-muted-foreground">Configure your OfflineAcademy preferences</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FolderOpen className="h-5 w-5" />
|
||||
Courses Directory
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
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.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSave} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="coursesRoot">Courses Root Path</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="coursesRoot"
|
||||
placeholder="/absolute/path/to/courses"
|
||||
value={coursesRoot}
|
||||
onChange={(e) => setCoursesRoot(e.target.value)}
|
||||
className="flex-1"
|
||||
disabled={saving}
|
||||
/>
|
||||
<Button type="submit" disabled={saving} className="gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" style={{ display: saving ? 'block' : 'none' }} />
|
||||
<Save className="h-4 w-4" style={{ display: saving ? 'none' : 'block' }} />
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Current default: ./My_Courses</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-6 space-y-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Key className="h-5 w-5 text-primary" />
|
||||
<h3 className="text-lg font-semibold">Quiz Settings</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="quizApiSource">Quiz API Source</Label>
|
||||
<Select value={quizApiSource} onValueChange={setQuizApiSource}>
|
||||
<SelectTrigger id="quizApiSource" disabled={saving}>
|
||||
<SelectValue placeholder="Select quiz API" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="quizapi">QuizAPI (requires API key)</SelectItem>
|
||||
<SelectItem value="the-trivia-api">The Trivia API (no key needed)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">Source used when auto-fetching quiz content.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<Label htmlFor="autoFetchQuizzes">Auto-Fetch Quizzes</Label>
|
||||
<p className="text-xs text-muted-foreground">Fetch quiz data when a module is opened.</p>
|
||||
</div>
|
||||
<input
|
||||
id="autoFetchQuizzes"
|
||||
type="checkbox"
|
||||
className="h-4 w-4"
|
||||
checked={autoFetchQuizzes}
|
||||
onChange={(event) => setAutoFetchQuizzes(event.target.checked)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{quizApiSource === 'quizapi' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="quizApiKey">QuizAPI Key <span className="text-xs text-muted-foreground font-normal">(secret)</span></Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="quizApiKey"
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
placeholder="Enter your QuizAPI key"
|
||||
value={quizApiKey}
|
||||
onChange={(e) => setQuizApiKey(e.target.value)}
|
||||
className="pr-10"
|
||||
disabled={saving}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 h-8 w-8 p-0"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
aria-label={showApiKey ? 'Hide API key' : 'Show API key'}
|
||||
>
|
||||
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Get your key at <a href="https://quizapi.io" target="_blank" rel="noopener noreferrer" className="underline hover:text-primary">quizapi.io</a>. Stored securely in local database.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-3 bg-muted/50 rounded-lg text-sm text-muted-foreground">
|
||||
<p className="font-medium mb-1">Quiz settings</p>
|
||||
<p>Auto-fetch is {autoFetchQuizzes ? 'enabled' : 'disabled'} with source <code className="bg-background px-2 py-1 rounded">{quizApiSource}</code>.</p>
|
||||
{quizApiSource === 'quizapi' && quizApiKey && (
|
||||
<p className="mt-1 text-green-500">✓ QuizAPI key configured ({quizApiKey.length} characters)</p>
|
||||
)}
|
||||
{quizApiSource === 'quizapi' && !quizApiKey && (
|
||||
<p className="mt-1 text-amber-500">⚠ QuizAPI selected but no API key configured. Quizzes will fall back to The Trivia API.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={saving} className="gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" style={{ display: saving ? 'block' : 'none' }} />
|
||||
<Save className="h-4 w-4" style={{ display: saving ? 'none' : 'block' }} />
|
||||
{saving ? 'Saving...' : 'Save settings'}
|
||||
</Button>
|
||||
|
||||
{status === 'success' && (
|
||||
<p className="text-sm text-green-500">Settings saved successfully.</p>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<p className="text-sm text-red-500">{statusMessage}</p>
|
||||
)}
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<string, React.ReactNode> = {
|
||||
VIDEO: <Film className="h-4 w-4 text-red-400" />,
|
||||
AUDIO: <Music className="h-4 w-4 text-purple-400" />,
|
||||
PDF: <FileText className="h-4 w-4 text-red-500" />,
|
||||
MARKDOWN: <FileText className="h-4 w-4 text-blue-400" />,
|
||||
HTML: <FileText className="h-4 w-4 text-orange-400" />,
|
||||
IMAGE: <ImageIcon className="h-4 w-4 text-green-400" />,
|
||||
QUIZ: <HelpCircle className="h-4 w-4 text-amber-400" />,
|
||||
OTHER: <FileText className="h-4 w-4 text-muted-foreground" />,
|
||||
}
|
||||
|
||||
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 (
|
||||
<VideoPlayer
|
||||
src={'/api/files/' + lesson.filePath}
|
||||
poster={lesson.thumbnail ? '/api/files/' + lesson.thumbnail : undefined}
|
||||
initialTime={lessonProgress?.position || 0}
|
||||
autoPlay={true}
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
onProgressSave={onSave}
|
||||
onEnded={onEnd}
|
||||
seekRequest={seekRequest}
|
||||
subtitles={lesson.subtitlePath ? '/api/files/' + lesson.subtitlePath : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NonVideoContent({ lesson }: { lesson: { type: string; filePath: string; title: string; duration: number | null } }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full bg-muted/50 rounded-lg">
|
||||
<div className="text-center p-8">
|
||||
{lesson.type === 'AUDIO' && (
|
||||
<audio
|
||||
src={'/api/files/' + lesson.filePath}
|
||||
controls
|
||||
className="w-full max-w-2xl mb-4"
|
||||
/>
|
||||
)}
|
||||
{['PDF', 'MARKDOWN', 'HTML', 'JSON', 'TEXT', 'TXT', 'VTT'].includes(lesson.type) && (
|
||||
<iframe
|
||||
src={'/api/files/' + lesson.filePath}
|
||||
className="w-full h-[60vh] rounded border"
|
||||
title={lesson.title}
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
/>
|
||||
)}
|
||||
{lesson.type === 'IMAGE' && (
|
||||
<img
|
||||
src={'/api/files/' + lesson.filePath}
|
||||
alt={lesson.title}
|
||||
className="max-w-full max-h-[70vh] rounded shadow-lg"
|
||||
/>
|
||||
)}
|
||||
{lesson.type === 'OTHER' && (
|
||||
<div className="text-center">
|
||||
<p className="text-muted-foreground">This file type cannot be previewed inline.</p>
|
||||
<a
|
||||
href={'/api/files/' + lesson.filePath}
|
||||
target="_blank"
|
||||
className="text-primary hover:underline mt-2 inline-block"
|
||||
>
|
||||
Open in new tab →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MobileOverlay({ lesson, lessonProgress }: {
|
||||
lesson: { title: string; type: string; duration: number | null }
|
||||
lessonProgress: { completed: boolean; position: number; lastWatched: string }
|
||||
}) {
|
||||
const [visible, setVisible] = React.useState(true)
|
||||
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(() => setVisible(false), 3000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [])
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="lg:hidden absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-black/90 to-transparent pointer-events-none"
|
||||
onTouchStart={() => setVisible(false)}
|
||||
>
|
||||
<div className="max-w-3xl mx-auto pointer-events-none">
|
||||
<h2 className="text-lg font-semibold text-white mb-1">{lesson.title}</h2>
|
||||
<div className="flex items-center gap-3 text-sm text-white/70">
|
||||
<span className="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 text-foreground capitalize">
|
||||
{lesson.type.toLowerCase()}
|
||||
</span>
|
||||
{lesson.duration && lessonProgress && (
|
||||
<>
|
||||
<span>{formatTime(lessonProgress.position)}</span>
|
||||
<span>/</span>
|
||||
<span>{formatTime(lesson.duration)}</span>
|
||||
</>
|
||||
)}
|
||||
{lesson.duration && !lessonProgress && (
|
||||
<span>{'0:00'} / {formatTime(lesson.duration)}</span>
|
||||
)}
|
||||
</div>
|
||||
{(lessonProgress?.position ?? 0) > 0 && lesson.duration && (
|
||||
<Progress
|
||||
value={Math.min(100, ((lessonProgress?.position ?? 0) / lesson.duration) * 100)}
|
||||
className="h-1.5 mt-2"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LessonHeader({ lesson, lessonProgress, prevLesson, nextLesson, course, currentIndex, totalLessons }: {
|
||||
lesson: {
|
||||
id: string
|
||||
title: string
|
||||
slug: string
|
||||
type: string
|
||||
duration: number | null
|
||||
order: number
|
||||
module: { name: string; id: string; courseId?: string }
|
||||
progress?: { completed: boolean; position: number; lastWatched: string } | null
|
||||
}
|
||||
lessonProgress: { completed: boolean; position: number; lastWatched: string }
|
||||
prevLesson: { id: string; courseSlug: string } | null
|
||||
nextLesson: { id: string; courseSlug: string } | null
|
||||
course: { id: string; slug: string; name: string; modules: any[] }
|
||||
currentIndex: number
|
||||
totalLessons: number
|
||||
}) {
|
||||
const allLessons = course.modules.flatMap(m => m.lessons)
|
||||
|
||||
return (
|
||||
<div className="hidden lg:block p-4 lg:p-6 border-t bg-card">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{lesson.title}</h2>
|
||||
<div className="flex flex-wrap items-center gap-2 mt-1 text-sm text-muted-foreground">
|
||||
{lessonTypeIcons[lesson.type] || lessonTypeIcons.OTHER}
|
||||
<span className="capitalize">{lesson.type.toLowerCase()}</span>
|
||||
{lesson.duration && <span>• {formatDuration(lesson.duration)}</span>}
|
||||
<span>• Module: {lesson.module.name}</span>
|
||||
<span>• Lesson {lesson.order + 1} of {allLessons.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{prevLesson && (
|
||||
<Link href={`/watch/${prevLesson.id}?course=${course.slug}`}>
|
||||
<Button variant="outline" size="sm" className="gap-1">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{nextLesson && (
|
||||
<Link href={`/watch/${nextLesson.id}?course=${course.slug}`}>
|
||||
<Button variant="outline" size="sm" className="gap-1">
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{lessonProgress?.position && lesson.duration && (
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between text-sm text-muted-foreground mb-1">
|
||||
<span>{formatTime(lessonProgress.position)} / {formatTime(lesson.duration)}</span>
|
||||
<span>{Math.round(Math.min(100, (lessonProgress.position / lesson.duration) * 100))}%</span>
|
||||
</div>
|
||||
<Progress value={Math.min(100, (lessonProgress.position / lesson.duration) * 100)} className="h-2" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function WatchPageClient({ initialData }: WatchPageClientProps) {
|
||||
const { lesson, course, prevLesson, nextLesson, currentIndex, totalLessons } = initialData
|
||||
const isVideo = lesson.type === 'VIDEO'
|
||||
const isQuiz = lesson.type === 'QUIZ'
|
||||
const allLessons = course.modules.flatMap(m => m.lessons)
|
||||
|
||||
const [lessonProgress, setLessonProgress] = useState(lesson.progress || {
|
||||
completed: false,
|
||||
position: 0,
|
||||
lastWatched: new Date().toISOString(),
|
||||
})
|
||||
const [currentTime, setCurrentTime] = useState(lesson.progress?.position || 0)
|
||||
const [bookmarkSeekRequest, setBookmarkSeekRequest] = useState<{ time: number; nonce: number } | null>(null)
|
||||
|
||||
const currentModule = course.modules.find(m => m.id === lesson.moduleId) || null
|
||||
|
||||
// Sync progress when initialData changes (e.g., after auto-next navigation)
|
||||
useEffect(() => {
|
||||
setLessonProgress(lesson.progress || { completed: false, position: 0, lastWatched: new Date().toISOString() })
|
||||
}, [lesson.id, lesson.progress])
|
||||
|
||||
function onSave(position: number) {
|
||||
saveProgressFn(lesson.id, course.id, lesson.moduleId, position)
|
||||
.then(() => setLessonProgress(p => ({ ...p, position, lastWatched: new Date().toISOString() })))
|
||||
.catch(e => console.error('Failed to save progress:', e))
|
||||
}
|
||||
|
||||
function onEnd() {
|
||||
markCompleteFn(lesson.id, course.id, lesson.moduleId, lesson.duration || 0)
|
||||
.then(() => setLessonProgress(p => ({ ...p, completed: true, position: lesson.duration || 0 })))
|
||||
.catch(e => console.error('Failed to mark complete:', e))
|
||||
if (nextLesson) window.location.href = `/watch/${nextLesson.id}?course=${course.slug}`
|
||||
}
|
||||
|
||||
const handlePrevLesson = useCallback(() => {
|
||||
if (prevLesson) {
|
||||
window.location.href = `/watch/${prevLesson.id}?course=${course.slug}`
|
||||
}
|
||||
}, [prevLesson, course.slug])
|
||||
|
||||
const handleNextLesson = useCallback(() => {
|
||||
if (nextLesson) {
|
||||
window.location.href = `/watch/${nextLesson.id}?course=${course.slug}`
|
||||
}
|
||||
}, [nextLesson, course.slug])
|
||||
|
||||
const handleBookmarkJump = useCallback((time: number) => {
|
||||
setBookmarkSeekRequest({ time, nonce: Date.now() })
|
||||
setCurrentTime(time)
|
||||
// Clear the seek request after the video player processes it
|
||||
setTimeout(() => setBookmarkSeekRequest(null), 100)
|
||||
}, [])
|
||||
|
||||
const videoPlayer = isVideo ? (
|
||||
<VideoContent
|
||||
lesson={lesson}
|
||||
lessonProgress={lessonProgress}
|
||||
onSave={onSave}
|
||||
onEnd={onEnd}
|
||||
onTimeUpdate={setCurrentTime}
|
||||
seekRequest={bookmarkSeekRequest}
|
||||
/>
|
||||
) : null
|
||||
|
||||
const quizContent = isQuiz ? (
|
||||
lesson.quiz ? (
|
||||
<QuizPlayer
|
||||
quiz={lesson.quiz}
|
||||
lessonId={lesson.id}
|
||||
courseId={course.id}
|
||||
moduleId={lesson.moduleId}
|
||||
/>
|
||||
) : (
|
||||
<Card className="mx-auto w-full max-w-3xl border-border/60 bg-card/80">
|
||||
<CardHeader>
|
||||
<CardTitle>Quiz unavailable</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground">
|
||||
The quiz cache could not be loaded for this lesson yet.
|
||||
Try rescanning the course or open the course page to regenerate the cache.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
) : null
|
||||
|
||||
const nonVideoContent = !isVideo && !isQuiz ? (
|
||||
<NonVideoContent lesson={lesson} />
|
||||
) : null
|
||||
|
||||
const courseTitle = getCourseDisplayName(course)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
<Header backHref={`/course/${course.slug}`} backLabel={courseTitle} coffeeUrl="https://ko-fi.com/nicetry247" />
|
||||
|
||||
<main className="flex-1 flex min-h-0 flex-col lg:flex-row overflow-hidden relative">
|
||||
{/* Main Video/Content Area */}
|
||||
<div className="flex-1 lg:w-3/4 min-w-0 min-h-0 flex flex-col relative">
|
||||
<div className="flex-1 min-h-0 relative bg-black">
|
||||
{/* Video container with proper aspect ratio on mobile */}
|
||||
<div className="w-full aspect-video lg:aspect-auto lg:h-full relative p-4 lg:p-6">
|
||||
{videoPlayer}
|
||||
{quizContent}
|
||||
{nonVideoContent}
|
||||
{isVideo && <MobileOverlay lesson={lesson} lessonProgress={lessonProgress} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lesson Header - Desktop */}
|
||||
<LessonHeader
|
||||
lesson={lesson}
|
||||
lessonProgress={lessonProgress}
|
||||
prevLesson={prevLesson}
|
||||
nextLesson={nextLesson}
|
||||
course={course}
|
||||
currentIndex={currentIndex}
|
||||
totalLessons={totalLessons}
|
||||
/>
|
||||
|
||||
{/* Mobile Navigation Buttons */}
|
||||
<div className="lg:hidden p-4 border-t bg-card flex justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handlePrevLesson}
|
||||
disabled={!prevLesson}
|
||||
className="gap-1"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleNextLesson}
|
||||
disabled={!nextLesson}
|
||||
className="gap-1"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Sidebar - Below Video on Mobile */}
|
||||
<div className="lg:hidden border-t bg-card/50">
|
||||
<div className="p-4 border-b">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-lg flex items-center gap-2">
|
||||
<BookOpen className="h-5 w-5" />
|
||||
Course Content
|
||||
</h3>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{course.stats.percentage}% complete
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all"
|
||||
style={{ width: `${course.stats.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isVideo && currentModule && (
|
||||
<div className="shrink-0 border-b border-primary/30 bg-primary/10 px-4 py-3">
|
||||
<p className="text-xs font-semibold uppercase text-primary">Now Playing</p>
|
||||
<p className="truncate text-sm font-medium">{currentModule.name}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{lesson.title}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4 space-y-4 max-h-[50vh] overflow-y-auto custom-scrollbar">
|
||||
{isVideo && (
|
||||
<LessonBookmarks
|
||||
lessonId={lesson.id}
|
||||
courseId={course.id}
|
||||
moduleId={lesson.moduleId}
|
||||
currentTime={currentTime}
|
||||
onJumpToTime={handleBookmarkJump}
|
||||
isVideo={isVideo}
|
||||
/>
|
||||
)}
|
||||
<ModuleAccordion
|
||||
courseId={course.id}
|
||||
modules={course.modules}
|
||||
currentLessonId={lesson.id}
|
||||
courseSlug={course.slug}
|
||||
defaultOpenModuleId={lesson.moduleId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar - Desktop */}
|
||||
<aside className="hidden lg:flex lg:w-1/4 border-l bg-card/50 flex-col h-[calc(100vh-4rem)] min-h-0 overscroll-y-contain">
|
||||
<div className="p-4 border-b shrink-0">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-lg flex items-center gap-2">
|
||||
<BookOpen className="h-5 w-5" />
|
||||
Course Content
|
||||
</h3>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{course.stats.percentage}% complete
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all"
|
||||
style={{ width: `${course.stats.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isVideo && currentModule && (
|
||||
<div className="shrink-0 border-b border-primary/30 bg-primary/10 px-4 py-3">
|
||||
<p className="text-xs font-semibold uppercase text-primary">Now Playing</p>
|
||||
<p className="truncate text-sm font-medium">{currentModule.name}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{lesson.title}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto overscroll-y-contain p-4 custom-scrollbar space-y-4">
|
||||
{isVideo && (
|
||||
<LessonBookmarks
|
||||
lessonId={lesson.id}
|
||||
courseId={course.id}
|
||||
moduleId={lesson.moduleId}
|
||||
currentTime={currentTime}
|
||||
onJumpToTime={handleBookmarkJump}
|
||||
isVideo={isVideo}
|
||||
/>
|
||||
)}
|
||||
<ModuleAccordion
|
||||
courseId={course.id}
|
||||
modules={course.modules}
|
||||
currentLessonId={lesson.id}
|
||||
courseSlug={course.slug}
|
||||
defaultOpenModuleId={lesson.moduleId}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { notFound } from 'next/navigation'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { WatchPageClient } from './WatchPageClient'
|
||||
import { ensureModuleQuizCache } from '@/lib/quiz'
|
||||
import { join } from 'path'
|
||||
|
||||
interface WatchPageProps {
|
||||
params: Promise<{ lessonId: string }>
|
||||
searchParams: Promise<{ course?: string }>
|
||||
}
|
||||
|
||||
export default async function WatchPage({ params, searchParams }: WatchPageProps) {
|
||||
const { lessonId } = await params
|
||||
const { course: courseSlug } = await searchParams
|
||||
|
||||
try {
|
||||
// Find the lesson with its module
|
||||
const lesson = await prisma.lesson.findUnique({
|
||||
where: { id: lessonId },
|
||||
include: {
|
||||
module: true,
|
||||
progress: {
|
||||
where: { userId: 'local-user' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!lesson) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
// Fetch the course
|
||||
const course = await prisma.course.findUnique({
|
||||
where: { id: lesson.module.courseId },
|
||||
})
|
||||
|
||||
if (!course) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const quizSourceSetting = await prisma.setting.findUnique({ where: { key: 'quizApiSource' } })
|
||||
const quizSource = quizSourceSetting?.value === 'the-trivia-api'
|
||||
? 'the-trivia-api'
|
||||
: 'quizapi'
|
||||
const quizCacheResult = lesson.type === 'QUIZ'
|
||||
? await ensureModuleQuizCache({
|
||||
modulePath: join(course.path, lesson.module.name),
|
||||
topic: lesson.module.name,
|
||||
source: quizSource,
|
||||
})
|
||||
: null
|
||||
|
||||
// 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
|
||||
const courseProgress = await prisma.progress.findFirst({
|
||||
where: { userId: 'local-user', courseId: course.id, lessonId: null, moduleId: null },
|
||||
})
|
||||
|
||||
// Calculate all lessons
|
||||
const allLessons = modules.flatMap(m => m.lessons)
|
||||
const currentIndex = allLessons.findIndex(l => l.id === lessonId)
|
||||
const prevLesson = currentIndex > 0
|
||||
? { id: allLessons[currentIndex - 1].id, courseSlug: course.slug }
|
||||
: null
|
||||
const nextLesson = currentIndex < allLessons.length - 1
|
||||
? { id: allLessons[currentIndex + 1].id, courseSlug: course.slug }
|
||||
: 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] ? {
|
||||
completed: lesson.progress[0].completed,
|
||||
position: lesson.progress[0].position,
|
||||
lastWatched: lesson.progress[0].lastWatched.toISOString(),
|
||||
} : null,
|
||||
quiz: quizCacheResult?.quiz || null,
|
||||
},
|
||||
course: {
|
||||
...course,
|
||||
progress: courseProgress ? {
|
||||
...courseProgress,
|
||||
lastWatched: courseProgress.lastWatched.toISOString(),
|
||||
} : null,
|
||||
modules: modules.map(m => ({
|
||||
...m,
|
||||
lessons: m.lessons.map(l => ({
|
||||
...l,
|
||||
progress: l.progress[0] ? {
|
||||
completed: l.progress[0].completed,
|
||||
position: l.progress[0].position,
|
||||
lastWatched: l.progress[0].lastWatched.toISOString(),
|
||||
} : null,
|
||||
})),
|
||||
})),
|
||||
stats: {
|
||||
totalLessons,
|
||||
completedLessons,
|
||||
percentage,
|
||||
},
|
||||
},
|
||||
prevLesson,
|
||||
nextLesson,
|
||||
currentIndex,
|
||||
totalLessons: allLessons.length,
|
||||
}
|
||||
|
||||
return <WatchPageClient initialData={data} />
|
||||
} catch (error) {
|
||||
console.error('WatchPage error:', error)
|
||||
notFound()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
Play,
|
||||
Clock as ClockIcon
|
||||
} from 'iconoir-react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { formatTime } from '@/lib/utils'
|
||||
import { getCourseDisplayName } from '@/lib/course-display'
|
||||
|
||||
interface ContinueWatchingCardProps {
|
||||
item: {
|
||||
lesson: {
|
||||
id: string
|
||||
title: string
|
||||
type: string
|
||||
duration: number | null
|
||||
thumbnail: string | null
|
||||
}
|
||||
progress: {
|
||||
position: number
|
||||
lastWatched: string
|
||||
}
|
||||
course: {
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
module: {
|
||||
name: string
|
||||
}
|
||||
}
|
||||
index: number
|
||||
}
|
||||
|
||||
export function ContinueWatchingCard({ item, index }: ContinueWatchingCardProps) {
|
||||
const { lesson, progress, course, module } = item
|
||||
const courseTitle = getCourseDisplayName(course)
|
||||
const percentage = lesson.duration && progress.position
|
||||
? Math.min(100, (progress.position / lesson.duration) * 100)
|
||||
: 0
|
||||
|
||||
// Determine video thumbnail style based on course/module
|
||||
const getThumbClass = () => {
|
||||
const name = (courseTitle + ' ' + module.name).toLowerCase()
|
||||
if (name.includes('linux') || name.includes('terraform') || name.includes('shell')) return 'video-thumb-linux'
|
||||
if (name.includes('code') || name.includes('program') || name.includes('javascript') || name.includes('python')) return 'video-thumb-code'
|
||||
if (name.includes('automat') || name.includes('workflow') || name.includes('n8n')) return 'video-thumb-flow'
|
||||
return 'video-thumb-presenter'
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/watch/${lesson.id}?course=${course.slug}`}
|
||||
key={`${lesson.id}-${index}`}
|
||||
className="flex-none w-72 sm:w-80 snap-start group"
|
||||
>
|
||||
<Card className="glass-card-elevated h-full overflow-hidden transition-all hover:shadow-[0_0_30px_-10px_hsl(160_100%_42%_/_0.25)] group">
|
||||
{/* Video Thumbnail */}
|
||||
<div className="relative aspect-video bg-muted overflow-hidden">
|
||||
<div className={`video-thumb ${getThumbClass()} w-full h-full transition-transform duration-500 group-hover:scale-[1.02]`} />
|
||||
|
||||
{/* Duration badge */}
|
||||
{lesson.duration && (
|
||||
<div className="absolute bottom-2 right-2 px-2 py-1 bg-black/80 backdrop-blur-sm rounded text-xs font-medium text-white">
|
||||
{formatTime(lesson.duration)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Play overlay */}
|
||||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<button className="relative w-14 h-14 rounded-full bg-accent/90 flex items-center justify-center text-accent-foreground hover:scale-110 hover:bg-accent transition-all duration-300" aria-label="Resume">
|
||||
<Play className="h-7 w-7 ml-1" />
|
||||
<span className="absolute inset-0 rounded-full bg-accent/30 animate-pulse" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress bar overlay */}
|
||||
{progress.position && lesson.duration && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-black/50">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-500"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs text-muted-foreground mb-1 truncate">{courseTitle}</p>
|
||||
<h3 className="font-medium text-sm mb-2 line-clamp-1 text-foreground group-hover:text-primary transition-colors">{lesson.title}</h3>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<ClockIcon className="h-3 w-3" />
|
||||
{lesson.duration
|
||||
? `${formatTime(progress.position || 0)} / ${formatTime(lesson.duration)}`
|
||||
: formatTime(progress.position || 0)}
|
||||
</span>
|
||||
{lesson.duration && (
|
||||
<Progress
|
||||
value={percentage}
|
||||
className="h-1.5 flex-1 gradient-progress"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
interface ContinueWatchingSectionProps {
|
||||
items: Array<{
|
||||
lesson: {
|
||||
id: string
|
||||
title: string
|
||||
type: string
|
||||
duration: number | null
|
||||
thumbnail: string | null
|
||||
}
|
||||
progress: {
|
||||
position: number
|
||||
lastWatched: string
|
||||
}
|
||||
course: {
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
module: {
|
||||
name: string
|
||||
}
|
||||
}>
|
||||
}
|
||||
|
||||
export function ContinueWatchingSection({ items }: ContinueWatchingSectionProps) {
|
||||
if (items.length === 0) return null
|
||||
|
||||
return (
|
||||
<section className="mb-10" aria-labelledby="continue-watching-heading">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 id="continue-watching-heading" className="text-xl font-semibold flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Play className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
Continue Watching
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 pb-4 -mx-4 px-4" style={{ scrollSnapType: 'x mandatory' }} role="list">
|
||||
{items.map((item, index) => (
|
||||
<ContinueWatchingCard key={`${item.lesson.id}-${index}`} item={item} index={index} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
Play,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
EllipsisVertical,
|
||||
PencilLine,
|
||||
Trash2,
|
||||
Bomb,
|
||||
Star,
|
||||
Tags,
|
||||
FolderPlus,
|
||||
BrainCircuit,
|
||||
Loader2,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
VideoCamera,
|
||||
ViewGrid,
|
||||
BookStack,
|
||||
Database,
|
||||
Folder,
|
||||
Code,
|
||||
Computer,
|
||||
Shield,
|
||||
Network,
|
||||
} from 'iconoir-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardFooter } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { getCourseThumbnailClient } from '@/lib/thumbnail-index'
|
||||
import { getCourseDisplayName } from '@/lib/course-display'
|
||||
import { getDeterministicGradient } from '@/lib/cover-gradient'
|
||||
|
||||
interface CourseCardProps {
|
||||
course: {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
thumbnail: string | null
|
||||
description: string | null
|
||||
favorited?: boolean
|
||||
tags?: Array<{ id: string; name: string; color?: string | null }>
|
||||
_count?: { modules: number; lessons: number }
|
||||
progress?: {
|
||||
completedLessons: number
|
||||
totalLessons: number
|
||||
percentage: number
|
||||
lastWatched: Date | string | null
|
||||
}
|
||||
}
|
||||
onChanged?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
function coverGradient(title: string): string {
|
||||
return getDeterministicGradient(title)
|
||||
}
|
||||
|
||||
function getCourseIcon(name: string) {
|
||||
const n = name.toLowerCase()
|
||||
if (n.includes('hashicorp') || n.includes('terraform') || n.includes('vault') || n.includes('consul')) return <Shield className="h-5 w-5" />
|
||||
if (n.includes('linux') || n.includes('shell') || n.includes('bash') || n.includes('terminal')) return <Computer className="h-5 w-5" />
|
||||
if (n.includes('code') || n.includes('program') || n.includes('develop') || n.includes('python') || n.includes('javascript')) return <Code className="h-5 w-5" />
|
||||
if (n.includes('automat') || n.includes('n8n') || n.includes('workflow') || n.includes('zapier')) return <Network className="h-5 w-5" />
|
||||
if (n.includes('database') || n.includes('sql') || n.includes('db')) return <Database className="h-5 w-5" />
|
||||
if (n.includes('folder') || n.includes('file') || n.includes('system')) return <Folder className="h-5 w-5" />
|
||||
return <BookStack className="h-5 w-5" />
|
||||
}
|
||||
|
||||
function getCourseIconColor(name: string) {
|
||||
const n = name.toLowerCase()
|
||||
if (n.includes('hashicorp') || n.includes('terraform') || n.includes('vault') || n.includes('consul')) return 'text-primary'
|
||||
if (n.includes('linux') || n.includes('shell') || n.includes('bash') || n.includes('terminal')) return 'text-emerald-400'
|
||||
if (n.includes('code') || n.includes('program') || n.includes('develop') || n.includes('python') || n.includes('javascript')) return 'text-amber-400'
|
||||
if (n.includes('automat') || n.includes('n8n') || n.includes('workflow') || n.includes('zapier')) return 'text-cyan-400'
|
||||
if (n.includes('database') || n.includes('sql') || n.includes('db')) return 'text-blue-400'
|
||||
return 'text-primary'
|
||||
}
|
||||
|
||||
function getCoverClass(name: string) {
|
||||
const n = name.toLowerCase()
|
||||
if (n.includes('hashicorp') || n.includes('terraform') || n.includes('vault') || n.includes('consul')) return 'course-cover-hashicorp'
|
||||
if (n.includes('linux') || n.includes('shell') || n.includes('bash') || n.includes('terminal')) return 'course-cover-linux'
|
||||
if (n.includes('code') || n.includes('program') || n.includes('develop') || n.includes('python') || n.includes('javascript')) return 'course-cover-code'
|
||||
if (n.includes('automat') || n.includes('n8n') || n.includes('workflow') || n.includes('zapier')) return 'course-cover-automation'
|
||||
return 'course-cover-generic'
|
||||
}
|
||||
|
||||
export function CourseCard({ course, onChanged }: CourseCardProps) {
|
||||
const router = useRouter()
|
||||
const [quickAction, setQuickAction] = React.useState<'tag' | 'category' | null>(null)
|
||||
const [quickActionValue, setQuickActionValue] = React.useState('')
|
||||
const [quickActionError, setQuickActionError] = React.useState<string | null>(null)
|
||||
const [quizStatus, setQuizStatus] = React.useState<'idle' | 'running' | 'success' | 'error'>('idle')
|
||||
const [quizMessage, setQuizMessage] = React.useState<string | null>(null)
|
||||
const { progress, _count } = course
|
||||
const courseTitle = getCourseDisplayName(course)
|
||||
const percentage = progress?.percentage || 0
|
||||
const completed = progress?.completedLessons || 0
|
||||
const total = progress?.totalLessons || _count?.lessons || 0
|
||||
const lastWatched = progress?.lastWatched
|
||||
const visibleTags = course.tags || []
|
||||
const categoryTags = visibleTags.filter((tag) => tag.name.startsWith('category:'))
|
||||
const plainTags = visibleTags.filter((tag) => !tag.name.startsWith('category:'))
|
||||
|
||||
const CourseIcon = getCourseIcon(course.name)
|
||||
const iconColor = getCourseIconColor(course.name)
|
||||
const coverClass = getCoverClass(course.name)
|
||||
const thumbnailUrl = getCourseThumbnailClient(course)
|
||||
|
||||
const refreshLibrary = async () => {
|
||||
await onChanged?.()
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
const patchCourse = async (payload: Record<string, unknown>) => {
|
||||
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)
|
||||
window.alert('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 handleRename = async () => {
|
||||
const nextName = window.prompt('Rename course', courseTitle)
|
||||
if (nextName === null) return
|
||||
|
||||
const trimmed = nextName.trim()
|
||||
if (!trimmed || trimmed === courseTitle) 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)
|
||||
window.alert('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 "${courseTitle}" from disk?\n\nThis permanently removes the course folder and all library records. This cannot be undone.`
|
||||
: `Delete "${courseTitle}" 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()
|
||||
} catch (error) {
|
||||
console.error('Delete course failed:', error)
|
||||
window.alert(
|
||||
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.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const clearQuiz = async () => {
|
||||
const confirmed = window.confirm(`Clear all quizzes for "${courseTitle}"? This cannot be undone.`)
|
||||
if (!confirmed) return
|
||||
|
||||
setQuizStatus('running')
|
||||
setQuizMessage('Clearing quizzes...')
|
||||
try {
|
||||
const response = await fetch(`/api/courses/${course.slug}/quiz`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'clear', module: '__all__' }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.error || 'Failed to clear quizzes')
|
||||
}
|
||||
|
||||
await onChanged?.()
|
||||
router.refresh()
|
||||
setQuizStatus('success')
|
||||
setQuizMessage(`Cleared all quizzes for ${courseTitle}`)
|
||||
} catch (error) {
|
||||
console.error('Clear quiz failed:', error)
|
||||
setQuizStatus('error')
|
||||
setQuizMessage(error instanceof Error ? error.message : 'Failed to clear quizzes.')
|
||||
}
|
||||
}
|
||||
|
||||
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 regenerated = data.results?.filter((item: { status: string }) => item.status === 'ok').length
|
||||
await onChanged?.()
|
||||
router.refresh()
|
||||
setQuizStatus('success')
|
||||
setQuizMessage(`QuizAPI regenerated ${regenerated ?? 'the'} module quiz set.`)
|
||||
} catch (error) {
|
||||
console.error('Regenerate quiz failed:', error)
|
||||
setQuizStatus('error')
|
||||
setQuizMessage(error instanceof Error ? error.message : 'Quiz regeneration failed.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="block">
|
||||
<Card className="glass-card-elevated group relative h-full flex flex-col overflow-hidden">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-3 top-3 z-30 h-9 w-9 rounded-full bg-black/40 text-white opacity-0 shadow-lg backdrop-blur-sm transition-all hover:bg-black/60 group-hover:opacity-100 focus:opacity-100"
|
||||
aria-label={`Course actions for ${courseTitle}`}
|
||||
>
|
||||
<EllipsisVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuItem onSelect={handleToggleFavorite} className="gap-2">
|
||||
<Star className={course.favorited ? 'h-4 w-4 fill-amber-400 text-amber-400' : 'h-4 w-4'} />
|
||||
{course.favorited ? 'Unpin favorite' : 'Pin favorite'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={(event) => { event.preventDefault(); startQuickAction('tag') }} className="gap-2">
|
||||
<Tags className="h-4 w-4" />
|
||||
Add tag
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={(event) => { event.preventDefault(); startQuickAction('category') }} className="gap-2">
|
||||
<FolderPlus className="h-4 w-4" />
|
||||
Add category
|
||||
</DropdownMenuItem>
|
||||
{quickAction && (
|
||||
<div className="px-2 py-2" onClick={(event) => event.stopPropagation()}>
|
||||
<label className="mb-1 block text-xs font-medium text-muted-foreground">
|
||||
{quickAction === 'tag' ? 'New tag' : 'New category'}
|
||||
</label>
|
||||
<input
|
||||
value={quickActionValue}
|
||||
onChange={(event) => 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 && <p className="mb-2 text-xs text-destructive">{quickActionError}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setQuickAction(null)}>Cancel</Button>
|
||||
<Button type="button" size="sm" onClick={submitQuickAction}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DropdownMenuItem onSelect={clearQuiz} disabled={quizStatus === 'running'} className="gap-2">
|
||||
{quizStatus === 'running' ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
||||
{quizStatus === 'running' ? 'Clearing...' : 'Clear Quiz'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={handleRegenerateQuiz} disabled={quizStatus === 'running'} className="gap-2">
|
||||
{quizStatus === 'running' ? <Loader2 className="h-4 w-4 animate-spin" /> : <BrainCircuit className="h-4 w-4" />}
|
||||
{quizStatus === 'running' ? 'Regenerating...' : 'Regenerate Quiz'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={handleRename} className="gap-2">
|
||||
<PencilLine className="h-4 w-4" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => handleDelete('library')} className="gap-2 text-destructive focus:text-destructive">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete from library
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => handleDelete('disk')} className="gap-2 text-destructive focus:text-destructive">
|
||||
<Bomb className="h-4 w-4" />
|
||||
Delete from disk
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Link href={'/course/' + course.slug} className="absolute inset-0 z-10" aria-label={`Open ${courseTitle}`}>
|
||||
<span className="sr-only">Open {courseTitle}</span>
|
||||
</Link>
|
||||
|
||||
{/* Cover: local image -> deterministic gradient -> legacy CSS cover */}
|
||||
<div className="relative aspect-video overflow-hidden rounded-t-xl bg-muted">
|
||||
{course.thumbnail ? (
|
||||
<img
|
||||
src={course.thumbnail}
|
||||
alt={courseTitle}
|
||||
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.02]"
|
||||
onError={(event) => {
|
||||
const target = event.currentTarget
|
||||
target.style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className={`flex h-full w-full items-center justify-center ${coverGradient(courseTitle)}`}>
|
||||
<p className="px-4 text-center text-lg font-semibold text-white drop-shadow-md">{courseTitle}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Play overlay */}
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 transition-opacity duration-300 group-hover:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-14 w-14 items-center justify-center rounded-full bg-primary/90 text-primary-foreground transition-all duration-300 hover:scale-110 hover:bg-primary"
|
||||
aria-label="Play"
|
||||
>
|
||||
<Play className="ml-1 h-7 w-7" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Quiz regeneration overlay */}
|
||||
{quizStatus === 'running' && (
|
||||
<div className="absolute inset-x-3 bottom-3 z-30 rounded-xl border border-primary/30 bg-background/90 p-3 shadow-lg backdrop-blur-md">
|
||||
<div className="mb-2 flex items-center gap-2 text-xs font-medium text-primary">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Regenerating quiz with QuizAPI...
|
||||
</div>
|
||||
<Progress value={70} className="h-1.5 gradient-progress" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress overlay */}
|
||||
{percentage > 0 && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1.5 bg-black/50">
|
||||
<Progress value={percentage} className="h-full gradient-progress bg-transparent" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Completion / favorite badges */}
|
||||
<div className="absolute right-3 top-3 z-20 flex flex-col items-end gap-2">
|
||||
{course.favorited && (
|
||||
<Badge variant="secondary" className="bg-amber-500/20 px-2.5 py-1 text-amber-300 border-amber-500/30 gap-1">
|
||||
<Star className="h-3 w-3 fill-amber-300" />
|
||||
Pinned
|
||||
</Badge>
|
||||
)}
|
||||
{percentage === 100 && total > 0 && (
|
||||
<Badge variant="success" className="px-2.5 py-1 gap-1">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
Complete
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Course Type Icon */}
|
||||
<div className="absolute left-3 top-3 z-20">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg border border-white/10 bg-black/60 backdrop-blur-sm">
|
||||
<span className={iconColor}>{CourseIcon}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<CardContent className="relative z-0 flex flex-1 flex-col p-5">
|
||||
<h3 className="mb-2 line-clamp-1 text-lg font-semibold transition-colors group-hover:text-primary">{courseTitle}</h3>
|
||||
|
||||
{course.description && (
|
||||
<p className="mb-4 flex-1 line-clamp-2 text-sm text-muted-foreground">{course.description}</p>
|
||||
)}
|
||||
|
||||
{visibleTags.length > 0 && (
|
||||
<div className="mb-4 flex flex-wrap gap-1.5">
|
||||
{categoryTags.slice(0, 2).map((tag) => (
|
||||
<Badge key={tag.id} variant="secondary" className="bg-primary/10 text-primary border-primary/20">
|
||||
{tag.name.replace(/^category:/, '')}
|
||||
</Badge>
|
||||
))}
|
||||
{plainTags.slice(0, 3).map((tag) => (
|
||||
<Badge key={tag.id} variant="outline" className="bg-white/5">
|
||||
{tag.name}
|
||||
</Badge>
|
||||
))}
|
||||
{visibleTags.length > 5 && (
|
||||
<Badge variant="outline" className="bg-white/5">+{visibleTags.length - 5}</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats with Iconoir icons */}
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2.5 text-xs text-muted-foreground">
|
||||
{_count?.modules && (
|
||||
<span className="flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-2.5 py-1">
|
||||
<ViewGrid className="h-3 w-3" />
|
||||
{_count.modules} modules
|
||||
</span>
|
||||
)}
|
||||
{total > 0 && (
|
||||
<span className="flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-2.5 py-1">
|
||||
<VideoCamera className="h-3 w-3" />
|
||||
{total} lessons
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{quizMessage && quizStatus !== 'idle' && (
|
||||
<div className={`mb-4 rounded-lg border px-3 py-2 text-xs ${quizStatus === 'error' ? 'border-destructive/30 bg-destructive/10 text-destructive' : 'border-primary/30 bg-primary/10 text-primary'}`}>
|
||||
{quizMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Emerald Gradient Progress Bar */}
|
||||
{total > 0 && (
|
||||
<div className="mb-4">
|
||||
<div className="mb-1.5 flex justify-between text-xs text-muted-foreground">
|
||||
<span>{completed} / {total} lessons</span>
|
||||
<span className="font-semibold text-primary">{Math.round(percentage)}%</span>
|
||||
</div>
|
||||
<Progress value={percentage} className="h-2.5 gradient-progress" />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{/* Footer */}
|
||||
<CardFooter className="border-t border-white/5 px-5 pb-5 pt-0">
|
||||
{lastWatched && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
Last: {new Date(lastWatched).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Search, Settings, Plus, BookOpen, BarChart2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ThemeToggle } from '@/components/ThemeToggle'
|
||||
|
||||
interface HeaderProps {
|
||||
showSearchFilter?: boolean
|
||||
showScanButtons?: boolean
|
||||
showBackButton?: boolean
|
||||
backHref?: string
|
||||
backLabel?: string
|
||||
onSearchClick?: () => void
|
||||
onFilterClick?: () => void
|
||||
onQuickScan?: () => void
|
||||
coffeeUrl?: string
|
||||
}
|
||||
|
||||
export function Header({
|
||||
showSearchFilter = true,
|
||||
showScanButtons = false,
|
||||
showBackButton = false,
|
||||
backHref,
|
||||
backLabel,
|
||||
onSearchClick,
|
||||
onFilterClick,
|
||||
onQuickScan,
|
||||
coffeeUrl,
|
||||
}: HeaderProps) {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false)
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 w-full border-b border-white/5 bg-background/80 backdrop-blur-xl">
|
||||
<div className="container mx-auto px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Back button - only on course/watch pages */}
|
||||
{showBackButton && backHref && (
|
||||
<Link
|
||||
href={backHref}
|
||||
className="header-control hover:text-primary hover:bg-primary/10 px-3 py-2"
|
||||
aria-label="Back"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m12 19-7-7 7-7"></path>
|
||||
<path d="M19 12H5"></path>
|
||||
</svg>
|
||||
<span className="hidden sm:inline ml-1">{backLabel || 'Back'}</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Logo - Text + Icon */}
|
||||
<Link href="/" className="flex items-center gap-3" aria-label="OfflineAcademy Home">
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="OfflineAcademy"
|
||||
className="h-10 w-auto object-contain transition-all duration-300"
|
||||
width="120"
|
||||
height="40"
|
||||
/>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold bg-gradient-to-r from-white via-primary to-accent bg-clip-text text-transparent">
|
||||
OfflineAcademy
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground font-medium">Your Offline Learning Hub</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Desktop Controls */}
|
||||
<div className="hidden md:flex items-center gap-1.5">
|
||||
{showSearchFilter && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="header-control"
|
||||
aria-label="Search"
|
||||
onClick={onSearchClick}
|
||||
>
|
||||
<svg className="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<path d="m21 21-4.35-4.35"></path>
|
||||
</svg>
|
||||
<span className="hidden lg:inline ml-1">Search</span>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showScanButtons && (
|
||||
<>
|
||||
{onQuickScan && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="btn-premium-primary"
|
||||
onClick={onQuickScan}
|
||||
disabled={false}
|
||||
>
|
||||
<svg className="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M23 4v6h-6"></path>
|
||||
<path d="M1 20v-6h6"></path>
|
||||
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
|
||||
</svg>
|
||||
<span className="hidden lg:inline ml-1">Quick Scan</span>
|
||||
</Button>
|
||||
)}
|
||||
<Link href="/scan">
|
||||
<Button variant="ghost" size="sm" className="header-control" aria-label="Full Scan">
|
||||
<svg className="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
<span className="hidden lg:inline ml-1">Full Scan</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ThemeToggle />
|
||||
<Link href="/settings">
|
||||
<Button variant="ghost" size="icon" className="header-control" aria-label="Settings">
|
||||
<svg className="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06-.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l-.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06-.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0-1.51 1H3a2 2 0 0 1-2 2 2 2 0 0 1-2-2V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09z"></path>
|
||||
</svg>
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/analytics" className="header-control p-1.5">
|
||||
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center border border-primary/20">
|
||||
<BarChart2 className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
</Link>
|
||||
{coffeeUrl ? (
|
||||
<Link href={coffeeUrl} target="_blank" rel="noreferrer noopener" className="header-control p-1.5">
|
||||
<img src="/ko-fi-icon.gif" alt="Support on Ko-fi" className="h-8 w-8 object-contain" />
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<div className="md:hidden">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="header-control"
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
aria-label="Menu"
|
||||
>
|
||||
{mobileMenuOpen ? (
|
||||
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 6 6 18"></path>
|
||||
<path d="m6 6 12 12"></path>
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="4" x2="20" y1="12" y2="12"></line>
|
||||
<line x1="4" x2="20" y1="6" y2="6"></line>
|
||||
<line x1="4" x2="20" y1="18" y2="18"></line>
|
||||
</svg>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
{mobileMenuOpen && (
|
||||
<div className="md:hidden mt-4 pb-4 border-t border-white/5 pt-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
{showSearchFilter && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="justify-start w-full"
|
||||
onClick={() => {
|
||||
onSearchClick?.()
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
>
|
||||
<Search className="h-4 w-4 mr-2" />
|
||||
Search
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{showScanButtons && (
|
||||
<>
|
||||
{onQuickScan && (
|
||||
<Button
|
||||
variant="default"
|
||||
className="justify-start w-full btn-premium-primary"
|
||||
onClick={() => {
|
||||
onQuickScan()
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
>
|
||||
<svg className="h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M23 4v6h-6"></path>
|
||||
<path d="M1 20v-6h6"></path>
|
||||
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
|
||||
</svg>
|
||||
Quick Scan
|
||||
</Button>
|
||||
)}
|
||||
<Link href="/scan" className="w-full" onClick={() => setMobileMenuOpen(false)}>
|
||||
<Button variant="ghost" className="justify-start w-full">
|
||||
<svg className="h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
Full Scan
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<Link href="/settings" className="w-full" onClick={() => setMobileMenuOpen(false)}>
|
||||
<Button variant="ghost" className="justify-start w-full">
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
Settings
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/analytics" className="w-full" onClick={() => setMobileMenuOpen(false)}>
|
||||
<Button variant="ghost" className="justify-start w-full">
|
||||
<BarChart2 className="h-4 w-4 mr-2" />
|
||||
Analytics
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { BookOpen, Play, ArrowRight, CheckCircle, Download, Monitor, Shield, Database, Zap, Search, Filter } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import Link from 'next/link'
|
||||
|
||||
export function HeroBanner() {
|
||||
return (
|
||||
<section className="relative overflow-hidden py-16 lg:py-24">
|
||||
<div className="absolute inset-0 -z-10" aria-hidden="true">
|
||||
<div className="absolute inset-0 opacity-30" style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='60' height='60' viewBox='0 0 60 60'%3E%3Cdefs%3E%3Cpattern id='hex' patternUnits='userSpaceOnUse' width='30' height='52'%3E%3Cpath d='M15 0 L30 10 L30 42 L15 52 L0 42 L0 10 Z' fill='none' stroke='%2310b981' stroke-width='0.5' stroke-opacity='0.15'/%3E%3C/pattern%3E%3C/defs%3E%3Crect width='100%25' height='100%25' fill='url(%23hex)'/%3E%3C/svg%3E")`,
|
||||
backgroundSize: '60px 60px',
|
||||
}} />
|
||||
<div className="absolute top-0 right-0 w-96 h-96 bg-primary/10 rounded-full blur-3xl translate-x-1/2 -translate-y-1/2" />
|
||||
<div className="absolute bottom-0 left-0 w-72 h-72 bg-accent/10 rounded-full blur-3xl -translate-x-1/2 translate-y-1/2" />
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-background/80" />
|
||||
</div>
|
||||
|
||||
<div className="container mx-auto px-4 relative z-10">
|
||||
<div className="grid lg:grid-cols-2 gap-12 lg:gap-16 items-center">
|
||||
<div className="text-center lg:text-left">
|
||||
<div className="inline-flex items-center justify-center mb-6">
|
||||
<div className="relative w-24 h-24 lg:w-28 lg:h-28">
|
||||
<div className="absolute inset-0 rounded-full bg-primary/20 blur-xl animate-pulse" />
|
||||
<div className="relative w-full h-full rounded-2xl bg-background-elevated/80 backdrop-blur-xl border border-primary/20 flex items-center justify-center">
|
||||
<div className="absolute inset-0 rounded-2xl opacity-10" style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40' viewBox='0 0 40 40'%3E%3Cpath d='M10 0 L20 6.67 L20 26.67 L10 33.33 L0 26.67 L0 6.67 Z' fill='none' stroke='%2310b981' stroke-width='0.5'/%3E%3C/svg%3E")`,
|
||||
backgroundSize: '20px 20px',
|
||||
}} />
|
||||
<svg className="w-14 h-14 lg:w-16 lg:h-16 text-primary" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bookHeroGrad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#5eead4"/>
|
||||
<stop offset="100%" stop-color="#0d9488"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="playHeroGrad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#fde047"/>
|
||||
<stop offset="100%" stop-color="#f59e0b"/>
|
||||
</linearGradient>
|
||||
<filter id="heroGlow"><feGaussianBlur stdDeviation="2" result="blur"/><feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
|
||||
</defs>
|
||||
<path d="M25 20 L42 20 L42 80 L25 80 Z" stroke="url(#bookHeroGrad)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
<path d="M58 20 L75 20 L75 80 L58 80 Z" stroke="url(#bookHeroGrad)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
<path d="M25 20 Q50 12 75 20" stroke="url(#bookHeroGrad)" stroke-width="2.5" stroke-linecap="round" fill="none"/>
|
||||
<path d="M25 80 Q50 88 75 80" stroke="url(#bookHeroGrad)" stroke-width="2.5" stroke-linecap="round" fill="none"/>
|
||||
<path d="M50 80 L50 85" stroke="url(#bookHeroGrad)" stroke-width="2" stroke-linecap="round" fill="none"/>
|
||||
<polygon points="43,38 43,62 63,50" fill="url(#playHeroGrad)" filter="url(#heroGlow)"/>
|
||||
<polygon points="45,40 45,48 53,44" fill="#fde68a" opacity="0.5"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl lg:text-6xl font-bold tracking-tight mb-4 bg-gradient-to-r from-white via-primary to-accent bg-clip-text text-transparent animate-fade-in">
|
||||
OfflineAcademy
|
||||
</h1>
|
||||
<p className="text-lg lg:text-xl text-muted-foreground max-w-lg mx-auto lg:mx-0 mb-8 animate-slide-up">
|
||||
Your Personal Offline Learning Center
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-center lg:justify-start gap-3 mb-8 animate-slide-up" style={{ animationDelay: '100ms' }}>
|
||||
<span className="px-3 py-1.5 rounded-full border border-primary/30 bg-primary/10 text-primary text-sm font-medium flex items-center gap-1.5">
|
||||
<Shield className="h-3.5 w-3.5" /> Privacy First
|
||||
</span>
|
||||
<span className="px-3 py-1.5 rounded-full border border-accent/30 bg-accent/10 text-accent text-sm font-medium flex items-center gap-1.5">
|
||||
<Download className="h-3.5 w-3.5" /> Fully Offline
|
||||
</span>
|
||||
<span className="px-3 py-1.5 rounded-full border border-green-500/30 bg-green-500/10 text-green-500 text-sm font-medium flex items-center gap-1.5">
|
||||
<Database className="h-3.5 w-3.5" /> Self-Hosted
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center lg:justify-start gap-4 animate-slide-up" style={{ animationDelay: '200ms' }}>
|
||||
<Link href="/scan">
|
||||
<Button size="lg" className="btn-premium-primary gap-2 px-8 py-3 text-base" style={{ minWidth: '180px' }}>
|
||||
<Play className="h-5 w-5" /> Start Learning
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/settings">
|
||||
<Button size="lg" variant="ghost" className="gap-2 px-8 py-3 text-base border-primary/30 hover:bg-primary/10" style={{ minWidth: '180px' }}>
|
||||
<Monitor className="h-5 w-5" /> View Demo
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 flex flex-wrap items-center justify-center lg:justify-start gap-6 text-xs text-muted-foreground animate-slide-up" style={{ animationDelay: '300ms' }}>
|
||||
<div className="flex items-center gap-1.5"><CheckCircle className="h-3.5 w-3.5 text-primary" /><span>No tracking</span></div>
|
||||
<div className="flex items-center gap-1.5"><CheckCircle className="h-3.5 w-3.5 text-primary" /><span>No accounts required</span></div>
|
||||
<div className="flex items-center gap-1.5"><CheckCircle className="h-3.5 w-3.5 text-primary" /><span>Works offline</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative animate-fade-in" style={{ animationDelay: '200ms' }}>
|
||||
<div className="absolute -top-4 -right-4 w-64 h-64 bg-gradient-to-br from-primary/10 to-transparent rounded-full blur-2xl pointer-events-none" />
|
||||
|
||||
<div className="relative glass-card-elevated rounded-2xl overflow-hidden border-primary/20 max-w-lg mx-auto lg:mx-0">
|
||||
<div className="flex items-center gap-2 px-4 py-3 bg-secondary/50 border-b border-secondary-border">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500/80" />
|
||||
<div className="w-3 h-3 rounded-full bg-yellow-500/80" />
|
||||
<div className="w-3 h-3 rounded-full bg-green-500/80" />
|
||||
</div>
|
||||
<div className="flex-1 text-center text-xs text-muted-foreground font-mono">OfflineAcademy - Dashboard</div>
|
||||
<div className="w-3 h-3 rounded-full bg-transparent" />
|
||||
</div>
|
||||
|
||||
<div className="p-4 lg:p-6 space-y-6" style={{ minHeight: '420px' }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-primary/15 flex items-center justify-center">
|
||||
<BookOpen className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<span className="font-semibold text-sm">OfflineAcademy</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative hidden sm:block">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<input type="text" placeholder="Search courses..." className="w-48 pl-8 pr-3 py-1.5 bg-secondary border border-secondary-border rounded-lg text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50" readOnly />
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="header-control"><Zap className="h-3.5 w-3.5" /><span className="hidden sm:inline">New</span></Button>
|
||||
<Button variant="ghost" size="sm" className="header-control"><Download className="h-3.5 w-3.5" /><span className="hidden sm:inline">Add</span></Button>
|
||||
<Button variant="ghost" size="sm" className="header-control"><ArrowRight className="h-3.5 w-3.5" /><span className="hidden sm:inline">Sort</span></Button>
|
||||
<Button variant="ghost" size="sm" className="header-control"><Filter className="h-3.5 w-3.5" /><span className="hidden sm:inline">Filter</span></Button>
|
||||
<div className="w-8 h-8 rounded-full bg-primary/15 flex items-center justify-center border border-primary/20 ml-1">
|
||||
<Monitor className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-2">
|
||||
<Play className="h-4 w-4 text-primary" /> Continue Watching
|
||||
</h3>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ title: 'Tutorial: JavaScript', duration: '1h 23m', bg: 'bg-gradient-to-br from-primary/20 to-primary/5' },
|
||||
{ title: '1. Vercel is awesome', duration: '1h 15m', bg: 'bg-gradient-to-br from-gray-100/10 to-gray-200/5' },
|
||||
{ title: '4. Automation Platforms', duration: '1h 30m', bg: 'bg-gradient-to-br from-primary/10 to-blue-500/10' },
|
||||
].map((item, i) => (
|
||||
<div key={i} className="group relative rounded-lg overflow-hidden">
|
||||
<div className={`aspect-video ${item.bg} relative overflow-hidden`}>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/90 flex items-center justify-center text-primary-foreground group-hover:scale-110 transition-transform opacity-0 group-hover:opacity-100">
|
||||
<Play className="h-5 w-5 ml-1" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary/50" style={{ width: `${30 + i * 20}%` }} />
|
||||
</div>
|
||||
<p className="text-xs text-foreground mt-1.5 line-clamp-1">{item.title}</p>
|
||||
<p className="text-xs text-muted-foreground">{item.duration}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-2">
|
||||
<BookOpen className="h-4 w-4 text-accent" /> Your Library
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{[
|
||||
{ title: '[TutorialsHub] - Network Config', bg: 'bg-gradient-to-br from-blue-600/30 to-purple-600/20' },
|
||||
{ title: 'FreeTutorials101: Linux Shell', bg: 'bg-gradient-to-br from-green-500/30 to-teal-500/20' },
|
||||
{ title: 'FreeCoursesOnline: Code With Me', bg: 'bg-gradient-to-br from-purple-600/30 to-pink-500/20' },
|
||||
{ title: 'FreeCoursesLab: Academy Lab', bg: 'bg-gradient-to-br from-teal-500/30 to-cyan-500/20' },
|
||||
].map((item, i) => (
|
||||
<div key={i} className="group relative rounded-lg overflow-hidden">
|
||||
<div className={`aspect-video ${item.bg} relative overflow-hidden transition-all duration-300 group-hover:scale-[1.02]`}>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
|
||||
<div className="absolute bottom-3 left-3 right-3 text-white text-xs font-medium line-clamp-2">{item.title}</div>
|
||||
<div className="absolute top-3 right-3 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="w-8 h-8 rounded-full bg-primary/90 flex items-center justify-center">
|
||||
<Play className="h-4 w-4 ml-1 text-primary-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute -bottom-6 -left-4 w-48 glass-card p-3 animate-float">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<div className="w-7 h-7 rounded-lg bg-primary/15 flex items-center justify-center">
|
||||
<Shield className="h-3.5 w-3.5 text-primary" />
|
||||
</div>
|
||||
<div><p className="font-medium text-foreground">Secure</p><p className="text-muted-foreground">Local only</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute -bottom-6 right-4 w-48 glass-card p-3 animate-float" style={{ animationDelay: '1.5s' }}>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<div className="w-7 h-7 rounded-lg bg-accent/15 flex items-center justify-center">
|
||||
<Download className="h-3.5 w-3.5 text-accent" />
|
||||
</div>
|
||||
<div><p className="font-medium text-foreground">Offline</p><p className="text-muted-foreground">No internet needed</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 animate-bounce">
|
||||
<ArrowRight className="h-6 w-6 text-primary/50 rotate-90" />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { Download, Smartphone, Monitor, CheckCircle, XCircle, AlertCircle, Loader2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
|
||||
export function InstallButton({
|
||||
variant = 'ghost',
|
||||
size = 'icon',
|
||||
className = '',
|
||||
...props
|
||||
}: { variant?: 'default' | 'ghost' | 'outline'; size?: 'sm' | 'icon'; className?: string }) {
|
||||
const [showInstructions, setShowInstructions] = React.useState(false)
|
||||
const [platform, setPlatform] = React.useState<'android' | 'ios' | 'desktop' | 'unknown'>('unknown')
|
||||
const [swDiagnostic, setSwDiagnostic] = React.useState<{
|
||||
controlling: boolean
|
||||
swVersion?: string
|
||||
error?: string
|
||||
} | null>(null)
|
||||
const [checkingSW, setCheckingSW] = React.useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
const ua = navigator.userAgent
|
||||
const isStandalone = window.matchMedia('(display-mode: standalone)').matches
|
||||
const isInWebAppiOS = (window.navigator as any).standalone === true
|
||||
|
||||
if (isStandalone || isInWebAppiOS) return
|
||||
|
||||
if (/iPad|iPhone|iPod/.test(ua)) setPlatform('ios')
|
||||
else if (/Android/.test(ua)) setPlatform('android')
|
||||
else setPlatform('desktop')
|
||||
}, [])
|
||||
|
||||
const checkSW = async () => {
|
||||
setCheckingSW(true)
|
||||
try {
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
setSwDiagnostic({ controlling: false, error: 'Service Workers not supported' })
|
||||
return
|
||||
}
|
||||
|
||||
// First, unregister all old SWs to force fresh registration
|
||||
const regs = await navigator.serviceWorker.getRegistrations()
|
||||
for (const reg of regs) {
|
||||
if (reg.active?.scriptURL.includes('sw.js') && !reg.active.scriptURL.includes('v=3')) {
|
||||
await reg.unregister()
|
||||
console.log('Unregistered old SW:', reg.scope)
|
||||
}
|
||||
}
|
||||
|
||||
// Register fresh
|
||||
const reg = await navigator.serviceWorker.register('/sw.js?v=3', { updateViaCache: 'none' })
|
||||
await reg.update()
|
||||
|
||||
const controlling = !!navigator.serviceWorker.controller
|
||||
let swVersion = 'unknown'
|
||||
|
||||
try {
|
||||
const res = await fetch('/sw.js?v=3')
|
||||
const text = await res.text()
|
||||
const match = text.match(/version\s+(\d+)/i) || text.match(/v(\d+)/i)
|
||||
if (match) swVersion = match[1]
|
||||
} catch {}
|
||||
|
||||
setSwDiagnostic({ controlling, swVersion })
|
||||
} catch (err) {
|
||||
setSwDiagnostic({ controlling: false, error: String(err) })
|
||||
} finally {
|
||||
setCheckingSW(false)
|
||||
}
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
checkSW()
|
||||
}, [])
|
||||
|
||||
const handleClick = () => setShowInstructions(true)
|
||||
|
||||
const isInstalled = window.matchMedia('(display-mode: standalone)').matches || (window.navigator as any).standalone === true
|
||||
|
||||
if (isInstalled) {
|
||||
return (
|
||||
<Button variant="ghost" size="icon" className={className} disabled {...props}>
|
||||
<Download className="h-4 w-4 opacity-50" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={className}
|
||||
onClick={handleClick}
|
||||
aria-label="Install OfflineAcademy"
|
||||
{...props}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* SW Status Indicator */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="header-control"
|
||||
onClick={checkSW}
|
||||
disabled={checkingSW}
|
||||
aria-label="Check Service Worker status"
|
||||
>
|
||||
{checkingSW ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : swDiagnostic?.controlling ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-400" />
|
||||
) : (
|
||||
<AlertCircle className="h-4 w-4 text-amber-400" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Diagnostic Tooltip */}
|
||||
{swDiagnostic && (
|
||||
<div className="absolute top-full right-0 mt-2 z-50 w-80 glass-card-elevated rounded-lg p-3 shadow-xl border border-white/10 animate-in slide-in-from-top-2">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="font-medium">SW Diagnostic</h4>
|
||||
<Button variant="ghost" size="icon" onClick={() => setSwDiagnostic(null)}>
|
||||
<XCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-1 text-xs">
|
||||
<div className="flex items-center gap-2 text-green-400">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
<span>Service Worker: <strong>Registered</strong></span>
|
||||
</div>
|
||||
<div className={`flex items-center gap-2 ${swDiagnostic.controlling ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{swDiagnostic.controlling ? <CheckCircle className="h-3 w-3" /> : <XCircle className="h-3 w-3" />}
|
||||
<span>Controlling page: <strong>{swDiagnostic.controlling ? 'YES ✓' : 'NO ✗'}</strong></span>
|
||||
</div>
|
||||
{swDiagnostic.swVersion && (
|
||||
<div className="text-muted-foreground">Version: {swDiagnostic.swVersion}</div>
|
||||
)}
|
||||
{swDiagnostic.error && (
|
||||
<div className="text-red-400">Error: {swDiagnostic.error}</div>
|
||||
)}
|
||||
{!swDiagnostic.controlling && (
|
||||
<div className="text-amber-400 mt-2 text-xs">
|
||||
If controlling=NO: refresh page, or check Brave Shields (lion icon → OFF)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showInstructions && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={() => setShowInstructions(false)} />
|
||||
<div className="relative w-full max-w-sm glass-card-elevated rounded-xl p-6 shadow-2xl border border-white/10 animate-in zoom-in-95">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<h3 className="font-semibold text-lg">Install OfflineAcademy</h3>
|
||||
<Button variant="ghost" size="icon" onClick={() => setShowInstructions(false)}>
|
||||
<XCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{platform === 'android' && (
|
||||
<div className="space-y-4 text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
On <strong>Android (Chrome/Brave/Edge)</strong>, use the browser menu:
|
||||
</p>
|
||||
<div className="bg-muted/50 rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded bg-primary/20 flex items-center justify-center">
|
||||
<Monitor className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Chrome / Edge</p>
|
||||
<p className="text-xs text-muted-foreground">Menu (⋮ top-right) → <strong>"Install app"</strong> or <strong>"Add to Home screen"</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 border-t border-white/10 pt-3">
|
||||
<div className="w-8 h-8 rounded bg-orange-500/20 flex items-center justify-center">
|
||||
<Smartphone className="h-4 w-4 text-orange-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Brave Browser</p>
|
||||
<p className="text-xs text-muted-foreground">Menu (⋮ bottom-right) → <strong>"Install app"</strong><br/>
|
||||
<span className="text-xs text-amber-400">If missing: Shields OFF (lion icon) → reload</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{platform === 'ios' && (
|
||||
<div className="space-y-4 text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
On <strong>iOS (Safari)</strong>, use the Share button:
|
||||
</p>
|
||||
<div className="bg-muted/50 rounded-lg p-4 space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded bg-primary/20 flex items-center justify-center">
|
||||
<Smartphone className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<ol className="space-y-1 text-xs">
|
||||
<li>Tap <strong>Share button</strong> (square with arrow up) at bottom</li>
|
||||
<li>Scroll down, tap <strong>"Add to Home Screen"</strong></li>
|
||||
<li>Tap <strong>"Add"</strong> top-right</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{platform === 'desktop' && (
|
||||
<div className="space-y-4 text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
On <strong>Desktop (Chrome/Edge)</strong>:
|
||||
</p>
|
||||
<div className="bg-muted/50 rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded bg-primary/20 flex items-center justify-center">
|
||||
<Monitor className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Chrome</p>
|
||||
<p className="text-xs text-muted-foreground">Menu (⋮ top-right) → <strong>"Install OfflineAcademy"</strong> or <strong>"Install app"</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 border-t border-white/10 pt-3">
|
||||
<div className="w-8 h-8 rounded bg-blue-500/20 flex items-center justify-center">
|
||||
<Monitor className="h-4 w-4 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Edge</p>
|
||||
<p className="text-xs text-muted-foreground">Menu (⋮ top-right) → <strong>"Apps"</strong> → <strong>"Install this site as an app"</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-amber-400">
|
||||
Creates Start Menu shortcut (Windows) / Applications folder (Mac)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{platform === 'unknown' && (
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Open in Chrome/Edge/Safari/Brave for install options
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button className="w-full mt-4" onClick={() => setShowInstructions(false)}>
|
||||
Got it
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { X, Download, Smartphone } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface InstallPromptProps {
|
||||
onDismiss: () => void
|
||||
onInstall: () => void
|
||||
}
|
||||
|
||||
export function InstallPrompt({ onDismiss, onInstall }: InstallPromptProps) {
|
||||
const [deferredPrompt, setDeferredPrompt] = React.useState<BeforeInstallPromptEvent | null>(null)
|
||||
const [isIOS, setIsIOS] = React.useState(false)
|
||||
const [showIOSInstructions, setShowIOSInstructions] = React.useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
// Check if iOS
|
||||
const ua = navigator.userAgent
|
||||
const isIOSDevice = /iPad|iPhone|iPod/.test(ua)
|
||||
setIsIOS(isIOSDevice)
|
||||
|
||||
// Check if already installed
|
||||
const isStandalone = window.matchMedia('(display-mode: standalone)').matches
|
||||
const isInWebAppiOS = (window.navigator as any).standalone === true
|
||||
if (isStandalone || isInWebAppiOS) {
|
||||
onDismiss()
|
||||
return
|
||||
}
|
||||
|
||||
// Check if dismissed before
|
||||
const dismissed = localStorage.getItem('offlineacademy-install-dismissed')
|
||||
if (dismissed) {
|
||||
onDismiss()
|
||||
return
|
||||
}
|
||||
|
||||
// Listen for beforeinstallprompt event (Android/Chrome)
|
||||
const handleBeforeInstallPrompt = (e: Event) => {
|
||||
e.preventDefault()
|
||||
setDeferredPrompt(e as BeforeInstallPromptEvent)
|
||||
}
|
||||
|
||||
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
|
||||
}
|
||||
}, [onDismiss])
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (deferredPrompt) {
|
||||
deferredPrompt.prompt()
|
||||
const { outcome } = await deferredPrompt.userChoice
|
||||
if (outcome === 'accepted') {
|
||||
localStorage.setItem('offlineacademy-install-dismissed', 'true')
|
||||
onDismiss()
|
||||
}
|
||||
setDeferredPrompt(null)
|
||||
} else {
|
||||
// iOS - show instructions
|
||||
setShowIOSInstructions(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDismiss = () => {
|
||||
localStorage.setItem('offlineacademy-install-dismissed', 'true')
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
if (showIOSInstructions) {
|
||||
return (
|
||||
<div className="fixed bottom-4 left-4 right-4 sm:bottom-6 sm:left-6 sm:right-6 sm:max-w-md z-50 animate-slide-up">
|
||||
<div className="glass-card-elevated rounded-xl p-4 shadow-xl border border-white/10">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-lg bg-primary/20 flex items-center justify-center">
|
||||
<Smartphone className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-lg">Install on iOS</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Tap the <strong>Share button</strong> (square with arrow up) in Safari,
|
||||
then scroll down and tap <strong>"Add to Home Screen"</strong>.
|
||||
</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setShowIOSInstructions(false)}>
|
||||
Got it
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={handleDismiss}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 left-4 right-4 sm:bottom-6 sm:left-6 sm:right-6 sm:max-w-md z-50 animate-slide-up">
|
||||
<div className="glass-card-elevated rounded-xl p-4 shadow-xl border border-white/10">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-lg bg-primary/20 flex items-center justify-center">
|
||||
<Download className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-lg">Install OfflineAcademy</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Add to home screen for offline access, faster loads, and app-like experience.
|
||||
</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button size="sm" onClick={handleInstall} className="gap-1">
|
||||
<Download className="h-4 w-4" />
|
||||
Install
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={handleDismiss}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Type for beforeinstallprompt event
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt: () => Promise<void>
|
||||
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X, Keyboard, MousePointer, Monitor, HelpCircle } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ShortcutItem {
|
||||
key: string
|
||||
description: string
|
||||
}
|
||||
|
||||
const shortcuts: ShortcutItem[] = [
|
||||
{ key: 'Space / K', description: 'Play / Pause' },
|
||||
{ key: 'J / ←', description: 'Rewind 10s' },
|
||||
{ key: 'L / →', description: 'Forward 30s' },
|
||||
{ key: '↑ / ↓', description: 'Volume Up / Down' },
|
||||
{ key: 'M', description: 'Mute / Unmute' },
|
||||
{ key: 'F', description: 'Toggle Fullscreen' },
|
||||
{ key: ', / [', description: 'Decrease speed' },
|
||||
{ key: '. / ]', description: 'Increase speed' },
|
||||
{ key: '0-9', description: 'Seek to 0%-90%' },
|
||||
{ key: 'P / I', description: 'Toggle Picture-in-Picture' },
|
||||
{ key: 'Esc', description: 'Close overlay / exit fullscreen / exit PiP' },
|
||||
{ key: '?', description: 'Show / Hide This Overlay' },
|
||||
]
|
||||
|
||||
export function KeyboardShortcutsOverlay({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
|
||||
if (!isOpen) return null
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4" onClick={onClose}>
|
||||
<div
|
||||
className="bg-card border rounded-xl shadow-2xl max-w-md w-full p-6 animate-in fade-in zoom-in-95 duration-200"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Keyboard className="h-6 w-6 text-primary" />
|
||||
<h2 className="text-xl font-semibold">Keyboard Shortcuts</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded-lg hover:bg-accent transition-colors"
|
||||
aria-label="Close shortcuts"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 max-h-64 overflow-y-auto pr-2">
|
||||
{shortcuts.map((item, index) => (
|
||||
<div key={index} className="flex items-center gap-3 p-2 rounded-lg hover:bg-accent/50 transition-colors">
|
||||
<kbd className="flex items-center gap-1 px-2.5 py-1 bg-muted border rounded text-sm font-mono text-muted-foreground min-w-[90px] text-center">
|
||||
{item.key.split(' / ').map((k, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 && <span className="text-xs text-muted-foreground/50">/</span>}
|
||||
<span>{k.trim()}</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</kbd>
|
||||
<span className="text-sm flex-1 text-foreground">{item.description}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
<span>Press <kbd className="px-1.5 py-0.5 bg-muted rounded">?</kbd> anywhere to toggle</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Bookmark, BookmarkPlus, Clock, Trash2 } from 'lucide-react'
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { formatTime } from '@/lib/utils'
|
||||
|
||||
export type LessonBookmark = {
|
||||
id: string
|
||||
lessonId: string
|
||||
courseId: string
|
||||
moduleId: string
|
||||
position: number
|
||||
note: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
lesson: {
|
||||
id: string
|
||||
title: string
|
||||
slug: string
|
||||
}
|
||||
}
|
||||
|
||||
interface LessonBookmarksProps {
|
||||
lessonId: string
|
||||
courseId: string
|
||||
moduleId: string
|
||||
currentTime: number
|
||||
onJumpToTime: (time: number) => void
|
||||
isVideo?: boolean
|
||||
}
|
||||
|
||||
function formatSavedAt(isoDate: string) {
|
||||
const date = new Date(isoDate)
|
||||
return date.toLocaleString([], {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
export function LessonBookmarks({
|
||||
lessonId,
|
||||
courseId,
|
||||
moduleId,
|
||||
currentTime,
|
||||
onJumpToTime,
|
||||
isVideo = true,
|
||||
}: LessonBookmarksProps) {
|
||||
const [bookmarks, setBookmarks] = useState<LessonBookmark[]>([])
|
||||
const [note, setNote] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||
|
||||
const sortedBookmarks = useMemo(
|
||||
() => [...bookmarks].sort((a, b) => a.position - b.position || a.createdAt.localeCompare(b.createdAt)),
|
||||
[bookmarks]
|
||||
)
|
||||
|
||||
const fetchBookmarks = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/bookmarks?lessonId=' + lessonId)
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.error || 'Failed to fetch bookmarks')
|
||||
}
|
||||
|
||||
setBookmarks(data.items || [])
|
||||
} catch (fetchError) {
|
||||
console.error('Failed to fetch bookmarks:', fetchError)
|
||||
setError('Could not load bookmarks. The notebook page got smudged.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [lessonId])
|
||||
|
||||
useEffect(() => {
|
||||
fetchBookmarks()
|
||||
}, [fetchBookmarks])
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!isVideo) return
|
||||
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/bookmarks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
lessonId,
|
||||
courseId,
|
||||
moduleId,
|
||||
position: Math.max(0, Math.floor(currentTime)),
|
||||
note,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.error || 'Failed to save bookmark')
|
||||
}
|
||||
|
||||
setBookmarks(prev => [data.bookmark, ...prev])
|
||||
setNote('')
|
||||
} catch (saveError) {
|
||||
console.error('Failed to create bookmark:', saveError)
|
||||
setError('Could not save bookmark. The pen ran out of cosmic ink.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (bookmarkId: string) => {
|
||||
setDeletingId(bookmarkId)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/bookmarks/' + bookmarkId, { method: 'DELETE' })
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.error || 'Failed to delete bookmark')
|
||||
}
|
||||
|
||||
setBookmarks(prev => prev.filter(bookmark => bookmark.id !== bookmarkId))
|
||||
} catch (deleteError) {
|
||||
console.error('Failed to delete bookmark:', deleteError)
|
||||
setError('Could not delete bookmark. The eraser bounced off the desk.')
|
||||
} finally {
|
||||
setDeletingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-white/10 bg-card/70 backdrop-blur-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Bookmark className="h-4 w-4 text-primary" />
|
||||
Lesson Bookmarks
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Save a timestamp and note for this lesson so you can jump back to the exact spot later.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!isVideo && (
|
||||
<div className="rounded-lg border border-dashed border-white/10 bg-white/5 px-3 py-2 text-sm text-muted-foreground">
|
||||
Time-based bookmarks are available on video lessons.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isVideo && (
|
||||
<div className="space-y-3 rounded-lg border border-white/10 bg-white/5 p-3">
|
||||
<div className="flex items-center justify-between gap-3 text-sm text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Clock className="h-4 w-4" />
|
||||
Current time: {formatTime(Math.max(0, Math.floor(currentTime)))}
|
||||
</span>
|
||||
<Button type="button" size="sm" className="gap-2" onClick={handleSave} disabled={saving}>
|
||||
<BookmarkPlus className="h-4 w-4" />
|
||||
{saving ? 'Saving...' : 'Save bookmark'}
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="Add a note about why this timestamp matters..."
|
||||
className="min-h-[92px] resize-none bg-background/80"
|
||||
maxLength={1000}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Tip: keep it short and searchable — a command name, bug, or concept works great.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/20 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium">Saved bookmarks</h4>
|
||||
<span className="text-xs text-muted-foreground">{sortedBookmarks.length} total</span>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="rounded-lg border border-white/10 bg-white/5 px-3 py-4 text-sm text-muted-foreground">
|
||||
Loading bookmarks...
|
||||
</div>
|
||||
) : sortedBookmarks.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-white/10 bg-white/5 px-3 py-4 text-sm text-muted-foreground">
|
||||
No bookmarks yet. Save your first “aha!” moment.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{sortedBookmarks.map(bookmark => (
|
||||
<div key={bookmark.id} className="rounded-lg border border-white/10 bg-background/60 p-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onJumpToTime(bookmark.position)}
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-full border border-primary/20 bg-primary/10 px-2.5 py-1 text-xs font-medium text-primary transition-colors hover:bg-primary/20"
|
||||
aria-label={`Jump to ${formatTime(bookmark.position)}`}
|
||||
>
|
||||
<Bookmark className="h-3.5 w-3.5" />
|
||||
{formatTime(bookmark.position)}
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm text-foreground">
|
||||
{bookmark.note || 'No note added'}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Saved {formatSavedAt(bookmark.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleDelete(bookmark.id)}
|
||||
disabled={deletingId === bookmark.id}
|
||||
aria-label="Delete bookmark"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
Accordion,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
AccordionContent,
|
||||
} from '@/components/ui/accordion'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
ChevronRight,
|
||||
Play,
|
||||
CheckCircle,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Clock,
|
||||
FileText,
|
||||
Film,
|
||||
Image as ImageIcon,
|
||||
Loader2,
|
||||
Music,
|
||||
HelpCircle,
|
||||
} from 'lucide-react'
|
||||
import { cn, formatDuration, formatTime } from '@/lib/utils'
|
||||
|
||||
interface ModuleAccordionProps {
|
||||
courseId: 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
|
||||
}>
|
||||
}>
|
||||
currentLessonId?: string
|
||||
courseSlug: string
|
||||
defaultOpenModuleId?: string | null
|
||||
}
|
||||
|
||||
const lessonTypeIcons: Record<string, React.ReactNode> = {
|
||||
VIDEO: <Film className="h-4 w-4 text-red-400" />,
|
||||
AUDIO: <Music className="h-4 w-4 text-purple-400" />,
|
||||
PDF: <FileText className="h-4 w-4 text-red-500" />,
|
||||
MARKDOWN: <FileText className="h-4 w-4 text-blue-400" />,
|
||||
HTML: <FileText className="h-4 w-4 text-orange-400" />,
|
||||
IMAGE: <ImageIcon className="h-4 w-4 text-green-400" />,
|
||||
QUIZ: <HelpCircle className="h-4 w-4 text-amber-400" />,
|
||||
OTHER: <FileText className="h-4 w-4 text-muted-foreground" />,
|
||||
}
|
||||
|
||||
async function saveProgress(payload: {
|
||||
lessonId: string
|
||||
courseId: string
|
||||
moduleId: string
|
||||
position: number
|
||||
completed: boolean
|
||||
}) {
|
||||
const response = await fetch('/api/progress', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save progress')
|
||||
}
|
||||
}
|
||||
|
||||
export function ModuleAccordion({ modules, currentLessonId, courseSlug, courseId, defaultOpenModuleId }: ModuleAccordionProps) {
|
||||
const router = useRouter()
|
||||
const [savingLessonIds, setSavingLessonIds] = React.useState<Record<string, boolean>>({})
|
||||
const [savingModuleIds, setSavingModuleIds] = React.useState<Record<string, boolean>>({})
|
||||
|
||||
const currentModuleId = React.useMemo(() => {
|
||||
if (defaultOpenModuleId) return defaultOpenModuleId
|
||||
if (!currentLessonId) return undefined
|
||||
for (const module of modules) {
|
||||
if (module.lessons.some(lesson => lesson.id === currentLessonId)) {
|
||||
return module.id
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}, [currentLessonId, modules, defaultOpenModuleId])
|
||||
const openModuleId = React.useMemo(() => {
|
||||
if (!currentModuleId) return undefined
|
||||
return [currentModuleId]
|
||||
}, [currentModuleId])
|
||||
|
||||
const toggleLessonDone = React.useCallback(async (
|
||||
lesson: {
|
||||
id: string
|
||||
moduleId: string
|
||||
duration: number | null
|
||||
progress?: { completed: boolean; position: number } | null
|
||||
}
|
||||
) => {
|
||||
const wasCompleted = Boolean(lesson.progress?.completed)
|
||||
const nextCompleted = !wasCompleted
|
||||
const position = nextCompleted
|
||||
? Math.max(lesson.progress?.position || 0, lesson.duration || 0)
|
||||
: (lesson.progress?.position || 0)
|
||||
|
||||
setSavingLessonIds((prev) => ({ ...prev, [lesson.id]: true }))
|
||||
try {
|
||||
await saveProgress({
|
||||
lessonId: lesson.id,
|
||||
courseId,
|
||||
moduleId: lesson.moduleId,
|
||||
position,
|
||||
completed: nextCompleted,
|
||||
})
|
||||
router.refresh()
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle lesson done:', error)
|
||||
} finally {
|
||||
setSavingLessonIds((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[lesson.id]
|
||||
return next
|
||||
})
|
||||
}
|
||||
}, [courseId, router])
|
||||
|
||||
const toggleModuleDone = React.useCallback(async (
|
||||
module: {
|
||||
id: string
|
||||
lessons: Array<{
|
||||
id: string
|
||||
moduleId: string
|
||||
duration: number | null
|
||||
progress?: { completed: boolean; position: number } | null
|
||||
}>
|
||||
}
|
||||
) => {
|
||||
const completedCount = module.lessons.filter((lesson) => lesson.progress?.completed).length
|
||||
const allCompleted = completedCount === module.lessons.length && module.lessons.length > 0
|
||||
const nextCompleted = !allCompleted
|
||||
|
||||
setSavingModuleIds((prev) => ({ ...prev, [module.id]: true }))
|
||||
try {
|
||||
for (const lesson of module.lessons) {
|
||||
const position = nextCompleted
|
||||
? Math.max(lesson.progress?.position || 0, lesson.duration || 0)
|
||||
: (lesson.progress?.position || 0)
|
||||
|
||||
await saveProgress({
|
||||
lessonId: lesson.id,
|
||||
courseId,
|
||||
moduleId: lesson.moduleId,
|
||||
position,
|
||||
completed: nextCompleted,
|
||||
})
|
||||
}
|
||||
router.refresh()
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle module done:', error)
|
||||
} finally {
|
||||
setSavingModuleIds((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[module.id]
|
||||
return next
|
||||
})
|
||||
}
|
||||
}, [courseId, router])
|
||||
|
||||
const [openModules, setOpenModules] = React.useState<string[] | undefined>(openModuleId)
|
||||
|
||||
React.useEffect(() => {
|
||||
setOpenModules(openModuleId)
|
||||
}, [openModuleId])
|
||||
|
||||
return (
|
||||
<Accordion type="multiple" className="w-full" value={openModules} onValueChange={setOpenModules}>
|
||||
{modules.map((module) => {
|
||||
const completedCount = module.lessons.filter((lesson) => lesson.progress?.completed).length
|
||||
const allCompleted = completedCount === module.lessons.length && module.lessons.length > 0
|
||||
const isSavingModule = Boolean(savingModuleIds[module.id])
|
||||
const isCurrentModule = currentModuleId === module.id
|
||||
|
||||
return (
|
||||
<AccordionItem key={module.id} value={module.id}>
|
||||
<AccordionTrigger className="py-3 text-left font-medium hover:bg-accent rounded-lg px-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground font-mono">
|
||||
{String(module.order + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<span className="flex-1">{module.name}</span>
|
||||
{isCurrentModule ? (
|
||||
<span className="rounded-full border border-primary/40 bg-primary/10 px-2 py-0.5 text-xs text-primary">
|
||||
Now Playing
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{completedCount}/{module.lessons.length}
|
||||
</span>
|
||||
)}
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pt-0 pb-2">
|
||||
<div className="space-y-3 pl-8 border-l border-border/50 ml-2">
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-white/5 bg-secondary/30 px-3 py-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{completedCount} / {module.lessons.length} lessons done
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{allCompleted ? 'All lessons in this module are marked done.' : 'Mark the whole module done or clear it in one click.'}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant={allCompleted ? 'outline' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => toggleModuleDone(module)}
|
||||
disabled={isSavingModule}
|
||||
className="gap-2"
|
||||
>
|
||||
{isSavingModule ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : allCompleted ? (
|
||||
<Circle className="h-4 w-4" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
)}
|
||||
{allCompleted ? 'Mark module incomplete' : 'Mark module done'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{module.lessons.map((lesson) => {
|
||||
const isCurrent = currentLessonId === lesson.id
|
||||
const isCompleted = Boolean(lesson.progress?.completed)
|
||||
const progress = lesson.progress
|
||||
const hasProgress = progress && progress.position > 0 && !isCompleted
|
||||
const progressPercent = lesson.duration && progress
|
||||
? Math.round((progress.position / lesson.duration) * 100)
|
||||
: 0
|
||||
const isSavingLesson = Boolean(savingLessonIds[lesson.id])
|
||||
const watchHref = '/watch/' + lesson.id + '?course=' + courseSlug
|
||||
|
||||
return (
|
||||
<div
|
||||
key={lesson.id}
|
||||
className={cn(
|
||||
'flex items-start gap-2 rounded-lg px-2 py-2 transition-colors',
|
||||
'hover:bg-accent',
|
||||
isCurrent && 'bg-primary/10 border border-primary/30',
|
||||
isCompleted && 'opacity-60'
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
href={watchHref}
|
||||
className="flex flex-1 min-w-0 items-start gap-3"
|
||||
>
|
||||
<div className="flex items-center gap-2 shrink-0 pt-0.5">
|
||||
<span className="text-xs text-muted-foreground font-mono w-6 text-right">
|
||||
{String(lesson.order + 1).padStart(2, '0')}
|
||||
</span>
|
||||
|
||||
{isCompleted ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-400 shrink-0" />
|
||||
) : hasProgress ? (
|
||||
<Clock className="h-4 w-4 text-yellow-400 shrink-0" />
|
||||
) : (
|
||||
<Play className="h-4 w-4 text-muted-foreground/50 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={cn(
|
||||
'font-medium truncate',
|
||||
isCompleted && 'line-through text-muted-foreground',
|
||||
isCurrent && 'text-primary'
|
||||
)}>
|
||||
{lesson.title}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{lessonTypeIcons[lesson.type] || lessonTypeIcons.OTHER}
|
||||
<span className="capitalize">{lesson.type.toLowerCase()}</span>
|
||||
{lesson.duration && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{formatDuration(lesson.duration)}</span>
|
||||
</>
|
||||
)}
|
||||
{hasProgress && lesson.duration && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{formatTime(progress!.position)} / {formatDuration(lesson.duration)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasProgress && lesson.duration && (
|
||||
<div className="h-1 bg-muted rounded-full mt-1 w-32">
|
||||
<div
|
||||
className="h-full bg-primary rounded-full transition-all"
|
||||
style={{ width: progressPercent + '%' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
'mt-0.5 h-9 w-9 shrink-0 rounded-full',
|
||||
isCompleted ? 'text-green-400 hover:text-green-300 hover:bg-green-500/10' : 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
)}
|
||||
onClick={() => toggleLessonDone({
|
||||
id: lesson.id,
|
||||
moduleId: lesson.moduleId,
|
||||
duration: lesson.duration,
|
||||
progress: lesson.progress ? {
|
||||
completed: lesson.progress.completed,
|
||||
position: lesson.progress.position,
|
||||
} : null,
|
||||
})}
|
||||
disabled={isSavingLesson}
|
||||
aria-label={isCompleted ? 'Mark lesson incomplete' : 'Mark lesson complete'}
|
||||
title={isCompleted ? 'Mark lesson incomplete' : 'Mark lesson complete'}
|
||||
>
|
||||
{isSavingLesson ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : isCompleted ? (
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
) : (
|
||||
<Circle className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
})}
|
||||
</Accordion>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { CheckCircle2, Edit3, HelpCircle, RefreshCw, XCircle } from 'lucide-react'
|
||||
import type { QuizCache } from '@/lib/quiz-types'
|
||||
|
||||
interface QuizPlayerProps {
|
||||
quiz: QuizCache
|
||||
lessonId: string
|
||||
courseId: string
|
||||
moduleId: string
|
||||
onCompleted?: () => void
|
||||
}
|
||||
|
||||
export function QuizPlayer({ quiz, lessonId, courseId, moduleId, onCompleted }: QuizPlayerProps) {
|
||||
const router = useRouter()
|
||||
const [quizData, setQuizData] = useState(quiz)
|
||||
const [started, setStarted] = useState(false)
|
||||
const [selectedAnswers, setSelectedAnswers] = useState<Record<string, number | null>>({})
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [topicDraft, setTopicDraft] = useState(quiz.topic)
|
||||
const [editingTopic, setEditingTopic] = useState(false)
|
||||
|
||||
const answeredCount = useMemo(
|
||||
() => Object.values(selectedAnswers).filter((value) => value !== null && value !== undefined).length,
|
||||
[selectedAnswers]
|
||||
)
|
||||
|
||||
const score = useMemo(() => {
|
||||
return quizData.questions.reduce((sum, question) => {
|
||||
const answer = selectedAnswers[question.id]
|
||||
return sum + (typeof answer === 'number' && answer === question.answerIndex ? 1 : 0)
|
||||
}, 0)
|
||||
}, [quizData.questions, selectedAnswers])
|
||||
|
||||
const totalQuestions = quizData.questions.length
|
||||
const passed = totalQuestions > 0 && score / totalQuestions >= 0.8
|
||||
|
||||
const startQuiz = () => setStarted(true)
|
||||
|
||||
const chooseAnswer = (questionId: string, answerIndex: number) => {
|
||||
setSelectedAnswers((prev) => ({ ...prev, [questionId]: answerIndex }))
|
||||
}
|
||||
|
||||
const submitQuiz = async () => {
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const response = await fetch('/api/quiz/attempt', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
lessonId,
|
||||
courseId,
|
||||
moduleId,
|
||||
score,
|
||||
totalQuestions,
|
||||
passed,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}))
|
||||
throw new Error(data?.error || 'Failed to save quiz attempt')
|
||||
}
|
||||
|
||||
setSubmitted(true)
|
||||
onCompleted?.()
|
||||
router.refresh()
|
||||
} catch (error) {
|
||||
console.error('Quiz submission failed:', error)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const refreshQuiz = async () => {
|
||||
setIsRefreshing(true)
|
||||
try {
|
||||
const response = await fetch('/api/quiz', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
lessonId,
|
||||
topic: topicDraft,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}))
|
||||
throw new Error(data?.error || 'Failed to fetch quiz')
|
||||
}
|
||||
|
||||
const data = await response.json() as { quiz?: QuizCache }
|
||||
if (data.quiz) {
|
||||
setQuizData(data.quiz)
|
||||
setSelectedAnswers({})
|
||||
setStarted(false)
|
||||
setSubmitted(false)
|
||||
}
|
||||
setEditingTopic(false)
|
||||
router.refresh()
|
||||
} catch (error) {
|
||||
console.error('Quiz refresh failed:', error)
|
||||
} finally {
|
||||
setIsRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!started) {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-3xl p-4 sm:p-6">
|
||||
<Card className="border-border/60 bg-card/80 backdrop-blur">
|
||||
<CardHeader className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<HelpCircle className="h-3.5 w-3.5" />
|
||||
Quiz
|
||||
</Badge>
|
||||
<Badge variant="outline">{quizData.questions.length} questions</Badge>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
onClick={() => setEditingTopic((value) => !value)}
|
||||
>
|
||||
<Edit3 className="h-4 w-4" />
|
||||
Edit Topic
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<CardTitle className="text-2xl">{quizData.title}</CardTitle>
|
||||
<CardDescription>{quizData.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
{editingTopic && (
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-border/60 bg-muted/30 p-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
Override the topic and refresh the quiz cache.
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<Input value={topicDraft} onChange={(event) => setTopicDraft(event.target.value)} placeholder="Type a new quiz topic" />
|
||||
<Button type="button" onClick={refreshQuiz} disabled={isRefreshing} className="gap-2">
|
||||
<RefreshCw className={isRefreshing ? 'h-4 w-4 animate-spin' : 'h-4 w-4'} />
|
||||
{isRefreshing ? 'Refreshing...' : 'Fetch Quiz'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 rounded-lg border border-border/60 bg-muted/20 p-4 text-sm text-muted-foreground sm:grid-cols-2">
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Source:</span> {quizData.source.toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Topic:</span> {quizData.topic}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Questions:</span> {quizData.questions.length}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Pass mark:</span> 80%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="button" size="lg" className="w-full sm:w-auto" onClick={startQuiz}>
|
||||
Start Quiz
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-4xl p-4 sm:p-6 space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border/60 bg-card/80 px-4 py-3">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Quiz in progress</p>
|
||||
<h2 className="text-xl font-semibold">{quizData.title}</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Badge variant="secondary">Answered {answeredCount}/{totalQuestions}</Badge>
|
||||
<Badge variant="outline">Source: {quizData.source.toUpperCase()}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{quizData.questions.map((question, index) => {
|
||||
const selected = selectedAnswers[question.id]
|
||||
const answered = typeof selected === 'number'
|
||||
const isCorrect = submitted && selected === question.answerIndex
|
||||
const isWrong = submitted && answered && selected !== question.answerIndex
|
||||
|
||||
return (
|
||||
<Card key={question.id} className="border-border/60 bg-card/80">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<Badge variant="outline" className="mb-2">Question {index + 1}</Badge>
|
||||
<CardTitle className="text-lg leading-snug">{question.prompt}</CardTitle>
|
||||
</div>
|
||||
{submitted && isCorrect && <CheckCircle2 className="h-5 w-5 text-green-400 shrink-0" />}
|
||||
{submitted && isWrong && <XCircle className="h-5 w-5 text-red-400 shrink-0" />}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid gap-2">
|
||||
{question.options.map((option, optionIndex) => {
|
||||
const active = selected === optionIndex
|
||||
const revealCorrect = submitted && optionIndex === question.answerIndex
|
||||
const revealWrong = submitted && active && optionIndex !== question.answerIndex
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${question.id}-${optionIndex}`}
|
||||
type="button"
|
||||
onClick={() => !submitted && chooseAnswer(question.id, optionIndex)}
|
||||
className={[
|
||||
'rounded-lg border px-4 py-3 text-left transition-colors',
|
||||
active ? 'border-primary bg-primary/10' : 'border-border/60 bg-background hover:bg-muted/40',
|
||||
revealCorrect ? 'border-green-500/60 bg-green-500/10' : '',
|
||||
revealWrong ? 'border-red-500/60 bg-red-500/10' : '',
|
||||
submitted ? 'cursor-default' : '',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>{option}</span>
|
||||
{submitted && revealCorrect && <CheckCircle2 className="h-4 w-4 text-green-400" />}
|
||||
{submitted && revealWrong && <XCircle className="h-4 w-4 text-red-400" />}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{submitted && question.explanation && (
|
||||
<div className="rounded-lg bg-muted/40 p-3 text-sm text-muted-foreground">
|
||||
{question.explanation}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border/60 bg-card/80 px-4 py-3">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{submitted ? (
|
||||
<span>
|
||||
Score: <span className="font-semibold text-foreground">{score}/{totalQuestions}</span> —{' '}
|
||||
<span className={passed ? 'text-green-400 font-semibold' : 'text-red-400 font-semibold'}>
|
||||
{passed ? 'Passed' : 'Not passed yet'}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>Pick an answer for each question, then submit your attempt.</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!submitted ? (
|
||||
<Button type="button" onClick={submitQuiz} disabled={isSubmitting || answeredCount < totalQuestions}>
|
||||
{isSubmitting ? 'Submitting...' : 'Submit Quiz'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" variant="outline" onClick={() => {
|
||||
setSubmitted(false)
|
||||
setStarted(false)
|
||||
setSelectedAnswers({})
|
||||
}}>
|
||||
Retry Quiz
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
|
||||
interface SplashScreenProps {
|
||||
onComplete: () => void
|
||||
minDuration?: number
|
||||
}
|
||||
|
||||
export function SplashScreen({ onComplete, minDuration = 1500 }: SplashScreenProps) {
|
||||
const [visible, setVisible] = React.useState(true)
|
||||
const [logoScale, setLogoScale] = React.useState(0.8)
|
||||
const [textOpacity, setTextOpacity] = React.useState(0)
|
||||
const onCompleteRef = React.useRef(onComplete)
|
||||
|
||||
onCompleteRef.current = onComplete
|
||||
|
||||
React.useEffect(() => {
|
||||
let mounted = true
|
||||
|
||||
// Animation sequence
|
||||
const timer = setTimeout(() => {
|
||||
if (!mounted) return
|
||||
setLogoScale(1)
|
||||
setTextOpacity(1)
|
||||
|
||||
setTimeout(() => {
|
||||
if (!mounted) return
|
||||
setVisible(false)
|
||||
onCompleteRef.current()
|
||||
}, 800)
|
||||
}, minDuration)
|
||||
|
||||
return () => {
|
||||
mounted = false
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [minDuration])
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background"
|
||||
style={{ backgroundColor: 'var(--background)' }}
|
||||
role="img"
|
||||
aria-label="OfflineAcademy loading"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
{/* Animated logo - using favicon.ico */}
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
transform: `scale(${logoScale})`,
|
||||
transition: 'transform 0.6s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/favicon16x16.ico"
|
||||
alt="OfflineAcademy"
|
||||
className="w-28 h-28 object-contain"
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* App name */}
|
||||
<div
|
||||
className="text-center"
|
||||
style={{
|
||||
opacity: textOpacity,
|
||||
transition: 'opacity 0.4s ease 0.2s',
|
||||
}}
|
||||
>
|
||||
<h1 className="text-2xl font-bold tracking-tight bg-gradient-to-r from-primary to-emerald-400 bg-clip-text text-transparent">
|
||||
OfflineAcademy
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Your Personal Offline Learning Center</p>
|
||||
</div>
|
||||
|
||||
{/* Loading indicator */}
|
||||
<div
|
||||
className="w-48 h-1.5 bg-muted rounded-full overflow-hidden"
|
||||
style={{
|
||||
opacity: textOpacity,
|
||||
transition: 'opacity 0.4s ease 0.4s',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-primary via-emerald-400 to-primary rounded-full animate-pulse"
|
||||
style={{ width: '60%' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { createContext, useContext, useEffect, useState, useCallback } from 'react'
|
||||
import { Sun, Moon, Monitor, ChevronDown } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system'
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme
|
||||
setTheme: (theme: Theme) => void
|
||||
resolvedTheme: 'light' | 'dark'
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>('system')
|
||||
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('dark')
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
// Initialize from localStorage
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('theme') as Theme | null
|
||||
if (stored) {
|
||||
setTheme(stored)
|
||||
}
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
// Apply theme to document
|
||||
useEffect(() => {
|
||||
if (!mounted) return
|
||||
|
||||
const root = document.documentElement
|
||||
let resolved: 'light' | 'dark'
|
||||
|
||||
if (theme === 'system') {
|
||||
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
} else {
|
||||
resolved = theme
|
||||
}
|
||||
|
||||
setResolvedTheme(resolved)
|
||||
root.classList.remove('light', 'dark')
|
||||
root.classList.add(resolved)
|
||||
root.style.colorScheme = resolved
|
||||
localStorage.setItem('theme', theme)
|
||||
}, [theme, mounted])
|
||||
|
||||
// Listen for system theme changes
|
||||
useEffect(() => {
|
||||
if (!mounted || theme !== 'system') return
|
||||
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const handleChange = () => {
|
||||
const resolved = mediaQuery.matches ? 'dark' : 'light'
|
||||
setResolvedTheme(resolved)
|
||||
document.documentElement.classList.remove('light', 'dark')
|
||||
document.documentElement.classList.add(resolved)
|
||||
document.documentElement.style.colorScheme = resolved
|
||||
}
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange)
|
||||
return () => mediaQuery.removeEventListener('change', handleChange)
|
||||
}, [theme, mounted])
|
||||
|
||||
const setThemeCallback = useCallback((newTheme: Theme) => {
|
||||
setTheme(newTheme)
|
||||
}, [])
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme: 'system', setTheme: () => {}, resolvedTheme: 'dark' }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme: setThemeCallback, resolvedTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext)
|
||||
if (!context) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme, resolvedTheme } = useTheme()
|
||||
|
||||
const themes: { value: Theme; label: string; icon: React.ReactNode }[] = [
|
||||
{ value: 'light', label: 'Light', icon: <Sun className="h-4 w-4" /> },
|
||||
{ value: 'dark', label: 'Dark', icon: <Moon className="h-4 w-4" /> },
|
||||
{ value: 'system', label: 'System', icon: <Monitor className="h-4 w-4" /> },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="relative"
|
||||
onClick={() => {
|
||||
const currentIndex = themes.findIndex(t => t.value === theme)
|
||||
const nextIndex = (currentIndex + 1) % themes.length
|
||||
setTheme(themes[nextIndex].value)
|
||||
}}
|
||||
aria-label={`Current theme: ${theme}. Click to cycle.`}
|
||||
title={`Theme: ${theme.charAt(0).toUpperCase() + theme.slice(1)}`}
|
||||
>
|
||||
{themes.find(t => t.value === resolvedTheme)?.icon || <Monitor className="h-4 w-4" />}
|
||||
</Button>
|
||||
|
||||
{/* Tooltip */}
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 opacity-0 invisible transition-all group-hover:opacity-100 group-hover:visible pointer-events-none">
|
||||
<div className="bg-muted px-2 py-1 rounded text-xs text-muted-foreground whitespace-nowrap">
|
||||
Theme: {theme.charAt(0).toUpperCase() + theme.slice(1)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,641 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { Play, Pause, Volume2, VolumeX, Maximize, Minimize, SkipBack, SkipForward, Loader2, HelpCircle, PictureInPicture2, Check } from 'lucide-react'
|
||||
import { cn, formatTime } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { KeyboardShortcutsOverlay } from '@/components/KeyboardShortcutsOverlay'
|
||||
|
||||
interface VideoPlayerProps {
|
||||
src: string
|
||||
poster?: string
|
||||
subtitles?: string
|
||||
onTimeUpdate?: (time: number) => void
|
||||
onEnded?: () => void
|
||||
onProgressSave?: (time: number) => void
|
||||
initialTime?: number
|
||||
autoPlay?: boolean
|
||||
seekRequest?: { time: number; nonce: number } | null
|
||||
}
|
||||
|
||||
export function VideoPlayer({
|
||||
src,
|
||||
poster,
|
||||
subtitles,
|
||||
onTimeUpdate,
|
||||
onEnded,
|
||||
onProgressSave,
|
||||
initialTime = 0,
|
||||
autoPlay = true,
|
||||
seekRequest = null,
|
||||
}: VideoPlayerProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(initialTime)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const [volume, setVolume] = useState(1)
|
||||
const [isMuted, setIsMuted] = useState(false)
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
const [showControls, setShowControls] = useState(true)
|
||||
const [buffered, setBuffered] = useState(0)
|
||||
const [playbackRate, setPlaybackRate] = useState(1)
|
||||
const playbackRates = [0.75, 1, 1.25, 1.5, 2]
|
||||
const [showShortcuts, setShowShortcuts] = useState(false)
|
||||
const [isPiPSupported, setIsPiPSupported] = useState(false)
|
||||
const [isPiPActive, setIsPiPActive] = useState(false)
|
||||
const [hoverProgress, setHoverProgress] = useState<{ time: number; x: number } | null>(null)
|
||||
const [showSettingsMenu, setShowSettingsMenu] = useState(false)
|
||||
const settingsMenuRef = useRef<HTMLDivElement>(null)
|
||||
const controlsTimeoutRef = useRef<ReturnType<typeof setTimeout>>()
|
||||
const progressSaveTimeoutRef = useRef<ReturnType<typeof setTimeout>>()
|
||||
const hasAutoPlayedRef = useRef(false)
|
||||
|
||||
const formatTimeDisplay = (seconds: number) => formatTime(seconds)
|
||||
|
||||
useEffect(() => {
|
||||
if (autoPlay && videoRef.current && !hasAutoPlayedRef.current) {
|
||||
hasAutoPlayedRef.current = true
|
||||
const playPromise = videoRef.current.play()
|
||||
if (playPromise !== undefined) {
|
||||
playPromise
|
||||
.then(() => {
|
||||
setIsPlaying(true)
|
||||
})
|
||||
.catch(() => {
|
||||
// Auto-play failed (browser policy), user must tap to play
|
||||
setIsPlaying(false)
|
||||
hasAutoPlayedRef.current = false // Allow retry on user interaction
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [autoPlay])
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
|
||||
const handleEnterPiP = () => setIsPiPActive(true)
|
||||
const handleLeavePiP = () => setIsPiPActive(false)
|
||||
|
||||
video.addEventListener('enterpictureinpicture', handleEnterPiP)
|
||||
video.addEventListener('leavepictureinpicture', handleLeavePiP)
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('enterpictureinpicture', handleEnterPiP)
|
||||
video.removeEventListener('leavepictureinpicture', handleLeavePiP)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
setIsPiPSupported(Boolean((document as Document & { pictureInPictureEnabled?: boolean }).pictureInPictureEnabled))
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleFullscreenChange = () => {
|
||||
setIsFullscreen(Boolean(document.fullscreenElement))
|
||||
}
|
||||
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange)
|
||||
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange)
|
||||
}, [])
|
||||
|
||||
const handleMouseMove = useCallback(() => {
|
||||
setShowControls(true)
|
||||
if (controlsTimeoutRef.current) clearTimeout(controlsTimeoutRef.current)
|
||||
controlsTimeoutRef.current = setTimeout(() => setShowControls(false), 3000)
|
||||
}, [])
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
if (!videoRef.current) return
|
||||
if (isPlaying) {
|
||||
videoRef.current.pause()
|
||||
} else {
|
||||
videoRef.current.play()
|
||||
}
|
||||
}, [isPlaying])
|
||||
|
||||
const togglePictureInPicture = useCallback(async () => {
|
||||
if (!videoRef.current) return
|
||||
|
||||
const video = videoRef.current
|
||||
const doc = document as Document & { pictureInPictureEnabled?: boolean; pictureInPictureElement?: Element | null }
|
||||
|
||||
if (!doc.pictureInPictureEnabled) {
|
||||
window.alert('Picture-in-Picture is not supported in this browser.')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (doc.pictureInPictureElement === video) {
|
||||
await doc.exitPictureInPicture?.()
|
||||
} else {
|
||||
await video.requestPictureInPicture?.()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Picture-in-Picture toggle failed:', error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const saveProgress = useCallback((time: number) => {
|
||||
if (progressSaveTimeoutRef.current) clearTimeout(progressSaveTimeoutRef.current)
|
||||
progressSaveTimeoutRef.current = setTimeout(() => {
|
||||
onProgressSave?.(time)
|
||||
}, 500)
|
||||
}, [onProgressSave])
|
||||
|
||||
const seekToPercent = (percent: number) => {
|
||||
if (videoRef.current && duration > 0) {
|
||||
videoRef.current.currentTime = (percent / 100) * duration
|
||||
setCurrentTime(videoRef.current.currentTime)
|
||||
saveProgress(videoRef.current.currentTime)
|
||||
}
|
||||
}
|
||||
|
||||
const handleProgressHover = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (duration > 0) {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const percent = (e.clientX - rect.left) / rect.width
|
||||
const hoverTime = percent * duration
|
||||
setHoverProgress({ time: hoverTime, x: e.clientX - rect.left })
|
||||
}
|
||||
}, [duration])
|
||||
|
||||
const handleProgressLeave = useCallback(() => {
|
||||
setHoverProgress(null)
|
||||
}, [])
|
||||
|
||||
const handleProgressClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (videoRef.current && duration > 0) {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const percent = (e.clientX - rect.left) / rect.width
|
||||
videoRef.current.currentTime = percent * duration
|
||||
setCurrentTime(videoRef.current.currentTime)
|
||||
saveProgress(videoRef.current.currentTime)
|
||||
}
|
||||
}, [duration, saveProgress])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!videoRef.current) return
|
||||
const video = videoRef.current
|
||||
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
|
||||
|
||||
switch (e.key) {
|
||||
case '?':
|
||||
if (e.shiftKey) {
|
||||
e.preventDefault()
|
||||
setShowShortcuts(prev => !prev)
|
||||
}
|
||||
break
|
||||
case ' ':
|
||||
case 'k':
|
||||
e.preventDefault()
|
||||
togglePlay()
|
||||
break
|
||||
case 'j':
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault()
|
||||
video.currentTime = Math.max(0, video.currentTime - 10)
|
||||
saveProgress(video.currentTime)
|
||||
break
|
||||
case 'l':
|
||||
case 'ArrowRight':
|
||||
e.preventDefault()
|
||||
video.currentTime = Math.min(video.duration, video.currentTime + 30)
|
||||
saveProgress(video.currentTime)
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
video.volume = Math.min(1, video.volume + 0.1)
|
||||
setVolume(video.volume)
|
||||
setIsMuted(false)
|
||||
break
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
video.volume = Math.max(0, video.volume - 0.1)
|
||||
setVolume(video.volume)
|
||||
if (video.volume === 0) setIsMuted(true)
|
||||
break
|
||||
case 'm':
|
||||
if (videoRef.current) {
|
||||
videoRef.current.muted = !isMuted
|
||||
setIsMuted(!isMuted)
|
||||
}
|
||||
break
|
||||
case 'f':
|
||||
if (videoRef.current) {
|
||||
if (!isFullscreen) {
|
||||
videoRef.current.requestFullscreen()
|
||||
} else {
|
||||
document.exitFullscreen()
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'p':
|
||||
case 'i':
|
||||
e.preventDefault()
|
||||
togglePictureInPicture()
|
||||
break
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
if (showShortcuts) {
|
||||
setShowShortcuts(false)
|
||||
break
|
||||
}
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen()
|
||||
break
|
||||
}
|
||||
if (isPiPActive) {
|
||||
togglePictureInPicture()
|
||||
}
|
||||
break
|
||||
case ',':
|
||||
case '[':
|
||||
video.playbackRate = Math.max(0.25, video.playbackRate - 0.25)
|
||||
setPlaybackRate(video.playbackRate)
|
||||
break
|
||||
case '.':
|
||||
case ']':
|
||||
video.playbackRate = Math.min(2, video.playbackRate + 0.25)
|
||||
setPlaybackRate(video.playbackRate)
|
||||
break
|
||||
case '0':
|
||||
case '1':
|
||||
case '2':
|
||||
case '3':
|
||||
case '4':
|
||||
case '5':
|
||||
case '6':
|
||||
case '7':
|
||||
case '8':
|
||||
case '9':
|
||||
e.preventDefault()
|
||||
seekToPercent(parseInt(e.key) * 10)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [togglePlay, isMuted, isFullscreen, saveProgress, duration, seekToPercent, togglePictureInPicture, showShortcuts, isPiPActive])
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
if (videoRef.current) {
|
||||
setDuration(videoRef.current.duration)
|
||||
if (initialTime > 0) {
|
||||
videoRef.current.currentTime = initialTime
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
if (videoRef.current) {
|
||||
const time = videoRef.current.currentTime
|
||||
setCurrentTime(time)
|
||||
onTimeUpdate?.(time)
|
||||
saveProgress(time)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDurationChange = () => {
|
||||
if (videoRef.current) {
|
||||
setDuration(videoRef.current.duration)
|
||||
}
|
||||
}
|
||||
|
||||
const handleProgress = () => {
|
||||
if (videoRef.current && videoRef.current.buffered.length > 0) {
|
||||
setBuffered(videoRef.current.buffered.end(videoRef.current.buffered.length - 1))
|
||||
}
|
||||
}
|
||||
|
||||
const handleEnded = () => {
|
||||
setIsPlaying(false)
|
||||
onEnded?.()
|
||||
}
|
||||
|
||||
const handlePlay = () => setIsPlaying(true)
|
||||
|
||||
const handlePause = () => {
|
||||
setIsPlaying(false)
|
||||
if (videoRef.current) {
|
||||
saveProgress(videoRef.current.currentTime)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVolumeChangeNative = () => {
|
||||
if (videoRef.current) {
|
||||
setVolume(videoRef.current.volume)
|
||||
setIsMuted(videoRef.current.muted)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRateChange = () => {
|
||||
if (videoRef.current) {
|
||||
setPlaybackRate(videoRef.current.playbackRate)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.playbackRate = playbackRate
|
||||
}
|
||||
}, [playbackRate])
|
||||
|
||||
useEffect(() => {
|
||||
if (videoRef.current && initialTime > 0 && videoRef.current.readyState >= 1) {
|
||||
videoRef.current.currentTime = initialTime
|
||||
// Allow auto-play attempt again when seeking to saved position
|
||||
hasAutoPlayedRef.current = false
|
||||
}
|
||||
}, [initialTime])
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoRef.current || !seekRequest) return
|
||||
|
||||
const nextTime = Math.max(0, seekRequest.time)
|
||||
videoRef.current.currentTime = nextTime
|
||||
setCurrentTime(nextTime)
|
||||
saveProgress(nextTime)
|
||||
|
||||
videoRef.current.play().catch(() => {})
|
||||
}, [seekRequest?.nonce, saveProgress])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (controlsTimeoutRef.current) clearTimeout(controlsTimeoutRef.current)
|
||||
if (progressSaveTimeoutRef.current) clearTimeout(progressSaveTimeoutRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (settingsMenuRef.current && !settingsMenuRef.current.contains(e.target as Node)) {
|
||||
setShowSettingsMenu(false)
|
||||
}
|
||||
}
|
||||
if (showSettingsMenu) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [showSettingsMenu])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="video-player-container"
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={() => setShowControls(false)}
|
||||
>
|
||||
<div className="relative w-full h-full" style={{ position: 'relative' }}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={src}
|
||||
poster={poster}
|
||||
preload="metadata"
|
||||
playsInline
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onDurationChange={handleDurationChange}
|
||||
onProgress={handleProgress}
|
||||
onEnded={handleEnded}
|
||||
onPlay={handlePlay}
|
||||
onPause={handlePause}
|
||||
onVolumeChange={handleVolumeChangeNative}
|
||||
onRateChange={handleRateChange}
|
||||
onClick={togglePlay}
|
||||
className="w-full h-full cursor-pointer"
|
||||
>
|
||||
{subtitles && (
|
||||
<track kind="subtitles" src={subtitles} srcLang="en" label="English" default />
|
||||
)}
|
||||
</video>
|
||||
</div>
|
||||
|
||||
{videoRef.current && videoRef.current.readyState < 2 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50 z-10">
|
||||
<Loader2 className="h-8 w-8 text-primary animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'absolute bottom-0 left-0 right-0 p-3 bg-gradient-to-t from-black/90 to-transparent transition-opacity duration-200',
|
||||
showControls || isPlaying ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2 relative">
|
||||
<span className="text-xs text-white font-mono w-12 text-right">
|
||||
{formatTimeDisplay(currentTime)}
|
||||
</span>
|
||||
<div
|
||||
className="flex-1 h-1.5 bg-white/20 rounded-full cursor-pointer relative"
|
||||
onMouseMove={handleProgressHover}
|
||||
onMouseLeave={handleProgressLeave}
|
||||
onClick={handleProgressClick}
|
||||
>
|
||||
{buffered > 0 && duration > 0 && (
|
||||
<div
|
||||
className="absolute top-0 left-0 h-full bg-white/30 rounded-full"
|
||||
style={{ width: `${(buffered / duration) * 100}%` }}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="absolute top-0 left-0 h-full bg-primary rounded-full transition-none"
|
||||
style={{ width: `${duration > 0 ? (currentTime / duration) * 100 : 0}%` }}
|
||||
/>
|
||||
{hoverProgress && (
|
||||
<div
|
||||
className="absolute bottom-full left-0 mb-2 transform -translate-x-1/2 transition-opacity duration-100 opacity-100 pointer-events-none z-20"
|
||||
style={{ left: `${(hoverProgress.x / (duration > 0 ? 1 : 1)) * 100}%` }}
|
||||
>
|
||||
<div className="bg-black/90 text-white text-xs px-2 py-1 rounded whitespace-nowrap shadow-lg">
|
||||
{formatTimeDisplay(hoverProgress.time)}
|
||||
</div>
|
||||
<div className="w-0 h-0 border-4 border-t-black/90 border-r-transparent border-l-transparent border-b-transparent mt-1" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-white font-mono w-12">
|
||||
{formatTimeDisplay(duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-white hover:bg-white/20"
|
||||
onClick={togglePlay}
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-white hover:bg-white/20"
|
||||
onClick={() => videoRef.current && (videoRef.current.currentTime = Math.max(0, videoRef.current.currentTime - 10))}
|
||||
aria-label="Rewind 10s"
|
||||
>
|
||||
<SkipBack className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-white hover:bg-white/20"
|
||||
onClick={() => videoRef.current && (videoRef.current.currentTime = Math.min(duration, videoRef.current.currentTime + 30))}
|
||||
aria-label="Forward 30s"
|
||||
>
|
||||
<SkipForward className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 relative">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-white hover:bg-white/20"
|
||||
onClick={() => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.muted = !isMuted
|
||||
setIsMuted(!isMuted)
|
||||
}
|
||||
}}
|
||||
aria-label={isMuted ? 'Unmute' : 'Mute'}
|
||||
>
|
||||
{isMuted || volume === 0 ? <VolumeX className="h-5 w-5" /> : <Volume2 className="h-5 w-5" />}
|
||||
</Button>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={isMuted ? 0 : volume}
|
||||
onChange={(e) => {
|
||||
const vol = parseFloat(e.target.value)
|
||||
if (videoRef.current) {
|
||||
videoRef.current.volume = vol
|
||||
setVolume(vol)
|
||||
setIsMuted(vol === 0)
|
||||
}
|
||||
}}
|
||||
className="w-20 h-1 bg-white/20 rounded-lg appearance-none accent-primary cursor-pointer"
|
||||
aria-label="Volume"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Fullscreen - FIRST */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-white hover:bg-white/20"
|
||||
onClick={async () => {
|
||||
if (videoRef.current) {
|
||||
if (!isFullscreen) {
|
||||
try {
|
||||
await videoRef.current.requestFullscreen({ navigationUI: 'hide' })
|
||||
} catch {
|
||||
videoRef.current.requestFullscreen()
|
||||
}
|
||||
} else {
|
||||
document.exitFullscreen()
|
||||
}
|
||||
}
|
||||
}}
|
||||
aria-label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
||||
>
|
||||
{isFullscreen ? <Minimize className="h-5 w-5" /> : <Maximize className="h-5 w-5" />}
|
||||
</Button>
|
||||
|
||||
{/* Picture-in-Picture */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
'text-white hover:bg-white/20',
|
||||
isPiPActive && 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||
)}
|
||||
onClick={togglePictureInPicture}
|
||||
disabled={!isPiPSupported}
|
||||
aria-label={isPiPActive ? 'Exit Picture-in-Picture' : 'Picture-in-Picture'}
|
||||
title={isPiPSupported ? 'Toggle Picture-in-Picture (I)' : 'Picture-in-Picture not supported'}
|
||||
>
|
||||
<PictureInPicture2 className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
{/* Settings Menu (3-dot) - Speed + Shortcuts */}
|
||||
<div className="relative" ref={settingsMenuRef}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-white hover:bg-white/20"
|
||||
onClick={() => setShowSettingsMenu(!showSettingsMenu)}
|
||||
aria-label="Settings"
|
||||
aria-expanded={showSettingsMenu}
|
||||
>
|
||||
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="1"></circle>
|
||||
<circle cx="19" cy="12" r="1"></circle>
|
||||
<circle cx="5" cy="12" r="1"></circle>
|
||||
</svg>
|
||||
</Button>
|
||||
|
||||
{showSettingsMenu && (
|
||||
<div
|
||||
className="absolute bottom-full right-0 mb-2 w-40 glass-card-elevated rounded-lg p-2 shadow-xl border border-white/10 animate-in zoom-in-95 z-20"
|
||||
role="menu"
|
||||
>
|
||||
<div className="px-3 py-2 text-xs font-semibold text-white/60 uppercase tracking-wider">Speed</div>
|
||||
{playbackRates.map((rate) => (
|
||||
<button
|
||||
key={rate}
|
||||
onClick={() => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.playbackRate = rate
|
||||
}
|
||||
setPlaybackRate(rate)
|
||||
setShowSettingsMenu(false)
|
||||
}}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 text-sm text-white hover:bg-white/10 rounded ${playbackRate === rate ? 'bg-primary/20 text-primary' : ''}`}
|
||||
role="menuitemradio"
|
||||
aria-checked={playbackRate === rate}
|
||||
>
|
||||
<span>{rate}x</span>
|
||||
{playbackRate === rate && <Check className="h-4 w-4" />}
|
||||
</button>
|
||||
))}
|
||||
<hr className="border-white/10 my-1" />
|
||||
<button
|
||||
onClick={() => setShowShortcuts(true)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-sm text-white hover:bg-white/10 rounded"
|
||||
role="menuitem"
|
||||
>
|
||||
<span>Keyboard Shortcuts</span>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
</button>
|
||||
<hr className="border-white/10 my-1" />
|
||||
<button
|
||||
onClick={() => setShowSettingsMenu(false)}
|
||||
className="w-full flex items-center justify-center px-3 py-2 text-sm text-white/70 hover:bg-white/10 rounded"
|
||||
role="menuitem"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<KeyboardShortcutsOverlay isOpen={showShortcuts} onClose={() => setShowShortcuts(false)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as React from 'react'
|
||||
import * as AccordionPrimitive from '@radix-ui/react-accordion'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Accordion = AccordionPrimitive.Root
|
||||
|
||||
const AccordionItem = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AccordionPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn('border-b', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AccordionItem.displayName = 'AccordionItem'
|
||||
|
||||
const AccordionTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
))
|
||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
|
||||
|
||||
const AccordionContent = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn('pb-4', className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
))
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
@@ -0,0 +1,44 @@
|
||||
import * as React from 'react'
|
||||
import * as AvatarPrimitive from '@radix-ui/react-avatar'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn('aspect-square h-full w-full', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-full w-full items-center justify-center rounded-full bg-muted',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
@@ -0,0 +1,32 @@
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
|
||||
secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
destructive: 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
|
||||
outline: 'text-foreground',
|
||||
success: 'border-transparent bg-green-500 text-white hover:bg-green-600',
|
||||
warning: 'border-transparent bg-yellow-500 text-white hover:bg-yellow-600',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,52 @@
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,66 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Card.displayName = 'Card'
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col space-y-1.5 p-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
CardHeader.displayName = 'CardHeader'
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn('text-2xl font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
CardTitle.displayName = 'CardTitle'
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
CardDescription.displayName = 'CardDescription'
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
)
|
||||
)
|
||||
CardContent.displayName = 'CardContent'
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center p-6 pt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
CardFooter.displayName = 'CardFooter'
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
@@ -0,0 +1,171 @@
|
||||
import * as React from 'react'
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
|
||||
import { Check, ChevronRight, Circle } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { inset?: boolean }
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset?: boolean }
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2.5 w-2.5 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { inset?: boolean }
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return <span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />
|
||||
}
|
||||
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as React from 'react'
|
||||
import * as ProgressPrimitive from '@radix-ui/react-progress'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Progress = React.forwardRef(
|
||||
(
|
||||
{ className, value = 0, ...props }: React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> & { value?: number },
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative h-2 w-full overflow-hidden rounded-full bg-secondary', className)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: 'translateX(-' + (100 - value) + '%)' }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
)
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
|
||||
export { Progress }
|
||||
@@ -0,0 +1,45 @@
|
||||
import * as React from 'react'
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative overflow-hidden', className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = 'vertical', ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'flex touch-none select-none transition-colors',
|
||||
orientation === 'vertical' &&
|
||||
'h-full w-2.5 border-l border-l-transparent p-[1px]',
|
||||
orientation === 'horizontal' &&
|
||||
'h-2.5 flex-col border-t border-t-transparent p-[1px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -0,0 +1,158 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as SelectPrimitive from '@radix-ui/react-select'
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
|
||||
error?: boolean
|
||||
}
|
||||
>(({ className, children, error, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-9 w-full items-center justify-between rounded-lg border bg-background px-3 py-2 text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||
error && 'border-destructive focus:ring-destructive',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default items-center justify-center py-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default items-center justify-center py-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as React from 'react'
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'shrink-0 bg-border',
|
||||
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,15 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn('animate-pulse rounded-md bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<'textarea'>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = 'Textarea'
|
||||
|
||||
export { Textarea }
|
||||
@@ -0,0 +1,25 @@
|
||||
import * as React from 'react'
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
@@ -0,0 +1,25 @@
|
||||
services:
|
||||
offlineacademy:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: offlineacademy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6767:6767"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 6767
|
||||
HOSTNAME: 0.0.0.0
|
||||
DATABASE_URL: file:./dev.db
|
||||
COURSES_ROOT: ./My_Courses
|
||||
NEXT_PUBLIC_APP_URL: http://localhost:6767
|
||||
volumes:
|
||||
# Put your course folders wherever you want on the host,
|
||||
# then map that folder to /app/My_Courses inside the container.
|
||||
- ./My_Courses:/app/My_Courses
|
||||
|
||||
# Persist the SQLite database outside the image.
|
||||
- ./prisma/dev.db:/app/prisma/dev.db
|
||||
@@ -0,0 +1,84 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface Toast {
|
||||
id: string
|
||||
title?: string
|
||||
description?: string
|
||||
variant?: 'default' | 'destructive'
|
||||
}
|
||||
|
||||
interface ToastContext {
|
||||
toasts: Toast[]
|
||||
toast: (props: Omit<Toast, 'id'>) => void
|
||||
dismiss: (id: string) => void
|
||||
}
|
||||
|
||||
const ToastContext = React.createContext<ToastContext | undefined>(undefined)
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [toasts, setToasts] = React.useState<Toast[]>([])
|
||||
|
||||
const toast = React.useCallback((props: Omit<Toast, 'id'>) => {
|
||||
const id = Math.random().toString(36).slice(2)
|
||||
setToasts((prev) => [...prev, { ...props, id }])
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||
}, 5000)
|
||||
}, [])
|
||||
|
||||
const dismiss = React.useCallback((id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ toasts, toast, dismiss }}>
|
||||
{children}
|
||||
<ToastViewport toasts={toasts} onDismiss={dismiss} />
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function ToastViewport({ toasts, onDismiss }: { toasts: Toast[]; onDismiss: (id: string) => void }) {
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
|
||||
{toasts.map((t) => (
|
||||
<ToastItem key={t.id} toast={t} onDismiss={onDismiss} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: (id: string) => void }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-start gap-3 p-4 rounded-lg border bg-card shadow-lg min-w-[280px] max-w-sm animate-in slide-in-from-bottom-2',
|
||||
toast.variant === 'destructive' && 'border-red-500/50 bg-red-500/10 text-red-500'
|
||||
)}
|
||||
>
|
||||
<div className="flex-1">
|
||||
{toast.title && <div className="font-medium">{toast.title}</div>}
|
||||
{toast.description && <div className="text-sm opacity-90 mt-0.5">{toast.description}</div>}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onDismiss(toast.id)}
|
||||
className="text-muted-foreground hover:text-foreground shrink-0"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const context = React.useContext(ToastContext)
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within a ToastProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface CourseTitleLike {
|
||||
name: string
|
||||
displayName?: string | null
|
||||
}
|
||||
|
||||
export function getCourseDisplayName(course: CourseTitleLike): string {
|
||||
return course.displayName?.trim() || course.name
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { readFile, readdir, stat } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
export const COURSE_METADATA_FILENAMES = [
|
||||
'offlineacademy.metadata.json',
|
||||
'course.metadata.json',
|
||||
'metadata.json',
|
||||
'.offlineacademy.json',
|
||||
]
|
||||
|
||||
export const MODULE_METADATA_FILENAMES = [
|
||||
'offlineacademy.metadata.json',
|
||||
'module.metadata.json',
|
||||
'metadata.json',
|
||||
'.offlineacademy.json',
|
||||
]
|
||||
|
||||
const ALL_METADATA_FILENAMES = new Set([
|
||||
...COURSE_METADATA_FILENAMES,
|
||||
...MODULE_METADATA_FILENAMES,
|
||||
])
|
||||
|
||||
export type CourseMetadataInput = {
|
||||
title?: unknown
|
||||
displayName?: unknown
|
||||
description?: unknown
|
||||
thumbnail?: unknown
|
||||
tags?: unknown
|
||||
categories?: unknown
|
||||
favorited?: unknown
|
||||
favorite?: unknown
|
||||
}
|
||||
|
||||
export type ApplyCourseMetadataResult = {
|
||||
appliedFields: string[]
|
||||
skippedFields: string[]
|
||||
tagsAdded: string[]
|
||||
source: string
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null
|
||||
const trimmed = value.trim()
|
||||
return trimmed.length > 0 ? trimmed : null
|
||||
}
|
||||
|
||||
function normalizeStringList(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map(asTrimmedString)
|
||||
.filter((item): item is string => Boolean(item))
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
.split(',')
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function normalizeMetadata(input: CourseMetadataInput) {
|
||||
const displayName = asTrimmedString(input.displayName) || asTrimmedString(input.title)
|
||||
const description = asTrimmedString(input.description)
|
||||
const thumbnail = asTrimmedString(input.thumbnail)
|
||||
const tags = normalizeStringList(input.tags)
|
||||
const categories = normalizeStringList(input.categories).map(category => (
|
||||
category.startsWith('category:') ? category : `category:${category}`
|
||||
))
|
||||
const favorited = typeof input.favorited === 'boolean'
|
||||
? input.favorited
|
||||
: typeof input.favorite === 'boolean'
|
||||
? input.favorite
|
||||
: undefined
|
||||
|
||||
return {
|
||||
displayName,
|
||||
description,
|
||||
thumbnail,
|
||||
tags: Array.from(new Set([...tags, ...categories])),
|
||||
favorited,
|
||||
}
|
||||
}
|
||||
|
||||
export function isMetadataFileName(fileName: string) {
|
||||
return ALL_METADATA_FILENAMES.has(fileName.toLowerCase())
|
||||
}
|
||||
|
||||
export async function readCourseMetadataFile(dirPath: string, fileNames = COURSE_METADATA_FILENAMES) {
|
||||
for (const fileName of fileNames) {
|
||||
const fullPath = join(dirPath, fileName)
|
||||
try {
|
||||
const fileStat = await stat(fullPath)
|
||||
if (!fileStat.isFile()) continue
|
||||
const raw = await readFile(fullPath, 'utf8')
|
||||
return {
|
||||
fileName,
|
||||
path: fullPath,
|
||||
metadata: JSON.parse(raw) as CourseMetadataInput,
|
||||
}
|
||||
} catch {
|
||||
// Try the next supported filename.
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export async function readModuleMetadataFiles(coursePath: string) {
|
||||
const results: Array<{ moduleName: string; fileName: string; path: string; metadata: CourseMetadataInput }> = []
|
||||
|
||||
try {
|
||||
const entries = await readdir(coursePath, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name.startsWith('.')) continue
|
||||
const found = await readCourseMetadataFile(join(coursePath, entry.name), MODULE_METADATA_FILENAMES)
|
||||
if (found) {
|
||||
results.push({ moduleName: entry.name, ...found })
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return results
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
export async function applyCourseMetadata(courseId: string, metadata: CourseMetadataInput, source: string): Promise<ApplyCourseMetadataResult> {
|
||||
const normalized = normalizeMetadata(metadata)
|
||||
const current = await prisma.course.findUnique({
|
||||
where: { id: courseId },
|
||||
select: {
|
||||
id: true,
|
||||
displayName: true,
|
||||
description: true,
|
||||
thumbnail: true,
|
||||
favorited: true,
|
||||
courseTags: { include: { tag: true } },
|
||||
},
|
||||
})
|
||||
|
||||
if (!current) {
|
||||
return { appliedFields: [], skippedFields: ['course:not-found'], tagsAdded: [], source }
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {}
|
||||
const appliedFields: string[] = []
|
||||
const skippedFields: string[] = []
|
||||
|
||||
if (normalized.displayName) {
|
||||
if (!current.displayName?.trim()) {
|
||||
data.displayName = normalized.displayName
|
||||
appliedFields.push('displayName')
|
||||
} else {
|
||||
skippedFields.push('displayName')
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized.description) {
|
||||
if (!current.description?.trim()) {
|
||||
data.description = normalized.description
|
||||
appliedFields.push('description')
|
||||
} else {
|
||||
skippedFields.push('description')
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized.thumbnail) {
|
||||
if (!current.thumbnail?.trim()) {
|
||||
data.thumbnail = normalized.thumbnail
|
||||
appliedFields.push('thumbnail')
|
||||
} else {
|
||||
skippedFields.push('thumbnail')
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(data).length > 0) {
|
||||
await prisma.course.update({ where: { id: courseId }, data })
|
||||
}
|
||||
|
||||
const existingTagNames = new Set(current.courseTags.map(courseTag => courseTag.tag.name.toLowerCase()))
|
||||
const tagsAdded: string[] = []
|
||||
|
||||
for (const tagName of normalized.tags) {
|
||||
if (existingTagNames.has(tagName.toLowerCase())) continue
|
||||
|
||||
const tag = await prisma.tag.upsert({
|
||||
where: { name: tagName },
|
||||
update: {},
|
||||
create: { name: tagName },
|
||||
})
|
||||
|
||||
await prisma.courseTag.upsert({
|
||||
where: { courseId_tagId: { courseId, tagId: tag.id } },
|
||||
update: {},
|
||||
create: { courseId, tagId: tag.id },
|
||||
})
|
||||
|
||||
tagsAdded.push(tagName)
|
||||
}
|
||||
|
||||
return { appliedFields, skippedFields, tagsAdded, source }
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { readdir, stat } from 'fs/promises'
|
||||
import { join, extname, basename } from 'path'
|
||||
import { writeFile, mkdir } from 'fs/promises'
|
||||
import { existsSync } from 'fs'
|
||||
|
||||
const LOCAL_THUMBNAIL_NAMES = ['cover', 'thumbnail']
|
||||
const LOCAL_THUMBNAIL_EXTS = ['.jpg', '.jpeg', '.png', '.webp']
|
||||
|
||||
const COVER_GRADIENTS = [
|
||||
'bg-gradient-to-br from-indigo-500 via-purple-500 to-pink-500',
|
||||
'bg-gradient-to-br from-blue-500 via-cyan-500 to-teal-500',
|
||||
'bg-gradient-to-br from-emerald-500 via-green-500 to-lime-500',
|
||||
'bg-gradient-to-br from-orange-500 via-red-500 to-rose-500',
|
||||
'bg-gradient-to-br from-violet-500 via-fuchsia-500 to-pink-500',
|
||||
'bg-gradient-to-br from-sky-500 via-blue-500 to-indigo-500',
|
||||
'bg-gradient-to-br from-amber-500 via-orange-500 to-red-500',
|
||||
'bg-gradient-to-br from-teal-500 via-emerald-500 to-green-500',
|
||||
]
|
||||
|
||||
export function getDeterministicGradient(title: string): string {
|
||||
let hash = 0
|
||||
for (let i = 0; i < title.length; i++) {
|
||||
hash = ((hash << 5) - hash + title.charCodeAt(i)) | 0
|
||||
}
|
||||
const index = Math.abs(hash) % COVER_GRADIENTS.length
|
||||
return COVER_GRADIENTS[index]
|
||||
}
|
||||
|
||||
export async function getLocalCourseThumbnailPath(coursePath: string): Promise<string | null> {
|
||||
try {
|
||||
const entries = await readdir(coursePath, { withFileTypes: true })
|
||||
const files = entries.filter(e => e.isFile())
|
||||
|
||||
for (const baseName of LOCAL_THUMBNAIL_NAMES) {
|
||||
for (const ext of LOCAL_THUMBNAIL_EXTS) {
|
||||
const match = files.find(f => f.name.toLowerCase() === `${baseName}${ext}`)
|
||||
if (match) {
|
||||
return join(coursePath, match.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// coursePath may not exist yet or be unreadable
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function ensurePublicThumbnail(
|
||||
courseSlug: string,
|
||||
sourcePath: string,
|
||||
): Promise<string | null> {
|
||||
const ext = extname(sourcePath).toLowerCase()
|
||||
if (!['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) return null
|
||||
|
||||
const thumbDir = join(process.cwd(), 'public', 'thumbnails', 'courses')
|
||||
if (!existsSync(thumbDir)) {
|
||||
await mkdir(thumbDir, { recursive: true })
|
||||
}
|
||||
|
||||
const targetName = `${courseSlug}-thumb${ext}`
|
||||
const targetPath = join(thumbDir, targetName)
|
||||
|
||||
try {
|
||||
const sourceStat = await stat(sourcePath)
|
||||
const targetStat = await stat(targetPath).catch(() => null)
|
||||
if (!targetStat || sourceStat.mtimeMs !== targetStat.mtimeMs || sourceStat.size !== targetStat.size) {
|
||||
const { copyFile } = await import('fs/promises')
|
||||
await copyFile(sourcePath, targetPath)
|
||||
const targetStat2 = await stat(targetPath)
|
||||
await stat(targetPath) // ensure exists
|
||||
try {
|
||||
// touch target mtime to match source so next run can diff by mtime
|
||||
const { utimes } = await import('fs/promises')
|
||||
await utimes(targetPath, new Date(sourceStat.mtimeMs), new Date(sourceStat.mtimeMs))
|
||||
} catch {
|
||||
// ignore utimes failure; content copy is what matters
|
||||
}
|
||||
}
|
||||
return `/thumbnails/courses/${targetName}`
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function getThumbnailPriority(coursePath: string): 'local' | 'downloaded' | 'gradient' {
|
||||
return 'local'
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
const COVER_GRADIENTS = [
|
||||
'bg-gradient-to-br from-indigo-500 via-purple-500 to-pink-500',
|
||||
'bg-gradient-to-br from-blue-500 via-cyan-500 to-teal-500',
|
||||
'bg-gradient-to-br from-emerald-500 via-green-500 to-lime-500',
|
||||
'bg-gradient-to-br from-orange-500 via-red-500 to-rose-500',
|
||||
'bg-gradient-to-br from-violet-500 via-fuchsia-500 to-pink-500',
|
||||
'bg-gradient-to-br from-sky-500 via-blue-500 to-indigo-500',
|
||||
'bg-gradient-to-br from-amber-500 via-orange-500 to-red-500',
|
||||
'bg-gradient-to-br from-teal-500 via-emerald-500 to-green-500',
|
||||
]
|
||||
|
||||
export function getDeterministicGradient(title: string): string {
|
||||
let hash = 0
|
||||
for (let i = 0; i < title.length; i++) {
|
||||
hash = ((hash << 5) - hash + title.charCodeAt(i)) | 0
|
||||
}
|
||||
const index = Math.abs(hash) % COVER_GRADIENTS.length
|
||||
return COVER_GRADIENTS[index]
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
export interface Course {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
thumbnail: string | null
|
||||
description: string | null
|
||||
path: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
_count: { modules: number; lessons: number }
|
||||
progress: {
|
||||
completedLessons: number
|
||||
totalLessons: number
|
||||
percentage: number
|
||||
lastWatched: string | null
|
||||
}
|
||||
coverClass?: string
|
||||
}
|
||||
|
||||
interface CoursesResponse {
|
||||
courses: any[]
|
||||
pagination: {
|
||||
page: number
|
||||
limit: number
|
||||
total: number
|
||||
totalPages: number
|
||||
hasNext: boolean
|
||||
hasPrev: boolean
|
||||
}
|
||||
}
|
||||
|
||||
interface UseCoursesOptions {
|
||||
initialPage?: number
|
||||
initialLimit?: number
|
||||
initialSearch?: string
|
||||
initialFilter?: string
|
||||
initialSortBy?: string
|
||||
initialSortOrder?: string
|
||||
}
|
||||
|
||||
export function useCourses(options: UseCoursesOptions = {}) {
|
||||
const [courses, setCourses] = useState<any[]>([])
|
||||
const [tags, setTags] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [pagination, setPagination] = useState({
|
||||
page: options.initialPage || 1,
|
||||
limit: options.initialLimit || 10,
|
||||
total: 0,
|
||||
totalPages: 0,
|
||||
hasNext: false,
|
||||
hasPrev: false,
|
||||
})
|
||||
const [search, setSearch] = useState(options.initialSearch || '')
|
||||
const [filter, setFilter] = useState(options.initialFilter || 'all')
|
||||
const [sortBy, setSortBy] = useState(options.initialSortBy || 'updatedAt')
|
||||
const [sortOrder, setSortOrder] = useState(options.initialSortOrder || 'desc')
|
||||
const [debouncedSearch, setDebouncedSearch] = useState(options.initialSearch || '')
|
||||
|
||||
// Debounce search
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedSearch(search)
|
||||
setPagination(prev => ({ ...prev, page: 1 }))
|
||||
}, 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [search])
|
||||
|
||||
const fetchCourses = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const params = new URLSearchParams({
|
||||
page: pagination.page.toString(),
|
||||
limit: pagination.limit.toString(),
|
||||
search: debouncedSearch,
|
||||
filter,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/courses?${params.toString()}`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch courses')
|
||||
}
|
||||
const data = await response.json()
|
||||
setCourses(data.courses)
|
||||
setTags(data.tags || [])
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
total: data.pagination.total,
|
||||
totalPages: data.pagination.totalPages,
|
||||
hasNext: data.pagination.hasNext,
|
||||
hasPrev: data.pagination.hasPrev,
|
||||
}))
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch courses')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [pagination.page, pagination.limit, debouncedSearch, filter, sortBy, sortOrder])
|
||||
|
||||
useEffect(() => {
|
||||
fetchCourses()
|
||||
}, [fetchCourses])
|
||||
|
||||
const goToPage = (page: number) => {
|
||||
setPagination(prev => ({ ...prev, page }))
|
||||
}
|
||||
|
||||
const nextPage = () => {
|
||||
if (pagination.hasNext) {
|
||||
setPagination(prev => ({ ...prev, page: prev.page + 1 }))
|
||||
}
|
||||
}
|
||||
|
||||
const prevPage = () => {
|
||||
if (pagination.hasPrev) {
|
||||
setPagination(prev => ({ ...prev, page: prev.page - 1 }))
|
||||
}
|
||||
}
|
||||
|
||||
const changeLimit = (limit: number) => {
|
||||
setPagination(prev => ({ ...prev, limit, page: 1 }))
|
||||
}
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
setSearch(value)
|
||||
}
|
||||
|
||||
const handleFilter = (value: string) => {
|
||||
setFilter(value)
|
||||
setPagination(prev => ({ ...prev, page: 1 }))
|
||||
}
|
||||
|
||||
const handleSort = (field: string) => {
|
||||
setSortBy(field)
|
||||
setPagination(prev => ({ ...prev, page: 1 }))
|
||||
}
|
||||
|
||||
const toggleSortOrder = () => {
|
||||
setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc')
|
||||
setPagination(prev => ({ ...prev, page: 1 }))
|
||||
}
|
||||
|
||||
return {
|
||||
courses,
|
||||
tags,
|
||||
loading,
|
||||
error,
|
||||
pagination,
|
||||
search,
|
||||
setSearch: handleSearch,
|
||||
filter,
|
||||
setFilter: handleFilter,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
setSortBy: handleSort,
|
||||
setSortOrder: toggleSortOrder,
|
||||
toggleSortOrder,
|
||||
goToPage,
|
||||
nextPage,
|
||||
prevPage,
|
||||
changeLimit,
|
||||
refetch: fetchCourses,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined
|
||||
}
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||
})
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
|
||||
@@ -0,0 +1,48 @@
|
||||
export type QuizSource = 'the-trivia-api' | 'quizapi'
|
||||
|
||||
export type QuizQuestion = {
|
||||
id: string
|
||||
prompt: string
|
||||
options: string[]
|
||||
answerIndex: number
|
||||
explanation?: string
|
||||
}
|
||||
|
||||
export type QuizCache = {
|
||||
version: 1
|
||||
source: QuizSource
|
||||
topic: string
|
||||
title: string
|
||||
description: string
|
||||
fetchedAt: string
|
||||
updatedAt: string
|
||||
questions: QuizQuestion[]
|
||||
}
|
||||
|
||||
export type QuizAttemptResult = {
|
||||
score: number
|
||||
totalQuestions: number
|
||||
passed: boolean
|
||||
}
|
||||
|
||||
export function cleanQuizTopic(input: string) {
|
||||
return input
|
||||
.replace(/quiz[_-]?cache/gi, '')
|
||||
.replace(/^\d+[\s._-]*/g, '')
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function titleCase(input: string) {
|
||||
return input
|
||||
.split(' ')
|
||||
.filter(Boolean)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
export function normalizeQuizTopic(input: string) {
|
||||
const cleaned = cleanQuizTopic(input)
|
||||
return cleaned.length > 0 ? titleCase(cleaned) : 'General Knowledge'
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
import 'server-only'
|
||||
|
||||
import { mkdir, readFile, writeFile, readdir } from 'fs/promises'
|
||||
import { dirname, join, relative, basename } from 'path'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import type { QuizCache, QuizQuestion, QuizSource } from './quiz-types'
|
||||
import { normalizeQuizTopic } from './quiz-types'
|
||||
|
||||
const QUIZ_CACHE_FILE = 'quiz_cache.json'
|
||||
const VIDEO_EXTENSIONS = new Set(['.mp4', '.mkv', '.webm', '.mov', '.avi', '.m4v', '.flv', '.wmv'])
|
||||
const ONLINE_QUIZ_SOURCES = new Set<QuizSource>(['the-trivia-api', 'quizapi'])
|
||||
|
||||
async function getQuizApiKey(): Promise<string | null> {
|
||||
const setting = await prisma.setting.findUnique({ where: { key: 'quizApiKey' } })
|
||||
return setting?.value || null
|
||||
}
|
||||
|
||||
function introCandidate(input: string) {
|
||||
return normalizeQuizTopic(input)
|
||||
.toLowerCase()
|
||||
.replace(/^\d+[\s._-]*/g, '')
|
||||
.replace(/^s\d+[\s._-]*/g, '')
|
||||
.replace(/\[[^\]]+\]/g, ' ')
|
||||
.replace(/[^a-z0-9\s]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function isIntroQuizTopic(input: string) {
|
||||
const value = introCandidate(input)
|
||||
return /(^|\b)(intro|introduction|introduce|introducing|welcome|overview|course overview|course introduction|getting started|getting started overview|websites you may like|exercise files|bonus lecture|conclusion|footnote|footnotes|endnote|endnotes|appendix|appendices|bibliography|references|notes|supplemental)(\b|$)/.test(value)
|
||||
}
|
||||
|
||||
function isOnlineQuizSource(source: unknown): source is QuizSource {
|
||||
return typeof source === 'string' && ONLINE_QUIZ_SOURCES.has(source as QuizSource)
|
||||
}
|
||||
|
||||
function decodeHtmlEntities(input: string) {
|
||||
return input
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
}
|
||||
|
||||
function shuffleArray<T>(items: T[]) {
|
||||
const copy = [...items]
|
||||
for (let i = copy.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1))
|
||||
;[copy[i], copy[j]] = [copy[j], copy[i]]
|
||||
}
|
||||
return copy
|
||||
}
|
||||
|
||||
function buildQuizTitle(topic: string) {
|
||||
const displayTopic = topic.split(' | ')[0].split(' - ')[0]
|
||||
return `Quiz: ${normalizeQuizTopic(displayTopic)}`
|
||||
}
|
||||
|
||||
function buildQuizDescription(topic: string, source: QuizSource) {
|
||||
return `Auto-generated practice quiz for ${normalizeQuizTopic(topic)} from ${source.toUpperCase()}.`
|
||||
}
|
||||
|
||||
function triviaQuestionText(value: unknown) {
|
||||
if (typeof value === 'string') return value
|
||||
if (value && typeof value === 'object' && 'text' in value) {
|
||||
return String((value as { text?: unknown }).text || 'Question')
|
||||
}
|
||||
return 'Question'
|
||||
}
|
||||
|
||||
function triviaCategoriesForTopic(topic: string): string[] {
|
||||
const lower = topic.toLowerCase()
|
||||
const categories = new Set<string>()
|
||||
|
||||
if (/history|war|ancient|medieval|empire|civilization/.test(lower)) categories.add('history')
|
||||
if (/geography|country|capital|map|continent|river|mountain|city/.test(lower)) categories.add('geography')
|
||||
if (/science|biology|chemistry|physics|space|astronomy|medical|medicine|anatomy/.test(lower)) categories.add('science')
|
||||
if (/music|song|album|artist|band/.test(lower)) categories.add('music')
|
||||
if (/sport|football|soccer|basketball|baseball|tennis|golf/.test(lower)) categories.add('sport_and_leisure')
|
||||
if (/movie|film|television|tv|actor|cinema/.test(lower)) categories.add('film_and_tv')
|
||||
if (/\bart\b|literature|book|novel|author|poetry/.test(lower)) categories.add('arts_and_literature')
|
||||
if (/food|drink|cuisine|recipe|cooking/.test(lower)) categories.add('food_and_drink')
|
||||
if (/culture|society|language|religion|mythology|politics/.test(lower)) categories.add('society_and_culture')
|
||||
if (/general knowledge|trivia/.test(lower)) categories.add('general_knowledge')
|
||||
|
||||
return Array.from(categories)
|
||||
}
|
||||
|
||||
function isTechnicalQuizTopic(topic: string) {
|
||||
return /terraform|hcl|tfstate|provider block|resource block|terraform manifest|infrastructure as code|\biac\b|aws|ec2|s3|iam|vpc|rds|cloudfront|route 53|lambda|devops|docker|kubernetes|\bk8s\b|linux|ansible|azure|ci\/cd|pipeline/.test(topic.toLowerCase())
|
||||
}
|
||||
|
||||
function quizApiTagsForTopic(topic: string) {
|
||||
const lower = topic.toLowerCase()
|
||||
const tags = new Set<string>()
|
||||
|
||||
if (/terraform|hcl|tfstate|provider block|resource block|terraform manifest|infrastructure as code|\biac\b/.test(lower)) tags.add('terraform')
|
||||
else if (/typescript|\bts\b|\.d\.ts|type guard|interface/.test(lower)) tags.add('typescript')
|
||||
|
||||
if (!tags.has('terraform')) {
|
||||
if (/aws|ec2|s3|iam|vpc|rds|cloudfront|route 53|lambda/.test(lower)) tags.add('aws')
|
||||
if (/docker|container|image/.test(lower)) tags.add('docker')
|
||||
if (/kubernetes|\bk8s\b|pod|deployment|cluster/.test(lower)) tags.add('kubernetes')
|
||||
if (/linux|bash|shell|systemd|red hat|rhel/.test(lower)) tags.add('linux')
|
||||
if (/sql|mysql|postgres|database/.test(lower)) tags.add('sql')
|
||||
if (/ansible|playbook/.test(lower)) tags.add('ansible')
|
||||
if (/azure|devops pipeline|ci_cd|ci\/cd/.test(lower)) tags.add('azure')
|
||||
}
|
||||
|
||||
if (tags.size) return Array.from(tags).slice(0, 4).join(',')
|
||||
|
||||
return topic
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9+#.\s-]/g, ' ')
|
||||
.split(/[\s,/_-]+/)
|
||||
.filter((word) => word.length >= 3)
|
||||
.filter((word) => !['the', 'and', 'for', 'with', 'module', 'modules', 'lesson', 'course', 'quiz', 'part', 'step', 'introduction', 'create', 'test', 'build'].includes(word))
|
||||
.slice(0, 4)
|
||||
.join(',')
|
||||
}
|
||||
|
||||
function focusKeywordsForTopic(topic: string) {
|
||||
const stopWords = new Set([
|
||||
'step', 'test', 'create', 'using', 'with', 'about', 'introduction', 'understand',
|
||||
'learn', 'build', 'building', 'manually', 'commands', 'command', 'execute',
|
||||
'executing', 'clean', 'course', 'lesson', 'video', 'quiz', 'what', 'which', 'this',
|
||||
])
|
||||
|
||||
return Array.from(new Set(
|
||||
topic
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s]/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter((word) => word.length >= 4)
|
||||
.filter((word) => !stopWords.has(word))
|
||||
)).slice(0, 30)
|
||||
}
|
||||
|
||||
function scoreQuizApiItem(item: Record<string, unknown>, focusKeywords: string[]) {
|
||||
const searchable = [
|
||||
item.text,
|
||||
item.question,
|
||||
item.explanation,
|
||||
item.quizTitle,
|
||||
item.category,
|
||||
Array.isArray(item.tags) ? item.tags.join(' ') : '',
|
||||
].join(' ').toLowerCase()
|
||||
|
||||
return focusKeywords.filter((keyword) => searchable.includes(keyword)).length
|
||||
}
|
||||
|
||||
function selectRelevantQuizApiItems(items: Array<Record<string, unknown>>, topic: string) {
|
||||
const focusKeywords = focusKeywordsForTopic(topic)
|
||||
if (!focusKeywords.length) return items.slice(0, 10)
|
||||
|
||||
const ranked = items
|
||||
.map((item, index) => ({ item, index, score: scoreQuizApiItem(item, focusKeywords) }))
|
||||
.sort((a, b) => b.score - a.score || a.index - b.index)
|
||||
|
||||
const relevant = ranked.filter((entry) => entry.score > 0).map((entry) => entry.item)
|
||||
return (relevant.length >= 5 ? relevant : ranked.map((entry) => entry.item)).slice(0, 10)
|
||||
}
|
||||
|
||||
function mapTriviaApiResults(topic: string, results: Array<Record<string, unknown>>): QuizCache {
|
||||
const questions: QuizQuestion[] = results.map((result) => {
|
||||
const questionText = decodeHtmlEntities(triviaQuestionText(result.question))
|
||||
const correctAnswer = decodeHtmlEntities(String(result.correctAnswer || ''))
|
||||
const incorrectAnswers = Array.isArray(result.incorrectAnswers)
|
||||
? result.incorrectAnswers.map((answer) => decodeHtmlEntities(String(answer)))
|
||||
: []
|
||||
const options = shuffleArray([correctAnswer, ...incorrectAnswers].filter(Boolean))
|
||||
const answerIndex = Math.max(0, options.findIndex((option) => option === correctAnswer))
|
||||
|
||||
return {
|
||||
id: randomUUID(),
|
||||
prompt: questionText,
|
||||
options,
|
||||
answerIndex,
|
||||
explanation: correctAnswer ? `Correct answer: ${correctAnswer}` : undefined,
|
||||
}
|
||||
})
|
||||
|
||||
const now = new Date().toISOString()
|
||||
return {
|
||||
version: 1,
|
||||
source: 'the-trivia-api',
|
||||
topic,
|
||||
title: buildQuizTitle(topic),
|
||||
description: buildQuizDescription(topic, 'the-trivia-api'),
|
||||
fetchedAt: now,
|
||||
updatedAt: now,
|
||||
questions,
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTriviaApiQuiz(topic: string): Promise<QuizCache> {
|
||||
const categories = triviaCategoriesForTopic(topic)
|
||||
const categoryList = categories.length ? categories.join(',') : 'general_knowledge'
|
||||
|
||||
const url = new URL('https://the-trivia-api.com/v2/questions')
|
||||
url.searchParams.set('limit', '10')
|
||||
url.searchParams.set('categories', categoryList)
|
||||
url.searchParams.set('difficulty', 'medium')
|
||||
|
||||
let response = await fetch(url.toString())
|
||||
if (!response.ok || response.status === 404) {
|
||||
const fallback = new URL('https://the-trivia-api.com/v2/questions')
|
||||
fallback.searchParams.set('limit', '10')
|
||||
fallback.searchParams.set('categories', 'general_knowledge')
|
||||
fallback.searchParams.set('difficulty', 'medium')
|
||||
response = await fetch(fallback.toString())
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`The Trivia API request failed with status ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json() as Array<Record<string, unknown>>
|
||||
if (!data.length) {
|
||||
throw new Error('The Trivia API returned no quiz questions')
|
||||
}
|
||||
|
||||
return mapTriviaApiResults(topic, data)
|
||||
}
|
||||
|
||||
function naturalCompare(a: string, b: string) {
|
||||
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' })
|
||||
}
|
||||
|
||||
function cleanVideoTitle(fileName: string) {
|
||||
return normalizeQuizTopic(
|
||||
basename(fileName).replace(/\.[^.]+$/, '')
|
||||
.replace(/^\d+[\s._-]*/g, '')
|
||||
.replace(/^step[\s._-]*\d+[\s._-]*/i, '')
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
)
|
||||
}
|
||||
|
||||
function topicDomainAnchorForPath(modulePath: string) {
|
||||
const courseFolder = normalizeQuizTopic(basename(dirname(modulePath)))
|
||||
const lower = courseFolder.toLowerCase()
|
||||
|
||||
if (/terraform|hashicorp/.test(lower)) return 'Terraform'
|
||||
if (/aws|amazon web services/.test(lower)) return 'AWS'
|
||||
if (/docker/.test(lower)) return 'Docker'
|
||||
if (/kubernetes|k8s/.test(lower)) return 'Kubernetes'
|
||||
if (/linux|red hat|rhel/.test(lower)) return 'Linux'
|
||||
if (/ansible/.test(lower)) return 'Ansible'
|
||||
if (/azure/.test(lower)) return 'Azure'
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
async function buildQuizTopic(baseTopic: string, modulePath?: string): Promise<string> {
|
||||
if (modulePath) {
|
||||
try {
|
||||
const entries = await readdir(modulePath, { withFileTypes: true })
|
||||
const videoTitles = entries
|
||||
.filter((entry) => entry.isFile() && !entry.name.startsWith('.') && VIDEO_EXTENSIONS.has(`.${entry.name.split('.').pop()?.toLowerCase() || ''}`))
|
||||
.sort((a, b) => naturalCompare(a.name, b.name))
|
||||
.map((entry) => cleanVideoTitle(entry.name))
|
||||
.filter(Boolean)
|
||||
.filter((title) => !isIntroQuizTopic(title))
|
||||
.filter((part, index, all) => all.findIndex((item) => item.toLowerCase() === part.toLowerCase()) === index)
|
||||
.slice(0, 16)
|
||||
|
||||
const domainAnchor = topicDomainAnchorForPath(modulePath)
|
||||
const videoTopicParts = domainAnchor && !videoTitles.some((title) => title.toLowerCase().includes(domainAnchor.toLowerCase()))
|
||||
? [...videoTitles, domainAnchor]
|
||||
: videoTitles
|
||||
|
||||
if (videoTopicParts.length) {
|
||||
return videoTopicParts.join(' | ')
|
||||
}
|
||||
} catch {
|
||||
// Video-title enrichment must not block quiz generation.
|
||||
}
|
||||
}
|
||||
|
||||
return normalizeQuizTopic(baseTopic)
|
||||
}
|
||||
|
||||
async function fetchQuizApiQuiz(topic: string, apiKey: string): Promise<QuizCache> {
|
||||
if (!apiKey) {
|
||||
throw new Error('QUIZAPI_KEY is not configured')
|
||||
}
|
||||
|
||||
const url = new URL('https://quizapi.io/api/v1/questions')
|
||||
url.searchParams.set('api_key', apiKey)
|
||||
url.searchParams.set('limit', '50')
|
||||
const tags = quizApiTagsForTopic(topic)
|
||||
if (tags) {
|
||||
url.searchParams.set('tags', tags)
|
||||
}
|
||||
if (tags.split(',').includes('terraform')) {
|
||||
url.searchParams.set('category', 'DevOps')
|
||||
}
|
||||
url.searchParams.set('random', 'true')
|
||||
|
||||
const response = await fetch(url.toString())
|
||||
if (!response.ok) {
|
||||
throw new Error(`QuizAPI request failed with status ${response.status}`)
|
||||
}
|
||||
|
||||
const payload = await response.json() as unknown
|
||||
const data = Array.isArray(payload)
|
||||
? payload as Array<Record<string, unknown>>
|
||||
: payload && typeof payload === 'object' && Array.isArray((payload as { data?: unknown }).data)
|
||||
? (payload as { data: Array<Record<string, unknown>> }).data
|
||||
: []
|
||||
|
||||
if (!data.length) {
|
||||
throw new Error('QuizAPI returned no quiz questions')
|
||||
}
|
||||
|
||||
const selectedData = selectRelevantQuizApiItems(data, topic)
|
||||
const questions: QuizQuestion[] = selectedData.map((item) => {
|
||||
const rawAnswers = item.answers
|
||||
let optionEntries: Array<{ key: string; value: string; isCorrect: boolean }> = []
|
||||
|
||||
if (Array.isArray(rawAnswers)) {
|
||||
optionEntries = rawAnswers
|
||||
.filter((answer): answer is Record<string, unknown> => Boolean(answer) && typeof answer === 'object')
|
||||
.map((answer, index) => ({
|
||||
key: String(answer.id || index),
|
||||
value: decodeHtmlEntities(String(answer.text || '')),
|
||||
isCorrect: Boolean(answer.isCorrect),
|
||||
}))
|
||||
.filter((entry) => Boolean(entry.value))
|
||||
} else {
|
||||
const answers = rawAnswers && typeof rawAnswers === 'object' ? (rawAnswers as Record<string, string | null>) : {}
|
||||
const correctAnswers = item.correct_answers && typeof item.correct_answers === 'object'
|
||||
? (item.correct_answers as Record<string, string>)
|
||||
: {}
|
||||
optionEntries = Object.entries(answers)
|
||||
.filter(([, value]) => Boolean(value))
|
||||
.map(([key, value]) => ({
|
||||
key,
|
||||
value: decodeHtmlEntities(String(value)),
|
||||
isCorrect: String(correctAnswers[`${key}_correct`] || '').toLowerCase() === 'true',
|
||||
}))
|
||||
}
|
||||
|
||||
const options = optionEntries.map((entry) => entry.value)
|
||||
const answerIndex = optionEntries.findIndex((entry) => entry.isCorrect)
|
||||
|
||||
return {
|
||||
id: randomUUID(),
|
||||
prompt: decodeHtmlEntities(String(item.text || item.question || 'Question')),
|
||||
options,
|
||||
answerIndex: Math.max(0, answerIndex),
|
||||
explanation: String(item.explanation || '') || undefined,
|
||||
}
|
||||
})
|
||||
|
||||
const now = new Date().toISOString()
|
||||
return {
|
||||
version: 1,
|
||||
source: 'quizapi',
|
||||
topic,
|
||||
title: buildQuizTitle(topic),
|
||||
description: buildQuizDescription(topic, 'quizapi'),
|
||||
fetchedAt: now,
|
||||
updatedAt: now,
|
||||
questions,
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchQuizBySource(topic: string, source: QuizSource, modulePath?: string): Promise<QuizCache> {
|
||||
const enrichedTopic = await buildQuizTopic(topic, modulePath)
|
||||
const technicalTopic = isTechnicalQuizTopic(enrichedTopic)
|
||||
|
||||
// Fetch QuizAPI key from database
|
||||
const quizApiKey = await getQuizApiKey()
|
||||
|
||||
if (source === 'quizapi' || technicalTopic) {
|
||||
if (!quizApiKey) {
|
||||
if (technicalTopic) {
|
||||
throw new Error('QUIZAPI_KEY is required for technical quiz topics. Configure it in Settings.')
|
||||
}
|
||||
console.warn('QuizAPI selected but QUIZAPI_KEY is not configured; falling back to The Trivia API.')
|
||||
return fetchTriviaApiQuiz(enrichedTopic)
|
||||
}
|
||||
|
||||
try {
|
||||
return await fetchQuizApiQuiz(enrichedTopic, quizApiKey)
|
||||
} catch (error) {
|
||||
if (technicalTopic) {
|
||||
throw error
|
||||
}
|
||||
console.warn('QuizAPI quiz generation failed; falling back to The Trivia API:', error)
|
||||
return fetchTriviaApiQuiz(enrichedTopic)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await fetchTriviaApiQuiz(enrichedTopic)
|
||||
} catch (error) {
|
||||
if (!quizApiKey) {
|
||||
throw error
|
||||
}
|
||||
console.warn('The Trivia API quiz generation failed; falling back to QuizAPI:', error)
|
||||
return fetchQuizApiQuiz(enrichedTopic, quizApiKey)
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadQuizCache(cachePath: string): Promise<QuizCache | null> {
|
||||
try {
|
||||
const raw = await readFile(cachePath, 'utf8')
|
||||
const parsed = JSON.parse(raw) as Partial<QuizCache> & { source?: unknown }
|
||||
if (!parsed || !Array.isArray(parsed.questions)) return null
|
||||
if (!isOnlineQuizSource(parsed.source)) return null
|
||||
return parsed as QuizCache
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeQuizCache(cachePath: string, quiz: QuizCache) {
|
||||
await mkdir(dirname(cachePath), { recursive: true })
|
||||
await writeFile(cachePath, JSON.stringify(quiz, null, 2), 'utf8')
|
||||
return cachePath
|
||||
}
|
||||
|
||||
export async function shouldSkipQuizGeneration(modulePath: string, topic: string) {
|
||||
if (isIntroQuizTopic(topic) || isIntroQuizTopic(basename(modulePath))) {
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = await readdir(modulePath, { withFileTypes: true })
|
||||
const videoTitles = entries
|
||||
.filter((entry) => entry.isFile() && !entry.name.startsWith('.') && VIDEO_EXTENSIONS.has(`.${entry.name.split('.').pop()?.toLowerCase() || ''}`))
|
||||
.map((entry) => cleanVideoTitle(entry.name))
|
||||
.filter(Boolean)
|
||||
|
||||
return videoTitles.length > 0 && videoTitles.every((title) => isIntroQuizTopic(title))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureModuleQuizCache(options: {
|
||||
modulePath: string
|
||||
topic: string
|
||||
source: QuizSource
|
||||
force?: boolean
|
||||
}): Promise<{ quiz: QuizCache; cachePath: string; created: boolean } | null> {
|
||||
const cachePath = join(options.modulePath, QUIZ_CACHE_FILE)
|
||||
|
||||
if (await shouldSkipQuizGeneration(options.modulePath, options.topic)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const existing = await loadQuizCache(cachePath)
|
||||
|
||||
if (existing && !options.force) {
|
||||
return { quiz: existing, cachePath, created: false }
|
||||
}
|
||||
|
||||
try {
|
||||
const quiz = await fetchQuizBySource(options.topic, options.source, options.modulePath)
|
||||
await writeQuizCache(cachePath, quiz)
|
||||
return { quiz, cachePath, created: !existing }
|
||||
} catch (error) {
|
||||
if (existing) {
|
||||
return { quiz: existing, cachePath, created: false }
|
||||
}
|
||||
console.error('Failed to generate quiz cache:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function getQuizCachePath(modulePath: string) {
|
||||
return join(modulePath, QUIZ_CACHE_FILE)
|
||||
}
|
||||
|
||||
export async function syncQuizLessonFromCache(options: {
|
||||
moduleId: string
|
||||
modulePath: string
|
||||
courseRoot: string
|
||||
topic?: string
|
||||
source?: QuizSource
|
||||
lessonOrder?: number
|
||||
autoFetch?: boolean
|
||||
force?: boolean
|
||||
}): Promise<{ quiz: QuizCache; cachePath: string; lesson: { id: string; title: string; slug: string; type: string; filePath: string }; created: boolean } | null> {
|
||||
const cachePath = getQuizCachePath(options.modulePath)
|
||||
let quiz = await loadQuizCache(cachePath)
|
||||
|
||||
if (!quiz && options.autoFetch) {
|
||||
const generated = await ensureModuleQuizCache({
|
||||
modulePath: options.modulePath,
|
||||
topic: options.topic || basename(options.modulePath),
|
||||
source: options.source || 'quizapi',
|
||||
force: options.force ?? false,
|
||||
})
|
||||
if (!generated) {
|
||||
return null
|
||||
}
|
||||
quiz = generated.quiz
|
||||
}
|
||||
|
||||
if (!quiz) {
|
||||
return null
|
||||
}
|
||||
|
||||
const existingLesson = await prisma.lesson.findUnique({
|
||||
where: { moduleId_slug: { moduleId: options.moduleId, slug: 'quiz' } },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
const filePath = relative(options.courseRoot, cachePath).replace(/\\\\/g, '/')
|
||||
const order = options.lessonOrder ?? await prisma.lesson.count({ where: { moduleId: options.moduleId } })
|
||||
const lesson = await prisma.lesson.upsert({
|
||||
where: { moduleId_slug: { moduleId: options.moduleId, slug: 'quiz' } },
|
||||
update: {
|
||||
title: quiz.title || 'Quiz',
|
||||
order,
|
||||
filePath,
|
||||
fileName: 'quiz_cache.json',
|
||||
mimeType: 'application/json',
|
||||
type: 'QUIZ',
|
||||
duration: null,
|
||||
thumbnail: null,
|
||||
subtitlePath: null,
|
||||
},
|
||||
create: {
|
||||
title: quiz.title || 'Quiz',
|
||||
slug: 'quiz',
|
||||
order,
|
||||
filePath,
|
||||
fileName: 'quiz_cache.json',
|
||||
mimeType: 'application/json',
|
||||
type: 'QUIZ',
|
||||
duration: null,
|
||||
thumbnail: null,
|
||||
subtitlePath: null,
|
||||
moduleId: options.moduleId,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
quiz,
|
||||
cachePath,
|
||||
lesson,
|
||||
created: !existingLesson,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,703 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { readdir, stat } from 'fs/promises'
|
||||
import { join, relative, extname, basename, dirname } from 'path'
|
||||
import { getMimeType, getLessonType, slugify } from '@/lib/utils'
|
||||
import { getVideoDuration, generateVideoThumbnail, checkVideoTools } from '@/lib/video-utils'
|
||||
import { getCourseThumbnail } from '@/lib/thumbnail-index'
|
||||
import { getLocalThumbnailUrl } from '@/lib/thumbnail-index-server'
|
||||
import { ensureModuleQuizCache, syncQuizLessonFromCache, shouldSkipQuizGeneration } from '@/lib/quiz'
|
||||
import { applyCourseMetadata, isMetadataFileName, readCourseMetadataFile } from '@/lib/course-metadata'
|
||||
import { getLocalCourseThumbnailPath, ensurePublicThumbnail } from '@/lib/course-thumbnail-utils'
|
||||
import { writeFile, mkdir } from 'fs/promises'
|
||||
import { existsSync } from 'fs'
|
||||
|
||||
// Natural sort: handles "1", "2", "10" correctly (not "1", "10", "2")
|
||||
function naturalSort(a: string, b: string): number {
|
||||
const re = /(\d+)|(\D+)/g
|
||||
const aParts = a.match(re) || []
|
||||
const bParts = b.match(re) || []
|
||||
|
||||
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
|
||||
const aPart = aParts[i] || ''
|
||||
const bPart = bParts[i] || ''
|
||||
const aNum = parseInt(aPart, 10)
|
||||
const bNum = parseInt(bPart, 10)
|
||||
if (!isNaN(aNum) && !isNaN(bNum)) {
|
||||
if (aNum !== bNum) return aNum - bNum
|
||||
} else {
|
||||
const cmp = aPart.localeCompare(bPart)
|
||||
if (cmp !== 0) return cmp
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const VIDEO_EXTENSIONS = new Set(['.mp4', '.mkv', '.webm', '.mov', '.avi', '.m4v', '.flv', '.wmv'])
|
||||
|
||||
async function directoryContainsVideo(dirPath: string, depth = 2): Promise<boolean> {
|
||||
if (depth <= 0) return false
|
||||
try {
|
||||
const entries = await readdir(dirPath, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile() && !entry.name.startsWith('.')) {
|
||||
if (VIDEO_EXTENSIONS.has(extname(entry.name).toLowerCase())) {
|
||||
return true
|
||||
}
|
||||
} else if (entry.isDirectory() && !entry.name.startsWith('.')) {
|
||||
if (await directoryContainsVideo(join(dirPath, entry.name), depth - 1)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore unreadable paths
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function getCoursesRoot(): Promise<string> {
|
||||
try {
|
||||
const setting = await prisma.setting.findUnique({
|
||||
where: { key: 'coursesRoot' },
|
||||
})
|
||||
return setting?.value || './My_Courses'
|
||||
} catch {
|
||||
return './My_Courses'
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCoursesRootPath(): Promise<string> {
|
||||
return getCoursesRoot()
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and save course thumbnail locally
|
||||
*/
|
||||
async function downloadCourseThumbnail(courseName: string, courseSlug: string): Promise<string | null> {
|
||||
try {
|
||||
const thumbnailUrl = getCourseThumbnail(courseName, courseSlug)
|
||||
if (!thumbnailUrl) return null
|
||||
|
||||
const thumbDir = join(process.cwd(), 'public', 'thumbnails', 'courses')
|
||||
if (!existsSync(thumbDir)) {
|
||||
await mkdir(thumbDir, { recursive: true })
|
||||
}
|
||||
|
||||
const response = await fetch(thumbnailUrl)
|
||||
if (!response.ok) return null
|
||||
|
||||
const buffer = await response.arrayBuffer()
|
||||
|
||||
// Determine extension from Content-Type header (more reliable than URL)
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
let primaryExt = '.png'
|
||||
if (contentType.includes('svg')) primaryExt = '.svg'
|
||||
else if (contentType.includes('jpeg') || contentType.includes('jpg')) primaryExt = '.jpg'
|
||||
else if (contentType.includes('png')) primaryExt = '.png'
|
||||
else if (contentType.includes('gif')) primaryExt = '.gif'
|
||||
else if (contentType.includes('webp')) primaryExt = '.webp'
|
||||
|
||||
// Fallback to URL-based detection
|
||||
if (primaryExt === '.png' && thumbnailUrl.includes('.svg')) primaryExt = '.svg'
|
||||
|
||||
// Save both .svg and .png versions to prevent 404s
|
||||
const extensionsToSave = primaryExt === '.svg' ? ['.svg', '.png'] : ['.png', '.svg']
|
||||
|
||||
let returnedPath = ''
|
||||
|
||||
for (const ext of extensionsToSave) {
|
||||
const filename = `${courseSlug}-thumb${ext}`
|
||||
const filepath = join(thumbDir, filename)
|
||||
|
||||
// For the secondary format, convert if needed
|
||||
if (ext !== primaryExt) {
|
||||
// We already have the buffer, just save with different extension
|
||||
await writeFile(filepath, Buffer.from(buffer))
|
||||
} else {
|
||||
await writeFile(filepath, Buffer.from(buffer))
|
||||
}
|
||||
|
||||
if (!returnedPath) {
|
||||
returnedPath = `/thumbnails/courses/${filename}`
|
||||
}
|
||||
|
||||
console.log(`Downloaded thumbnail for ${courseName} (${ext})`)
|
||||
}
|
||||
|
||||
return returnedPath
|
||||
} catch (error) {
|
||||
console.warn(`Failed to download thumbnail for ${courseName}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
interface ScanResult {
|
||||
coursesCreated: number
|
||||
coursesUpdated: number
|
||||
modulesCreated: number
|
||||
modulesUpdated: number
|
||||
lessonsCreated: number
|
||||
lessonsUpdated: number
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
interface CourseMetadata {
|
||||
title: string
|
||||
description?: string
|
||||
thumbnail?: string
|
||||
icon?: string
|
||||
tags: string[]
|
||||
metadata: Record<string, any>
|
||||
}
|
||||
|
||||
const COURSE_METADATA_MAPPINGS: Record<string, CourseMetadata> = {
|
||||
'hashicorp': {
|
||||
title: 'HashiCorp Certified',
|
||||
description: 'Official HashiCorp certification courses for Terraform, Vault, Consul, and Nomad',
|
||||
tags: ['hashicorp', 'terraform', 'vault', 'consul', 'nomad', 'certification'],
|
||||
metadata: { vendor: 'HashiCorp', certifications: ['Terraform Associate', 'Vault Associate', 'Consul Associate'] },
|
||||
},
|
||||
'terraform': {
|
||||
title: 'Terraform',
|
||||
description: 'Infrastructure as Code with Terraform - from basics to advanced patterns',
|
||||
tags: ['iac', 'terraform', 'hashicorp', 'cloud', 'devops'],
|
||||
metadata: { provider: 'HashiCorp', registry: 'registry.terraform.io' },
|
||||
},
|
||||
'jenkins': {
|
||||
title: 'Jenkins CI/CD',
|
||||
description: 'Complete Jenkins pipeline automation - from basics to advanced declarative pipelines',
|
||||
tags: ['ci/cd', 'jenkins', 'automation', 'pipeline', 'devops'],
|
||||
metadata: { url: 'https://www.jenkins.io', plugins: ['Pipeline', 'Blue Ocean', 'GitHub Integration'] },
|
||||
},
|
||||
'kubernetes': {
|
||||
title: 'Kubernetes',
|
||||
description: 'Container orchestration with Kubernetes - fundamentals to advanced operations',
|
||||
tags: ['k8s', 'kubernetes', 'containers', 'orchestration', 'cloud-native'],
|
||||
metadata: { versions: ['1.28', '1.29', '1.30'], cni: ['Calico', 'Cilium', 'Flannel'] },
|
||||
},
|
||||
'docker': {
|
||||
title: 'Docker & Containers',
|
||||
description: 'Container fundamentals - Docker, Podman, Buildah, and container best practices',
|
||||
tags: ['docker', 'containers', 'podman', 'containerd', 'buildah'],
|
||||
metadata: { registry: 'Docker Hub', runtimes: ['containerd', 'cri-o', 'runc'] },
|
||||
},
|
||||
'ansible': {
|
||||
title: 'Ansible Automation',
|
||||
description: 'Infrastructure automation with Ansible - playbooks, roles, collections, and AWX',
|
||||
tags: ['ansible', 'automation', 'configuration-management', 'redhat'],
|
||||
metadata: { galaxy: 'galaxy.ansible.com', collections: ['community.general', 'community.docker', 'kubernetes.core'] },
|
||||
},
|
||||
'prometheus': {
|
||||
title: 'Prometheus & Grafana',
|
||||
description: 'Monitoring and observability with Prometheus, Grafana, Alertmanager, and Loki',
|
||||
tags: ['monitoring', 'prometheus', 'grafana', 'observability', 'alerting'],
|
||||
metadata: { stack: ['Prometheus', 'Grafana', 'Alertmanager', 'Loki', 'Tempo'] },
|
||||
},
|
||||
'github-actions': {
|
||||
title: 'GitHub Actions CI/CD',
|
||||
description: 'CI/CD pipelines with GitHub Actions - workflows, reusable actions, and self-hosted runners',
|
||||
tags: ['github', 'actions', 'ci/cd', 'workflows', 'automation'],
|
||||
metadata: { marketplace: 'GitHub Marketplace', runners: ['ubuntu-latest', 'windows-latest', 'macos-latest', 'self-hosted'] },
|
||||
},
|
||||
'linux': {
|
||||
title: 'Linux System Administration',
|
||||
description: 'Linux fundamentals - shell scripting, systemd, networking, security, and performance tuning',
|
||||
tags: ['linux', 'shell', 'bash', 'systemd', 'networking', 'security'],
|
||||
metadata: { distros: ['Ubuntu', 'Debian', 'RHEL', 'Fedora', 'Arch'], shells: ['bash', 'zsh', 'fish'] },
|
||||
},
|
||||
'python': {
|
||||
title: 'Python Programming',
|
||||
description: 'Python programming - from basics to advanced async, testing, and packaging',
|
||||
tags: ['python', 'programming', 'async', 'testing', 'packaging'],
|
||||
metadata: { versions: ['3.10', '3.11', '3.12'], frameworks: ['FastAPI', 'Django', 'Flask', 'Pydantic'] },
|
||||
},
|
||||
'go': {
|
||||
title: 'Go Programming',
|
||||
description: 'Go programming - concurrency, microservices, CLI tools, and performance',
|
||||
tags: ['go', 'golang', 'concurrency', 'microservices', 'cli'],
|
||||
metadata: { versions: ['1.21', '1.22'], tools: ['go modules', 'golangci-lint', 'delve'] },
|
||||
},
|
||||
'aws': {
|
||||
title: 'Amazon Web Services',
|
||||
description: 'AWS cloud services - compute, storage, networking, serverless, and security',
|
||||
tags: ['aws', 'cloud', 'serverless', 'infrastructure', 'devops'],
|
||||
metadata: { regions: ['us-east-1', 'us-west-2', 'eu-west-1'], certifications: ['Solutions Architect', 'Developer', 'SysOps'] },
|
||||
},
|
||||
'azure': {
|
||||
title: 'Microsoft Azure',
|
||||
description: 'Azure cloud platform - compute, storage, networking, DevOps, and AI services',
|
||||
tags: ['azure', 'cloud', 'microsoft', 'devops', 'ai'],
|
||||
metadata: { regions: ['East US', 'West Europe', 'Southeast Asia'], certifications: ['AZ-104', 'AZ-204', 'AZ-400'] },
|
||||
},
|
||||
'gcp': {
|
||||
title: 'Google Cloud Platform',
|
||||
description: 'Google Cloud - compute, storage, BigQuery, Kubernetes Engine, and AI/ML',
|
||||
tags: ['gcp', 'google-cloud', 'bigquery', 'kubernetes', 'ai'],
|
||||
metadata: { regions: ['us-central1', 'europe-west1', 'asia-northeast1'], certifications: ['Cloud Architect', 'Data Engineer', 'DevOps Engineer'] },
|
||||
},
|
||||
}
|
||||
|
||||
interface ScanOptions {
|
||||
skipVideoMetadata?: boolean
|
||||
maxConcurrency?: number
|
||||
maxLessonsPerCourse?: number
|
||||
maxModulesPerCourse?: number
|
||||
batchSize?: number
|
||||
}
|
||||
|
||||
interface ScanResult {
|
||||
coursesCreated: number
|
||||
coursesUpdated: number
|
||||
modulesCreated: number
|
||||
modulesUpdated: number
|
||||
lessonsCreated: number
|
||||
lessonsUpdated: number
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
// Utility for concurrent task processing with limited concurrency
|
||||
async function parallelLimit<T>(
|
||||
items: T[],
|
||||
processor: (item: T) => Promise<void>,
|
||||
concurrency: number
|
||||
): Promise<void> {
|
||||
const queue = [...items]
|
||||
let running = 0
|
||||
|
||||
async function processNext() {
|
||||
if (queue.length === 0 && running === 0) return
|
||||
|
||||
const item = queue.shift()
|
||||
if (!item) return
|
||||
|
||||
running++
|
||||
try {
|
||||
await processor(item)
|
||||
} finally {
|
||||
running--
|
||||
processNext()
|
||||
}
|
||||
}
|
||||
|
||||
// Start initial workers
|
||||
while (running < concurrency && queue.length > 0) {
|
||||
processNext()
|
||||
}
|
||||
|
||||
// Wait for all to complete
|
||||
while (running > 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
}
|
||||
|
||||
interface ScanOptions {
|
||||
skipVideoMetadata?: boolean
|
||||
maxConcurrency?: number
|
||||
maxLessonsPerCourse?: number
|
||||
maxModulesPerCourse?: number
|
||||
autoFetchQuizzes?: boolean
|
||||
quizApiSource?: 'the-trivia-api' | 'quizapi'
|
||||
}
|
||||
|
||||
async function scanCourses(options: ScanOptions = {}): Promise<ScanResult> {
|
||||
const {
|
||||
skipVideoMetadata = true,
|
||||
maxConcurrency = 8,
|
||||
maxLessonsPerCourse = 500,
|
||||
maxModulesPerCourse = 50,
|
||||
batchSize = 50,
|
||||
autoFetchQuizzes = false,
|
||||
quizApiSource = 'quizapi',
|
||||
} = options
|
||||
|
||||
const result: ScanResult = {
|
||||
coursesCreated: 0,
|
||||
coursesUpdated: 0,
|
||||
modulesCreated: 0,
|
||||
modulesUpdated: 0,
|
||||
lessonsCreated: 0,
|
||||
lessonsUpdated: 0,
|
||||
errors: [],
|
||||
}
|
||||
|
||||
try {
|
||||
const coursesRoot = await getCoursesRoot()
|
||||
const rootStat = await stat(coursesRoot).catch(() => null)
|
||||
if (!rootStat || !rootStat.isDirectory()) {
|
||||
result.errors.push('Courses root directory not found: ' + coursesRoot)
|
||||
return result
|
||||
}
|
||||
|
||||
// Get all existing courses in one query to avoid N+1
|
||||
const existingCourses = await prisma.course.findMany({
|
||||
select: { id: true, slug: true, name: true, path: true, hidden: true, description: true, thumbnail: true, displayName: true }
|
||||
})
|
||||
const existingCourseMap = new Map(existingCourses.map(c => [c.slug, c]))
|
||||
|
||||
const hiddenCourseSlugs = new Set(
|
||||
existingCourses.filter(c => c.hidden).map(c => c.slug)
|
||||
)
|
||||
|
||||
const courseDirs = await readdir(coursesRoot, { withFileTypes: true })
|
||||
let courses = courseDirs.filter(d => d.isDirectory() && !d.name.startsWith('.') && !hiddenCourseSlugs.has(slugify(d.name)))
|
||||
|
||||
// Strict: ignore folders that don't contain any video files
|
||||
const strictCourses: typeof courses = []
|
||||
for (const courseDir of courses) {
|
||||
const coursePath = join(coursesRoot, courseDir.name)
|
||||
const hasVideo = await directoryContainsVideo(coursePath, 3)
|
||||
if (hasVideo) {
|
||||
strictCourses.push(courseDir)
|
||||
} else {
|
||||
result.errors.push(`Skipping empty/non-video course folder: ${courseDir.name}`)
|
||||
}
|
||||
}
|
||||
courses = strictCourses
|
||||
|
||||
// Batch upsert courses
|
||||
const courseUpserts = courses
|
||||
.map((courseDir) => ({
|
||||
where: { slug: slugify(courseDir.name) },
|
||||
update: {
|
||||
name: courseDir.name,
|
||||
path: join(coursesRoot, courseDir.name),
|
||||
},
|
||||
create: {
|
||||
name: courseDir.name,
|
||||
slug: slugify(courseDir.name),
|
||||
path: join(coursesRoot, courseDir.name),
|
||||
},
|
||||
}))
|
||||
|
||||
// Process in batches to avoid memory issues
|
||||
for (let i = 0; i < courseUpserts.length; i += batchSize) {
|
||||
const batch = courseUpserts.slice(i, i + batchSize)
|
||||
await prisma.$transaction(
|
||||
batch.map(upsert => prisma.course.upsert(upsert))
|
||||
)
|
||||
}
|
||||
|
||||
// Get updated courses
|
||||
const updatedCourses = await prisma.course.findMany({
|
||||
where: { slug: { in: courses.map(c => slugify(c.name)) } },
|
||||
select: { id: true, slug: true, name: true }
|
||||
})
|
||||
const courseMap = new Map(updatedCourses.map(c => [c.slug, c]))
|
||||
|
||||
// Now process modules and lessons with better batching
|
||||
for (const courseDir of courses) {
|
||||
if (result.errors.length > 50) break // Stop if too many errors
|
||||
|
||||
const courseSlug = slugify(courseDir.name)
|
||||
const course = courseMap.get(courseSlug)
|
||||
if (!course) continue
|
||||
|
||||
const existingCourse = existingCourseMap.get(courseSlug)
|
||||
if (!existingCourse) result.coursesCreated++
|
||||
else result.coursesUpdated++
|
||||
|
||||
const courseLowerName = courseDir.name.toLowerCase()
|
||||
let courseMetadata: CourseMetadata | null = null
|
||||
for (const [key, meta] of Object.entries(COURSE_METADATA_MAPPINGS)) {
|
||||
if (courseLowerName.includes(key)) {
|
||||
courseMetadata = meta
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const coursePath = join(coursesRoot, courseDir.name)
|
||||
const courseJsonMetadata = await readCourseMetadataFile(coursePath)
|
||||
if (courseJsonMetadata) {
|
||||
await applyCourseMetadata(course.id, courseJsonMetadata.metadata, `course-json:${courseJsonMetadata.fileName}`)
|
||||
}
|
||||
|
||||
await scanModules(course.id, coursePath, courseSlug, coursesRoot, result, courseMetadata, maxModulesPerCourse, maxLessonsPerCourse, autoFetchQuizzes, quizApiSource)
|
||||
|
||||
// Step 1: Local image scanner - check course folder root for cover/thumbnail images
|
||||
const localThumbPath = await getLocalCourseThumbnailPath(coursePath)
|
||||
let finalThumbnail: string | null = null
|
||||
if (localThumbPath) {
|
||||
finalThumbnail = await ensurePublicThumbnail(courseSlug, localThumbPath)
|
||||
}
|
||||
|
||||
if (courseMetadata) {
|
||||
const existingCourse = existingCourseMap.get(courseSlug)
|
||||
if (!finalThumbnail) {
|
||||
// Only download external thumbnail if no local cover found
|
||||
finalThumbnail = existingCourse?.thumbnail ? null : await downloadCourseThumbnail(courseDir.name, courseSlug)
|
||||
}
|
||||
await applyCourseMetadata(
|
||||
course.id,
|
||||
{
|
||||
displayName: courseMetadata.title,
|
||||
description: courseMetadata.description,
|
||||
thumbnail: finalThumbnail || undefined,
|
||||
tags: courseMetadata.tags,
|
||||
},
|
||||
'scanner-defaults'
|
||||
)
|
||||
} else if (finalThumbnail) {
|
||||
// Course has local cover but no metadata mapping - still save thumbnail
|
||||
await prisma.course.update({
|
||||
where: { id: course.id },
|
||||
data: { thumbnail: finalThumbnail },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
result.errors.push('Failed to scan courses root: ' + error)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export async function scanCoursesDirectory(options: ScanOptions = {}): Promise<ScanResult> {
|
||||
return scanCourses({ skipVideoMetadata: true, maxConcurrency: 6, ...options }) // Fast scan with concurrency
|
||||
}
|
||||
|
||||
export async function scanCoursesFull(options: ScanOptions = {}): Promise<ScanResult> {
|
||||
return scanCourses({ skipVideoMetadata: false, maxConcurrency: 3, ...options }) // Full scan with metadata, lower concurrency for resource limits
|
||||
}
|
||||
|
||||
async function scanModules(
|
||||
courseId: string,
|
||||
coursePath: string,
|
||||
courseSlug: string,
|
||||
coursesRoot: string,
|
||||
result: ScanResult,
|
||||
courseMetadata: CourseMetadata | null,
|
||||
maxModulesPerCourse: number,
|
||||
maxLessonsPerCourse: number,
|
||||
autoFetchQuizzes: boolean,
|
||||
quizApiSource: 'the-trivia-api' | 'quizapi'
|
||||
) {
|
||||
try {
|
||||
const entries = await readdir(coursePath, { withFileTypes: true })
|
||||
const moduleDirs = entries.filter(e => e.isDirectory() && !e.name.startsWith('.')).sort((a, b) => naturalSort(a.name, b.name))
|
||||
|
||||
// Get existing modules in one query
|
||||
const existingModules = await prisma.module.findMany({
|
||||
where: { courseId },
|
||||
select: { id: true, slug: true, name: true }
|
||||
})
|
||||
const existingModuleMap = new Map(existingModules.map(m => [m.slug, m]))
|
||||
|
||||
// Batch upsert modules
|
||||
const moduleUpserts = moduleDirs
|
||||
.slice(0, maxModulesPerCourse)
|
||||
.map((moduleDir, index) => ({
|
||||
where: { courseId_slug: { courseId, slug: slugify(moduleDir.name) } },
|
||||
update: { name: moduleDir.name, order: index },
|
||||
create: { name: moduleDir.name, slug: slugify(moduleDir.name), order: index, courseId },
|
||||
}))
|
||||
|
||||
if (moduleUpserts.length > 0) {
|
||||
await prisma.$transaction(
|
||||
moduleUpserts.map(upsert => prisma.module.upsert(upsert))
|
||||
)
|
||||
}
|
||||
|
||||
// Get updated modules
|
||||
const updatedModules = await prisma.module.findMany({
|
||||
where: { courseId, slug: { in: moduleUpserts.map(u => u.where.courseId_slug.slug) } },
|
||||
select: { id: true, slug: true, name: true }
|
||||
})
|
||||
const moduleMap = new Map(updatedModules.map(m => [m.slug, m]))
|
||||
|
||||
// Process modules and scan lessons
|
||||
for (const moduleDir of moduleDirs.slice(0, maxModulesPerCourse)) {
|
||||
const moduleSlug = slugify(moduleDir.name)
|
||||
const module = moduleMap.get(moduleSlug)
|
||||
if (!module) continue
|
||||
|
||||
const existingModule = existingModuleMap.get(moduleSlug)
|
||||
if (!existingModule) result.modulesCreated++
|
||||
else result.modulesUpdated++
|
||||
|
||||
// Scan lessons in this module
|
||||
await scanLessons(courseId, module.id, join(coursePath, moduleDir.name), coursesRoot, result, maxLessonsPerCourse, autoFetchQuizzes, quizApiSource)
|
||||
}
|
||||
} catch (error) {
|
||||
result.errors.push('Failed to scan modules for course ' + courseSlug + ': ' + error)
|
||||
}
|
||||
}
|
||||
|
||||
async function scanLessons(
|
||||
courseId: string,
|
||||
moduleId: string,
|
||||
modulePath: string,
|
||||
coursesRoot: string,
|
||||
result: ScanResult,
|
||||
maxLessonsPerCourse: number,
|
||||
autoFetchQuizzes: boolean,
|
||||
quizApiSource: 'the-trivia-api' | 'quizapi'
|
||||
) {
|
||||
try {
|
||||
const moduleJsonMetadata = await readCourseMetadataFile(modulePath)
|
||||
if (moduleJsonMetadata) {
|
||||
await applyCourseMetadata(courseId, moduleJsonMetadata.metadata, `module-json:${basename(modulePath)}/${moduleJsonMetadata.fileName}`)
|
||||
}
|
||||
|
||||
// Track slugs within this module so same-name files do not overwrite each other.
|
||||
const moduleSeenSlugs = new Set<string>()
|
||||
|
||||
if (autoFetchQuizzes) {
|
||||
await ensureModuleQuizCache({
|
||||
modulePath,
|
||||
topic: basename(modulePath),
|
||||
source: quizApiSource,
|
||||
}).catch((error) => {
|
||||
result.errors.push('Failed to warm quiz cache for ' + modulePath + ': ' + error)
|
||||
})
|
||||
}
|
||||
|
||||
const entries = await readdir(modulePath, { withFileTypes: true })
|
||||
const files = entries
|
||||
.filter(e => e.isFile() && !e.name.startsWith('.'))
|
||||
.sort((a, b) => naturalSort(a.name, b.name))
|
||||
|
||||
// Build a map of subtitle files by their base name (for matching with videos)
|
||||
const subtitleMap = new Map<string, string>()
|
||||
for (const file of files) {
|
||||
const ext = extname(file.name).slice(1).toLowerCase()
|
||||
if (['srt', 'vtt'].includes(ext)) {
|
||||
const baseName = basename(file.name, extname(file.name))
|
||||
const relativePath = relative(coursesRoot, join(modulePath, file.name)).replace(/\\/g, '/')
|
||||
subtitleMap.set(baseName, relativePath)
|
||||
}
|
||||
}
|
||||
|
||||
// Get existing lessons in one query
|
||||
const existingLessons = await prisma.lesson.findMany({
|
||||
where: { moduleId },
|
||||
select: { id: true, slug: true }
|
||||
})
|
||||
const existingLessonMap = new Map(existingLessons.map(l => [l.slug, l]))
|
||||
|
||||
// Filter files to only include supported types
|
||||
const supportedFiles = files.filter(file => {
|
||||
if (file.name === 'quiz_cache.json' || isMetadataFileName(file.name)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const mimeType = getMimeType(file.name)
|
||||
const lessonType = getLessonType(mimeType)
|
||||
if (lessonType === 'OTHER' && !['json', 'txt', 'srt', 'vtt'].includes(extname(file.name).slice(1))) {
|
||||
return false
|
||||
}
|
||||
// Skip subtitle files for lesson creation - they'll be attached to videos
|
||||
if (['srt', 'vtt'].includes(extname(file.name).slice(1).toLowerCase())) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// Prepare lesson upserts
|
||||
const lessonUpserts = supportedFiles
|
||||
.slice(0, maxLessonsPerCourse)
|
||||
.map((file, index) => {
|
||||
const relativePath = relative(coursesRoot, join(modulePath, file.name)).replace(/\\/g, '/')
|
||||
const mimeType = getMimeType(file.name)
|
||||
const lessonType = getLessonType(mimeType)
|
||||
const baseName = basename(file.name, extname(file.name))
|
||||
const baseSlug = slugify(baseName)
|
||||
|
||||
let lessonSlug = baseSlug
|
||||
if (moduleSeenSlugs.has(lessonSlug)) {
|
||||
const typedSlug = `${baseSlug}-${lessonType.toLowerCase()}`
|
||||
lessonSlug = typedSlug
|
||||
let suffix = 2
|
||||
while (moduleSeenSlugs.has(lessonSlug)) {
|
||||
lessonSlug = `${typedSlug}-${suffix}`
|
||||
suffix += 1
|
||||
}
|
||||
}
|
||||
moduleSeenSlugs.add(lessonSlug)
|
||||
|
||||
// Check if there's a matching subtitle file
|
||||
const subtitlePath = subtitleMap.get(baseName) || null
|
||||
|
||||
return {
|
||||
where: { moduleId_slug: { moduleId, slug: lessonSlug } },
|
||||
update: {
|
||||
title: baseName,
|
||||
order: index,
|
||||
filePath: relativePath,
|
||||
fileName: file.name,
|
||||
mimeType,
|
||||
type: lessonType,
|
||||
duration: null,
|
||||
thumbnail: null,
|
||||
subtitlePath,
|
||||
},
|
||||
create: {
|
||||
title: baseName,
|
||||
slug: lessonSlug,
|
||||
order: index,
|
||||
filePath: relativePath,
|
||||
fileName: file.name,
|
||||
mimeType,
|
||||
type: lessonType,
|
||||
duration: null,
|
||||
thumbnail: null,
|
||||
subtitlePath,
|
||||
moduleId,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
if (lessonUpserts.length > 0) {
|
||||
await prisma.$transaction(
|
||||
lessonUpserts.map(upsert => prisma.lesson.upsert(upsert))
|
||||
)
|
||||
}
|
||||
|
||||
const moduleShouldSkipQuiz = await shouldSkipQuizGeneration(modulePath, basename(modulePath))
|
||||
|
||||
// Update counts for non-quiz lessons first
|
||||
const newLessons = lessonUpserts.filter(u => !existingLessonMap.has(u.where.moduleId_slug.slug))
|
||||
result.lessonsCreated += newLessons.length
|
||||
result.lessonsUpdated += lessonUpserts.length - newLessons.length
|
||||
|
||||
if (!moduleShouldSkipQuiz) {
|
||||
if (autoFetchQuizzes) {
|
||||
await ensureModuleQuizCache({
|
||||
modulePath,
|
||||
topic: basename(modulePath),
|
||||
source: quizApiSource,
|
||||
}).catch((error) => {
|
||||
result.errors.push('Failed to warm quiz cache for ' + modulePath + ': ' + error)
|
||||
})
|
||||
}
|
||||
|
||||
const quizSync = await syncQuizLessonFromCache({
|
||||
moduleId,
|
||||
modulePath,
|
||||
courseRoot: coursesRoot,
|
||||
topic: basename(modulePath),
|
||||
source: quizApiSource,
|
||||
lessonOrder: lessonUpserts.length,
|
||||
autoFetch: autoFetchQuizzes,
|
||||
})
|
||||
|
||||
if (quizSync) {
|
||||
if (quizSync.created) result.lessonsCreated += 1
|
||||
else result.lessonsUpdated += 1
|
||||
}
|
||||
} else {
|
||||
await prisma.lesson.deleteMany({ where: { moduleId, slug: 'quiz' } }).catch(() => {})
|
||||
}
|
||||
} catch (error) {
|
||||
result.errors.push('Failed to scan lessons: ' + error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export async function getCourseStats(courseId: string) {
|
||||
const [modulesCount, lessonsCount] = await Promise.all([
|
||||
prisma.module.count({ where: { courseId } }),
|
||||
prisma.lesson.count({ where: { module: { courseId } } }),
|
||||
])
|
||||
return { modulesCount, lessonsCount }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Course Thumbnail Index - Server Only
|
||||
* Handles local filesystem thumbnail checks
|
||||
* Only import this in server components
|
||||
*/
|
||||
import { join } from 'path'
|
||||
import { existsSync } from 'fs'
|
||||
|
||||
/**
|
||||
* Get local thumbnail URL if it exists on filesystem
|
||||
* Server-side only - checks filesystem
|
||||
*/
|
||||
export async function getLocalThumbnailUrl(courseSlug: string): Promise<string | null> {
|
||||
// Check for .png first, then .svg
|
||||
for (const ext of ['.png', '.svg']) {
|
||||
const localPath = join(process.cwd(), 'public', 'thumbnails', 'courses', `${courseSlug}-thumb${ext}`)
|
||||
if (existsSync(localPath)) {
|
||||
return `/thumbnails/courses/${courseSlug}-thumb${ext}`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Course Thumbnail Index - Client Safe
|
||||
* Maps course names to their external thumbnail URLs
|
||||
* Does NOT check local filesystem (server-side only)
|
||||
*
|
||||
* IMPORTANT: This file MUST NOT import 'fs' or 'path' modules
|
||||
* as it's used in client components. Server-side filesystem
|
||||
* operations should use lib/thumbnail-index-server.ts
|
||||
*/
|
||||
|
||||
const courseThumbnails: Record<string, string> = {
|
||||
// HashiCorp / Terraform
|
||||
'terraform': 'https://www.datocms-assets.com/2885/1623276269-terraform-logo.png',
|
||||
'hashicorp': 'https://www.datocms-assets.com/2885/1623276182-hashicorp-logo.png',
|
||||
'vault': 'https://www.datocms-assets.com/2885/1623276272-vault-logo.png',
|
||||
'consul': 'https://www.datocms-assets.com/2885/1623276274-consul-logo.png',
|
||||
'nomad': 'https://www.datocms-assets.com/2885/1623276276-nomad-logo.png',
|
||||
|
||||
// Jenkins
|
||||
'jenkins': 'https://www.jenkins.io/images/logos/jenkins/jenkins.svg',
|
||||
|
||||
// Kubernetes
|
||||
'kubernetes': 'https://raw.githubusercontent.com/kubernetes/kubernetes/master/logo/logo.png',
|
||||
'k8s': 'https://raw.githubusercontent.com/kubernetes/kubernetes/master/logo/logo.png',
|
||||
|
||||
// Docker
|
||||
'docker': 'https://www.docker.com/wp-content/uploads/2022/03/Moby-logo.png',
|
||||
'podman': 'https://podman.io/images/logo.svg',
|
||||
|
||||
// Ansible
|
||||
'ansible': 'https://ansible.com/img/ansible-logo-tm.png',
|
||||
|
||||
// Monitoring
|
||||
'prometheus': 'https://prometheus.io/assets/prometheus_logo-cb55bb5c346.png',
|
||||
'grafana': 'https://grafana.com/static/assets/img/blog/grafana_logo.png',
|
||||
|
||||
// CI/CD
|
||||
'github-actions': 'https://github.githubassets.com/images/modules/site/github-actions-icon.png',
|
||||
'gitlab': 'https://about.gitlab.com/images/press/logo.svg',
|
||||
|
||||
// Linux
|
||||
'linux': 'https://upload.wikimedia.org/wikipedia/commons/3/35/Tux.svg',
|
||||
'ubuntu': 'https://assets.ubuntu.com/v1/29985a98-ubuntu-logo32.png',
|
||||
|
||||
// Python
|
||||
'python': 'https://www.python.org/static/community_logos/python-logo-generic.svg',
|
||||
|
||||
// Go
|
||||
'go': 'https://go.dev/images/gophers/gopher.svg',
|
||||
'golang': 'https://go.dev/images/gophers/gopher.svg',
|
||||
|
||||
// Cloud
|
||||
'aws': 'https://a0.awsstatic.com/libra-css/images/logos/aws_logo_smile_1200x630.png',
|
||||
'azure': 'https://azure.microsoft.com/svghandler/azure-logo/',
|
||||
'gcp': 'https://cloud.google.com/images/social-icon-google-cloud-1200-630.png',
|
||||
|
||||
// Programming
|
||||
'javascript': 'https://raw.githubusercontent.com/github/explore/main/topics/javascript/javascript.png',
|
||||
'typescript': 'https://raw.githubusercontent.com/github/explore/main/topics/typescript/typescript.png',
|
||||
'react': 'https://raw.githubusercontent.com/github/explore/main/topics/react/react.png',
|
||||
'vue': 'https://raw.githubusercontent.com/github/explore/main/topics/vue/vue.png',
|
||||
'nodejs': 'https://raw.githubusercontent.com/github/explore/main/topics/nodejs/nodejs.png',
|
||||
|
||||
// DevOps tools
|
||||
'packer': 'https://www.datocms-assets.com/2885/1623276282-packer-logo.png',
|
||||
'vagrant': 'https://www.datocms-assets.com/2885/1623276284-vagrant-logo.png',
|
||||
}
|
||||
|
||||
/**
|
||||
* Get external thumbnail URL from index
|
||||
*/
|
||||
export function getCourseThumbnail(courseName: string, slug?: string): string | null {
|
||||
const name = courseName.toLowerCase()
|
||||
const courseSlug = (slug || '').toLowerCase()
|
||||
|
||||
for (const [key, url] of Object.entries(courseThumbnails)) {
|
||||
if (name.includes(key) || courseSlug.includes(key)) {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Client-side version that uses database-provided thumbnail
|
||||
export function getCourseThumbnailClient(course: { thumbnail?: string | null; name: string; slug?: string }): string | null {
|
||||
// First use database-stored thumbnail
|
||||
if (course.thumbnail) {
|
||||
return course.thumbnail
|
||||
}
|
||||
|
||||
// Fallback to external URLs from index
|
||||
return getCourseThumbnail(course.name, course.slug)
|
||||
}
|
||||
|
||||
export function getAvailableThumbnails(): string[] {
|
||||
return Object.values(courseThumbnails)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function formatDuration(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const secs = seconds % 60
|
||||
|
||||
if (hours > 0) {
|
||||
return hours + 'h ' + minutes + 'm'
|
||||
}
|
||||
return minutes + 'm ' + secs + 's'
|
||||
}
|
||||
|
||||
export function formatTime(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
|
||||
if (hours > 0) {
|
||||
return hours + ':' + String(minutes).padStart(2, '0') + ':' + String(secs).padStart(2, '0')
|
||||
}
|
||||
return minutes + ':' + String(secs).padStart(2, '0')
|
||||
}
|
||||
|
||||
export function slugify(text: string): string {
|
||||
const result = text
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/[\s_-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
return result || 'lesson'
|
||||
}
|
||||
|
||||
export function getMimeType(filename: string): string {
|
||||
const ext = filename.split('.').pop()?.toLowerCase()
|
||||
const mimeTypes: Record<string, string> = {
|
||||
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',
|
||||
html: 'text/html',
|
||||
htm: 'text/html',
|
||||
json: 'application/json',
|
||||
txt: 'text/plain',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
srt: 'text/plain',
|
||||
vtt: 'text/vtt',
|
||||
}
|
||||
return mimeTypes[ext || ''] || 'application/octet-stream'
|
||||
}
|
||||
|
||||
export function getLessonType(mimeType: string): string {
|
||||
if (mimeType.startsWith('video/')) return 'VIDEO'
|
||||
if (mimeType.startsWith('audio/')) return 'AUDIO'
|
||||
if (mimeType === 'application/pdf') return 'PDF'
|
||||
if (mimeType === 'text/markdown') return 'MARKDOWN'
|
||||
if (mimeType.startsWith('text/html')) return 'HTML'
|
||||
if (mimeType === 'application/json') return 'JSON'
|
||||
if (mimeType === 'text/plain') return 'TEXT'
|
||||
if (mimeType === 'text/vtt') return 'VTT'
|
||||
if (mimeType.startsWith('image/')) return 'IMAGE'
|
||||
return 'OTHER'
|
||||
}
|
||||
|
||||
export function isVideoType(type: string): boolean {
|
||||
return type === 'VIDEO'
|
||||
}
|
||||
|
||||
export function isDocumentType(type: string): boolean {
|
||||
return ['PDF', 'MARKDOWN', 'HTML'].includes(type)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { execSync } from 'child_process'
|
||||
import { join, dirname, basename, extname } from 'path'
|
||||
import { existsSync, mkdirSync } from 'fs'
|
||||
|
||||
/**
|
||||
* Get video duration in seconds using ffprobe
|
||||
*/
|
||||
export function getVideoDuration(filePath: string): number | null {
|
||||
try {
|
||||
const output = execSync(
|
||||
`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${filePath}"`,
|
||||
{ encoding: 'utf-8', timeout: 30000 }
|
||||
)
|
||||
const duration = parseFloat(output.trim())
|
||||
return isNaN(duration) ? null : Math.round(duration)
|
||||
} catch (error) {
|
||||
console.warn(`Failed to get duration for ${filePath}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate thumbnail from video at specified time (default 10% or 5 seconds)
|
||||
*/
|
||||
export function generateVideoThumbnail(
|
||||
videoPath: string,
|
||||
thumbnailDir: string,
|
||||
timePercent: number = 0.1
|
||||
): string | null {
|
||||
try {
|
||||
// Get video duration first to calculate timestamp
|
||||
const duration = getVideoDuration(videoPath)
|
||||
if (!duration) return null
|
||||
|
||||
const timestamp = Math.min(duration * timePercent, 5) // Max 5 seconds or 10%
|
||||
const timeStr = formatTimeForFfmpeg(timestamp)
|
||||
|
||||
// Create thumbnail directory if it doesn't exist
|
||||
if (!existsSync(thumbnailDir)) {
|
||||
mkdirSync(thumbnailDir, { recursive: true })
|
||||
}
|
||||
|
||||
const videoName = basename(videoPath, extname(videoPath))
|
||||
const thumbnailName = `${videoName}-thumb.jpg`
|
||||
const thumbnailPath = join(thumbnailDir, thumbnailName)
|
||||
|
||||
// Generate thumbnail using ffmpeg
|
||||
execSync(
|
||||
`ffmpeg -y -ss ${timeStr} -i "${videoPath}" -vframes 1 -q:v 2 -vf "scale=320:-1" "${thumbnailPath}"`,
|
||||
{ stdio: 'ignore', timeout: 60000 }
|
||||
)
|
||||
|
||||
if (existsSync(thumbnailPath)) {
|
||||
return thumbnailName
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
console.warn(`Failed to generate thumbnail for ${videoPath}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format seconds as HH:MM:SS for ffmpeg -ss parameter
|
||||
*/
|
||||
function formatTimeForFfmpeg(seconds: number): string {
|
||||
const hrs = Math.floor(seconds / 3600)
|
||||
const mins = Math.floor((seconds % 3600) / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
const ms = Math.round((seconds % 1) * 1000)
|
||||
return `${hrs.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}.${ms.toString().padStart(3, '0')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if ffmpeg/ffprobe are available
|
||||
*/
|
||||
export function checkVideoTools(): boolean {
|
||||
try {
|
||||
execSync('ffprobe -version', { stdio: 'ignore' })
|
||||
execSync('ffmpeg -version', { stdio: 'ignore' })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export 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
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export 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
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
@@ -0,0 +1,16 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
images: {
|
||||
remotePatterns: [],
|
||||
dangerouslyAllowSVG: true,
|
||||
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
|
||||
},
|
||||
experimental: {
|
||||
serverActions: {
|
||||
bodySizeLimit: '2mb',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"name": "offlineacademy",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 5001",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"db:push": "prisma db push",
|
||||
"db:studio": "prisma studio",
|
||||
"scan": "tsx scripts/scan.ts",
|
||||
"postinstall": "prisma generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.12.0",
|
||||
"@radix-ui/react-accordion": "^1.1.2",
|
||||
"@radix-ui/react-avatar": "^1.0.4",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-label": "^2.1.9",
|
||||
"@radix-ui/react-progress": "^1.0.3",
|
||||
"@radix-ui/react-scroll-area": "^1.2.11",
|
||||
"@radix-ui/react-select": "^2.3.1",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.0",
|
||||
"date-fns": "^4.4.0",
|
||||
"iconoir-react": "^7.11.0",
|
||||
"lucide-react": "^0.363.0",
|
||||
"next": "14.2.0",
|
||||
"next-themes": "^0.3.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"sharp": "^0.35.1",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.12.0",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-next": "14.2.0",
|
||||
"postcss": "^8.4.38",
|
||||
"prisma": "^5.12.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.4.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
// Prisma Schema for OfflineU2
|
||||
// Models: Course, Module, Lesson, Progress
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Course {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
displayName String?
|
||||
slug String @unique
|
||||
path String @unique
|
||||
thumbnail String?
|
||||
description String?
|
||||
hidden Boolean @default(false)
|
||||
favorited Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
modules Module[]
|
||||
progress Progress[]
|
||||
bookmarks Bookmark[]
|
||||
courseTags CourseTag[]
|
||||
tags Tag[] @relation("CourseTags")
|
||||
|
||||
@@index([slug])
|
||||
@@index([path])
|
||||
@@index([favorited])
|
||||
}
|
||||
|
||||
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[]
|
||||
bookmarks Bookmark[]
|
||||
|
||||
@@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")
|
||||
subtitlePath String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
progress Progress[]
|
||||
bookmarks Bookmark[]
|
||||
quiz Quiz?
|
||||
quizAttempts QuizAttempt[]
|
||||
|
||||
@@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 QuizAttempt {
|
||||
id String @id @default(cuid())
|
||||
userId String @default("local-user")
|
||||
lessonId String?
|
||||
lesson Lesson? @relation(fields: [lessonId], references: [id], onDelete: Cascade)
|
||||
score Int @default(0)
|
||||
passed Boolean @default(false)
|
||||
completed Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([userId, lessonId])
|
||||
@@index([userId, lessonId])
|
||||
}
|
||||
|
||||
model Quiz {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
description String?
|
||||
lessonId String? @unique
|
||||
lesson Lesson? @relation(fields: [lessonId], references: [id], onDelete: Cascade)
|
||||
source String?
|
||||
topic String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
questions Question[]
|
||||
|
||||
@@index([lessonId])
|
||||
}
|
||||
|
||||
model Question {
|
||||
id String @id @default(cuid())
|
||||
quizId String
|
||||
quiz Quiz @relation(fields: [quizId], references: [id], onDelete: Cascade)
|
||||
type String
|
||||
text String
|
||||
options String?
|
||||
correctAnswer String?
|
||||
order Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([quizId])
|
||||
}
|
||||
|
||||
model Bookmark {
|
||||
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)
|
||||
position Int
|
||||
note String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([userId, lessonId])
|
||||
@@index([userId, courseId])
|
||||
@@index([userId, createdAt])
|
||||
}
|
||||
|
||||
model Setting {
|
||||
id String @id @default(cuid())
|
||||
key String @unique
|
||||
value String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Tag {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
color String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
courseTags CourseTag[]
|
||||
courses Course[] @relation("CourseTags")
|
||||
}
|
||||
|
||||
model CourseTag {
|
||||
courseId String
|
||||
tagId String
|
||||
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([courseId, tagId])
|
||||
@@index([tagId])
|
||||
@@index([courseId])
|
||||
}
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 225">
|
||||
<rect width="400" height="225" fill="#00ADD8"/>
|
||||
<text x="200" y="112" font-family="system-ui, sans-serif" font-size="24" font-weight="bold" fill="white" text-anchor="middle">Go Programming</text>
|
||||
<text x="200" y="140" font-family="system-ui, sans-serif" font-size="14" fill="rgba(255,255,255,0.8)" text-anchor="middle">Course Thumbnail</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 426 B |
@@ -0,0 +1 @@
|
||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 33 36" width="2292" height="2500"><style>.st0{fill:#000}</style><g id="logo_4_"><path class="st0" d="M20 26.7l5.4-3V3.2L20 0v15.3h-6.9v-6l-5.5 3v20.5l5.5 3.2V20.7H20z"/><path class="st0" d="M28 4.6v20.8l-8 4.4V36l13-7.5v-21zM13.1 0L0 7.5v21l5.1 2.9V10.6l8-4.4z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 339 B |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 295 KiB |
@@ -0,0 +1,438 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg version="1.1" viewBox="0 0 216 256" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>Tux</title>
|
||||
<defs id="tux_fx">
|
||||
<linearGradient id="gradient_belly_shadow">
|
||||
<stop offset="0" stop-color="#000000"/>
|
||||
<stop offset="1" stop-color="#000000" stop-opacity="0.25"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="gradient_wing_tip_right_shadow">
|
||||
<stop offset="0" stop-color="#110800"/>
|
||||
<stop offset="0.59" stop-color="#a65a00" stop-opacity="0.8"/>
|
||||
<stop offset="1" stop-color="#ff921e" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="gradient_wing_tip_right_glare_1">
|
||||
<stop offset="0" stop-color="#7c7c7c"/>
|
||||
<stop offset="1" stop-color="#7c7c7c" stop-opacity="0.33"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="gradient_wing_tip_right_glare_2">
|
||||
<stop offset="0" stop-color="#7c7c7c"/>
|
||||
<stop offset="1" stop-color="#7c7c7c" stop-opacity="0.33"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="gradient_foot_left_layer_1">
|
||||
<stop offset="0" stop-color="#b98309"/>
|
||||
<stop offset="1" stop-color="#382605"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="gradient_foot_left_glare">
|
||||
<stop offset="0" stop-color="#ebc40c"/>
|
||||
<stop offset="1" stop-color="#ebc40c" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="gradient_foot_right_shadow">
|
||||
<stop offset="0" stop-color="#000000"/>
|
||||
<stop offset="1" stop-color="#000000" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="gradient_foot_right_layer_1">
|
||||
<stop offset="0" stop-color="#3e2a06"/>
|
||||
<stop offset="1" stop-color="#ad780a"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="gradient_foot_right_glare">
|
||||
<stop offset="0" stop-color="#f3cd0c"/>
|
||||
<stop offset="1" stop-color="#f3cd0c" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="gradient_eyeball">
|
||||
<stop offset="0" stop-color="#fefefc"/>
|
||||
<stop offset="0.75" stop-color="#fefefc"/>
|
||||
<stop offset="1" stop-color="#d4d4d4"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="gradient_pupil_left_glare">
|
||||
<stop offset="0" stop-color="#757574" stop-opacity="0"/>
|
||||
<stop offset="0.25" stop-color="#757574"/>
|
||||
<stop offset="0.5" stop-color="#757574"/>
|
||||
<stop offset="1" stop-color="#757574" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="gradient_pupil_right_glare_2">
|
||||
<stop offset="0" stop-color="#949494" stop-opacity="0.39"/>
|
||||
<stop offset="0.5" stop-color="#949494"/>
|
||||
<stop offset="1" stop-color="#949494" stop-opacity="0.39"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="gradient_eyelid_left">
|
||||
<stop offset="0" stop-color="#c8c8c8"/>
|
||||
<stop offset="1" stop-color="#797978"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="gradient_eyelid_right">
|
||||
<stop offset="0" stop-color="#747474"/>
|
||||
<stop offset="0.13" stop-color="#8c8c8c"/>
|
||||
<stop offset="0.25" stop-color="#a4a4a4"/>
|
||||
<stop offset="0.5" stop-color="#d4d4d4"/>
|
||||
<stop offset="0.62" stop-color="#d4d4d4"/>
|
||||
<stop offset="1" stop-color="#7c7c7c"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="gradient_eyebrow">
|
||||
<stop offset="0" stop-color="#646464" stop-opacity="0"/>
|
||||
<stop offset="0.31" stop-color="#646464" stop-opacity="0.58"/>
|
||||
<stop offset="0.47" stop-color="#646464"/>
|
||||
<stop offset="0.73" stop-color="#646464" stop-opacity="0.26"/>
|
||||
<stop offset="1" stop-color="#646464" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="gradient_beak_base">
|
||||
<stop offset="0" stop-color="#020204"/>
|
||||
<stop offset="0.73" stop-color="#020204"/>
|
||||
<stop offset="1" stop-color="#5c5c5c"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="gradient_mandible_lower">
|
||||
<stop offset="0" stop-color="#d2940a"/>
|
||||
<stop offset="0.75" stop-color="#d89c08"/>
|
||||
<stop offset="0.87" stop-color="#b67e07"/>
|
||||
<stop offset="1" stop-color="#946106"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="gradient_mandible_upper">
|
||||
<stop offset="0" stop-color="#ad780a"/>
|
||||
<stop offset="0.12" stop-color="#d89e08"/>
|
||||
<stop offset="0.25" stop-color="#edb80b"/>
|
||||
<stop offset="0.39" stop-color="#ebc80d"/>
|
||||
<stop offset="0.53" stop-color="#f5d838"/>
|
||||
<stop offset="0.77" stop-color="#f6d811"/>
|
||||
<stop offset="1" stop-color="#f5cd31"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="gradient_nares">
|
||||
<stop offset="0" stop-color="#3a2903"/>
|
||||
<stop offset="0.55" stop-color="#735208"/>
|
||||
<stop offset="1" stop-color="#ac8c04"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="gradient_beak_corner">
|
||||
<stop offset="0" stop-color="#f5ce2d"/>
|
||||
<stop offset="1" stop-color="#d79b08"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="fill_belly_shadow_left" href="#gradient_belly_shadow" xlink:href="#gradient_belly_shadow"
|
||||
gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1" gradientTransform="translate(61.18,121.19) scale(19,18)"/>
|
||||
<radialGradient id="fill_belly_shadow_right" href="#gradient_belly_shadow" xlink:href="#gradient_belly_shadow"
|
||||
gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1" gradientTransform="translate(125.74,131.6) scale(23.6,18)"/>
|
||||
<radialGradient id="fill_belly_shadow_middle" href="#gradient_belly_shadow" xlink:href="#gradient_belly_shadow"
|
||||
gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1" gradientTransform="translate(94.21,127.47) scale(9.35,10)"/>
|
||||
<linearGradient id="fill_foot_left_base" href="#gradient_foot_left_layer_1" xlink:href="#gradient_foot_left_layer_1"
|
||||
gradientUnits="userSpaceOnUse" x1="23.18" y1="193.01" x2="64.31" y2="262.02"/>
|
||||
<linearGradient id="fill_foot_left_glare" href="#gradient_foot_left_glare" xlink:href="#gradient_foot_left_glare"
|
||||
gradientUnits="userSpaceOnUse" x1="64.47" y1="210.83" x2="77.41" y2="235.21"/>
|
||||
<linearGradient id="fill_foot_right_shadow" href="#gradient_foot_right_shadow" xlink:href="#gradient_foot_right_shadow"
|
||||
gradientUnits="userSpaceOnUse" x1="146.93" y1="211.96" x2="150.2" y2="235.73"/>
|
||||
<linearGradient id="fill_foot_right_base" href="#gradient_foot_right_layer_1" xlink:href="#gradient_foot_right_layer_1"
|
||||
gradientUnits="userSpaceOnUse" x1="151.5" y1="253.02" x2="192.94" y2="185.84"/>
|
||||
<linearGradient id="fill_foot_right_glare" href="#gradient_foot_right_glare" xlink:href="#gradient_foot_right_glare"
|
||||
gradientUnits="userSpaceOnUse" x1="162.81" y1="180.67" x2="161.59" y2="191.64"/>
|
||||
<radialGradient id="fill_wing_tip_right_shadow_lower" href="#gradient_wing_tip_right_shadow" xlink:href="#gradient_wing_tip_right_shadow"
|
||||
gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1" gradientTransform="translate(169.71,194.53) rotate(15) scale(19.66,20.64)"/>
|
||||
<radialGradient id="fill_wing_tip_right_shadow_upper" href="#gradient_wing_tip_right_shadow" xlink:href="#gradient_wing_tip_right_shadow"
|
||||
gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1" gradientTransform="translate(169.71,189.89) rotate(-2.42) scale(19.74,14.86)"/>
|
||||
<radialGradient id="fill_wing_tip_right_glare_1" href="#gradient_wing_tip_right_glare_1" xlink:href="#gradient_wing_tip_right_glare_1"
|
||||
gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1" gradientTransform="translate(184.65,176.62) rotate(23.5) scale(6.95,3.21)"/>
|
||||
<linearGradient id="fill_wing_tip_right_glare_2" href="#gradient_wing_tip_right_glare_2" xlink:href="#gradient_wing_tip_right_glare_2"
|
||||
gradientUnits="userSpaceOnUse" x1="165.69" y1="173.58" x2="168.27" y2="173.47"/>
|
||||
<radialGradient id="fill_eyeball_left" href="#gradient_eyeball" xlink:href="#gradient_eyeball"
|
||||
gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1" gradientTransform="translate(86.49,51.41) rotate(-0.6) scale(10.24,15.68)"/>
|
||||
<linearGradient id="fill_pupil_left_glare" href="#gradient_pupil_left_glare" xlink:href="#gradient_pupil_left_glare"
|
||||
gradientUnits="userSpaceOnUse" x1="84.29" y1="46.64" x2="89.32" y2="55.63"/>
|
||||
<radialGradient id="fill_eyelid_left" href="#gradient_eyelid_left" xlink:href="#gradient_eyelid_left"
|
||||
gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1" gradientTransform="translate(84.89,43.74) rotate(-9.35) scale(6.25,5.77)"/>
|
||||
<linearGradient id="fill_eyebrow_left" href="#gradient_eyebrow" xlink:href="#gradient_eyebrow"
|
||||
gradientUnits="userSpaceOnUse" x1="83.59" y1="32.51" x2="94.48" y2="43.63"/>
|
||||
<radialGradient id="fill_eyeball_right" href="#gradient_eyeball" xlink:href="#gradient_eyeball"
|
||||
gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1" gradientTransform="translate(118.06,51.41) rotate(-1.8) scale(13.64,15.68)"/>
|
||||
<linearGradient id="fill_pupil_right_glare" href="#gradient_pupil_right_glare_2" xlink:href="#gradient_pupil_right_glare_2"
|
||||
gradientUnits="userSpaceOnUse" x1="117.87" y1="47.25" x2="123.66" y2="54.11"/>
|
||||
<linearGradient id="fill_eyelid_right" href="#gradient_eyelid_right" xlink:href="#gradient_eyelid_right"
|
||||
gradientUnits="userSpaceOnUse" x1="112.9" y1="36.23" x2="131.32" y2="47.01"/>
|
||||
<linearGradient id="fill_eyebrow_right" href="#gradient_eyebrow" xlink:href="#gradient_eyebrow"
|
||||
gradientUnits="userSpaceOnUse" x1="119.16" y1="31.56" x2="131.42" y2="43.14"/>
|
||||
<radialGradient id="fill_beak_base" href="#gradient_beak_base" xlink:href="#gradient_beak_base"
|
||||
gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1" gradientTransform="translate(97.64,60.12) rotate(-36) scale(11.44,10.38)"/>
|
||||
<radialGradient id="fill_mandible_lower_base" href="#gradient_mandible_lower" xlink:href="#gradient_mandible_lower"
|
||||
gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1" gradientTransform="translate(109.77,70.61) rotate(-22.4) scale(27.15,19.07)"/>
|
||||
<linearGradient id="fill_mandible_upper_base" href="#gradient_mandible_upper" xlink:href="#gradient_mandible_upper"
|
||||
gradientUnits="userSpaceOnUse" x1="78.09" y1="69.26" x2="126.77" y2="68.88"/>
|
||||
<radialGradient id="fill_naris_left" href="#gradient_nares" xlink:href="#gradient_nares"
|
||||
gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1" gradientTransform="translate(92.11,59.88) scale(1.32,1.42)"/>
|
||||
<radialGradient id="fill_naris_right" href="#gradient_nares" xlink:href="#gradient_nares"
|
||||
gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1" gradientTransform="translate(104.65,59.7) scale(2.78,1.62)"/>
|
||||
<linearGradient id="fill_beak_corner" href="#gradient_beak_corner" xlink:href="#gradient_beak_corner"
|
||||
gradientUnits="userSpaceOnUse" x1="126.74" y1="67.49" x2="126.74" y2="71.09"/>
|
||||
<filter id="blur_belly_shadow_left">
|
||||
<feGaussianBlur stdDeviation="0.64 0.55"/>
|
||||
</filter>
|
||||
<filter id="blur_belly_shadow_right">
|
||||
<feGaussianBlur stdDeviation="0.98"/>
|
||||
</filter>
|
||||
<filter id="blur_belly_shadow_middle">
|
||||
<feGaussianBlur stdDeviation="0.68"/>
|
||||
</filter>
|
||||
<filter id="blur_belly_shadow_lower" x="-0.8" width="2.6" y="-0.2" height="1.4">
|
||||
<feGaussianBlur stdDeviation="1.25"/>
|
||||
</filter>
|
||||
<filter id="blur_belly_glare" x="-0.8" width="2.6" y="-0.5" height="2">
|
||||
<feGaussianBlur stdDeviation="1.78 2.19"/>
|
||||
</filter>
|
||||
<filter id="blur_head_glare" x="-0.3" width="1.6" y="-0.3" height="1.6">
|
||||
<feGaussianBlur stdDeviation="1.73"/>
|
||||
</filter>
|
||||
<filter id="blur_neck_glare" x="-0.2" width="1.4" y="-0.2" height="1.4">
|
||||
<feGaussianBlur stdDeviation="0.78"/>
|
||||
</filter>
|
||||
<filter id="blur_wing_left_glare" x="-0.2" width="1.4" y="-0.2" height="1.4">
|
||||
<feGaussianBlur stdDeviation="0.98"/>
|
||||
</filter>
|
||||
<filter id="blur_wing_right_glare" x="-0.2" width="1.4" y="-0.2" height="1.4">
|
||||
<feGaussianBlur stdDeviation="1.19 1.17"/>
|
||||
</filter>
|
||||
<filter id="blur_foot_left_layer_1" x="-0.2" width="1.4" y="-0.2" height="1.4">
|
||||
<feGaussianBlur stdDeviation="3.38"/>
|
||||
</filter>
|
||||
<filter id="blur_foot_left_layer_2">
|
||||
<feGaussianBlur stdDeviation="2.1 2.06"/>
|
||||
</filter>
|
||||
<filter id="blur_foot_left_glare">
|
||||
<feGaussianBlur stdDeviation="0.32"/>
|
||||
</filter>
|
||||
<filter id="blur_foot_right_shadow">
|
||||
<feGaussianBlur stdDeviation="1.95 1.9"/>
|
||||
</filter>
|
||||
<filter id="blur_foot_right_layer_1" x="-0.2" width="1.4" y="-0.2" height="1.4">
|
||||
<feGaussianBlur stdDeviation="4.12"/>
|
||||
</filter>
|
||||
<filter id="blur_foot_right_layer_2" x="-0.2" width="1.4" y="-0.2" height="1.4">
|
||||
<feGaussianBlur stdDeviation="3.12 3.37"/>
|
||||
</filter>
|
||||
<filter id="blur_foot_right_glare" x="-0.2" width="1.4" y="-0.2" height="1.4">
|
||||
<feGaussianBlur stdDeviation="0.41"/>
|
||||
</filter>
|
||||
<filter id="blur_wing_tip_right_shadow_lower" x="-0.3" width="1.6" y="-0.3" height="1.6">
|
||||
<feGaussianBlur stdDeviation="2.45"/>
|
||||
</filter>
|
||||
<filter id="blur_wing_tip_right_shadow_upper" x="-0.2" width="1.4" y="-0.2" height="1.4">
|
||||
<feGaussianBlur stdDeviation="1.12 0.81"/>
|
||||
</filter>
|
||||
<filter id="blur_wing_tip_right_glare" x="-0.2" width="1.4" y="-0.2" height="1.4">
|
||||
<feGaussianBlur stdDeviation="0.88"/>
|
||||
</filter>
|
||||
<filter id="blur_pupil_left_glare" x="-0.3" width="1.6" y="-0.3" height="1.6">
|
||||
<feGaussianBlur stdDeviation="0.44"/>
|
||||
</filter>
|
||||
<filter id="blur_eyebrow_left">
|
||||
<feGaussianBlur stdDeviation="0.12"/>
|
||||
</filter>
|
||||
<filter id="blur_pupil_right_glare" x="-0.2" width="1.4" y="-0.2" height="1.4">
|
||||
<feGaussianBlur stdDeviation="0.45"/>
|
||||
</filter>
|
||||
<filter id="blur_eyebrow_right">
|
||||
<feGaussianBlur stdDeviation="0.13"/>
|
||||
</filter>
|
||||
<filter id="blur_beak_shadow_lower" x="-0.2" width="1.4" y="-0.2" height="1.4">
|
||||
<feGaussianBlur stdDeviation="1.75"/>
|
||||
</filter>
|
||||
<filter id="blur_beak_shadow_upper">
|
||||
<feGaussianBlur stdDeviation="0.8 0.74"/>
|
||||
</filter>
|
||||
<filter id="blur_mandible_lower_glare" x="-0.2" width="1.4" y="-0.2" height="1.4">
|
||||
<feGaussianBlur stdDeviation="0.77"/>
|
||||
</filter>
|
||||
<filter id="blur_mandible_upper_shadow">
|
||||
<feGaussianBlur stdDeviation="0.65"/>
|
||||
</filter>
|
||||
<filter id="blur_mandible_upper_glare" x="-0.2" width="1.4" y="-0.2" height="1.4">
|
||||
<feGaussianBlur stdDeviation="0.73"/>
|
||||
</filter>
|
||||
<filter id="blur_naris_left" x="-0.2" width="1.4" y="-0.2" height="1.4">
|
||||
<feGaussianBlur stdDeviation="0.1"/>
|
||||
</filter>
|
||||
<filter id="blur_naris_right">
|
||||
<feGaussianBlur stdDeviation="0.1"/>
|
||||
</filter>
|
||||
<filter id="blur_beak_corner" x="-0.2" width="1.4" y="-0.2" height="1.4">
|
||||
<feGaussianBlur stdDeviation="0.23"/>
|
||||
</filter>
|
||||
<clipPath id="clip_body">
|
||||
<use href="#body_base" xlink:href="#body_base"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip_wing_left">
|
||||
<use href="#wing_left_base" xlink:href="#wing_left_base"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip_wing_right">
|
||||
<use href="#wing_right_base" xlink:href="#wing_right_base"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip_foot_left">
|
||||
<use href="#foot_left_base" xlink:href="#foot_left_base"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip_foot_right">
|
||||
<use href="#foot_right_base" xlink:href="#foot_right_base"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip_wing_tip_right">
|
||||
<use href="#wing_tip_right_base" xlink:href="#wing_tip_right_base"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip_eye_left">
|
||||
<use href="#eyeball_left" xlink:href="#eyeball_left"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip_pupil_left">
|
||||
<use href="#pupil_left_base" xlink:href="#pupil_left_base"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip_eye_right">
|
||||
<use href="#eyeball_right" xlink:href="#eyeball_right"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip_pupil_right">
|
||||
<use href="#pupil_right_base" xlink:href="#pupil_right_base"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip_mandible_lower">
|
||||
<use href="#mandible_lower_base" xlink:href="#mandible_lower_base"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip_mandible_upper">
|
||||
<use href="#mandible_upper_base" xlink:href="#mandible_upper_base"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip_beak">
|
||||
<use href="#mandible_lower_base" xlink:href="#mandible_lower_base"/>
|
||||
<use href="#mandible_upper_base" xlink:href="#mandible_upper_base"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g id="tux">
|
||||
<g id="body">
|
||||
<path id="body_base" fill="#020204"
|
||||
d="m 106.95,0 c -6,0 -12.02,1.18 -17.46,4.12 -5.78,3.11 -10.52,8.09 -13.43,13.97 -2.92,5.88 -4.06,12.16 -4.24,19.08 -0.33,13.14 0.3,26.92 1.29,39.41 0.26,3.8 0.74,6.02 0.25,9.93 -1.62,8.3 -8.88,13.88 -12.76,21.17 -4.27,8.04 -6.07,17.13 -9.29,25.65 -2.95,7.79 -7.09,15.1 -9.88,22.95 -3.91,10.97 -5.08,23.03 -2.5,34.39 1.97,8.66 6.08,16.78 11.62,23.73 -0.8,1.44 -1.58,2.91 -2.4,4.34 -2.57,4.43 -5.71,8.64 -7.17,13.55 -0.73,2.45 -1.02,5.07 -0.55,7.59 0.47,2.52 1.75,4.93 3.75,6.53 1.31,1.04 2.9,1.72 4.53,2.1 1.63,0.37 3.32,0.46 5,0.43 6.37,-0.14 12.55,-2.07 18.71,-3.69 3.66,-0.96 7.34,-1.81 11.03,-2.58 13.14,-2.69 27.8,-1.61 39.99,0.15 4.13,0.63 8.23,1.44 12.29,2.43 6.36,1.54 12.69,3.5 19.23,3.69 1.72,0.05 3.46,-0.03 5.14,-0.4 1.68,-0.38 3.31,-1.06 4.65,-2.13 2.01,-1.6 3.29,-4.02 3.76,-6.54 0.47,-2.52 0.18,-5.15 -0.56,-7.61 -1.48,-4.92 -4.65,-9.11 -7.27,-13.52 -1.04,-1.75 -2,-3.53 -3.03,-5.28 7.9,-8.87 14.26,-19.13 17.94,-30.4 4.01,-12.3 4.75,-25.55 3.06,-38.38 -1.69,-12.83 -5.76,-25.27 -11.11,-37.05 -6.72,-14.76 -12.37,-20.1 -16.47,-33.07 -4.42,-14.02 -0.77,-30.61 -4.06,-43.32 -1.17,-4.32 -3.04,-8.45 -5.45,-12.23 -2.82,-4.43 -6.4,-8.39 -10.65,-11.47 -6.78,-4.92 -15.3,-7.54 -23.96,-7.54 z"/>
|
||||
<path id="belly" fill="#fdfdfb"
|
||||
d="m 83.13,74 c -0.9,1.13 -1.48,2.49 -1.84,3.89 -0.35,1.4 -0.48,2.85 -0.54,4.3 -0.11,2.89 0.07,5.83 -0.7,8.62 -0.82,2.98 -2.65,5.57 -4.44,8.08 -3.11,4.36 -6.25,8.84 -7.78,13.97 -0.93,3.1 -1.24,6.39 -0.91,9.62 -3.47,5.1 -6.48,10.53 -8.98,16.18 -3.78,8.57 -6.37,17.69 -7.28,27.01 -1.12,11.41 0.34,23.15 4.85,33.69 3.25,7.63 8.11,14.6 14.38,20.04 3.18,2.76 6.72,5.11 10.5,6.97 13.11,6.45 29.31,6.46 42.2,-0.41 6.74,-3.59 12.43,-8.84 17.91,-14.15 3.3,-3.2 6.59,-6.48 9.11,-10.32 4.85,-7.41 6.54,-16.41 7.59,-25.2 1.83,-15.36 1.89,-31.6 -4.85,-45.53 -2.32,-4.8 -5.41,-9.22 -9.12,-13.05 -0.98,-6.7 -2.93,-13.27 -5.76,-19.42 -2.05,-4.45 -4.54,-8.68 -6.44,-13.18 -0.78,-1.85 -1.46,-3.75 -2.32,-5.56 -0.87,-1.81 -1.93,-3.55 -3.39,-4.94 -1.48,-1.42 -3.33,-2.43 -5.28,-3.07 -1.95,-0.65 -4.01,-0.94 -6.06,-1.04 -4.11,-0.21 -8.22,0.33 -12.33,0.16 -3.27,-0.13 -6.53,-0.7 -9.8,-0.51 -1.63,0.1 -3.26,0.39 -4.78,1.01 -1.52,0.61 -2.92,1.56 -3.94,2.84 z"/>
|
||||
<g id="body_self_shadows">
|
||||
<path id="belly_shadow_left" opacity="0.25" fill="url(#fill_belly_shadow_left)" filter="url(#blur_belly_shadow_left)" clip-path="url(#clip_body)"
|
||||
d="m 68.67,115.18 c 0.87,1.31 -0.55,5.84 19.86,2.94 0,0 -3.59,0.39 -7.12,1.21 -5.49,1.84 -10.27,3.89 -13.97,6.61 -3.65,2.7 -6.33,6.21 -9.68,9.22 0,0 5.43,-9.92 6.78,-12.91 1.36,-2.99 -0.22,-2.85 0.85,-7.25 1.07,-4.4 3.69,-8.63 3.69,-8.63 0,0 -2.14,6.22 -0.41,8.81 z"/>
|
||||
<path id="belly_shadow_right" opacity="0.42" fill="url(#fill_belly_shadow_right)" filter="url(#blur_belly_shadow_right)" clip-path="url(#clip_body)"
|
||||
d="m 134.28,113.99 c -4.16,2.9 -6.6,2.56 -11.64,3.12 -5.05,0.57 -18.7,0.36 -18.7,0.36 0,0 1.97,-0.03 6.36,0.78 4.38,0.82 13.31,1.6 18.34,3.51 5.04,1.92 6.87,2.47 9.93,4.4 4.35,2.75 7.55,7.06 11.71,10.08 0,0 0.2,-4 -1.48,-6.99 -1.68,-2.99 -6.2,-7.7 -7.53,-12.1 -1.32,-4.4 -1.96,-13.04 -1.96,-13.04 0,0 -0.88,6.99 -5.03,9.88 z"/>
|
||||
<path id="belly_shadow_middle" opacity="0.2" fill="url(#fill_belly_shadow_middle)" filter="url(#blur_belly_shadow_middle)" clip-path="url(#clip_body)"
|
||||
d="m 95.17,107.81 c -0.16,1.25 -0.36,2.5 -0.6,3.74 -0.12,0.61 -0.26,1.22 -0.48,1.8 -0.23,0.58 -0.56,1.14 -1.02,1.55 -0.41,0.37 -0.9,0.62 -1.4,0.85 -1.94,0.88 -4.01,1.47 -6.12,1.74 0.84,0.06 1.68,0.14 2.53,0.23 0.53,0.06 1.06,0.12 1.57,0.25 0.52,0.14 1.03,0.34 1.46,0.65 0.47,0.35 0.84,0.82 1.12,1.34 0.55,1.02 0.73,2.2 0.83,3.37 0.13,1.48 0.14,2.98 0.03,4.46 0.1,-0.99 0.31,-1.98 0.62,-2.92 0.57,-1.72 1.47,-3.32 2.69,-4.65 0.49,-0.52 1.02,-1.01 1.6,-1.42 1.79,-1.26 4.07,-1.81 6.24,-1.51 -2.21,0.09 -4.44,-0.6 -6.2,-1.93 -0.9,-0.68 -1.68,-1.52 -2.22,-2.5 -0.84,-1.52 -1.08,-3.37 -0.65,-5.05 z"/>
|
||||
<path id="belly_shadow_lower" opacity="0.11" fill="#000000" filter="url(#blur_belly_shadow_lower)" clip-path="url(#clip_body)"
|
||||
d="m 89.85,137.14 c -1.06,4.03 -1.79,8.15 -2.17,12.31 -0.55,5.87 -0.42,11.78 -0.74,17.67 -0.26,4.99 -0.85,10.04 0.02,14.97 0.41,2.35 1.15,4.64 2.2,6.78 0.16,-0.82 0.29,-1.64 0.36,-2.47 0.37,-4 -0.3,-8.01 -0.53,-12.01 -0.4,-7.02 0.57,-14.04 0.97,-21.06 0.3,-5.39 0.27,-10.8 -0.11,-16.19 z"/>
|
||||
</g>
|
||||
<g id="body_glare">
|
||||
<path id="belly_glare" opacity="0.75" fill="#7c7c7c" filter="url(#blur_belly_glare)" clip-path="url(#clip_body)"
|
||||
d="m 160.08,131.23 c 1.03,-0.16 7.34,5.21 6.48,7.21 -0.86,1.99 -2.49,0.79 -3.65,0.8 -1.16,0.02 -4.33,1.46 -4.86,0.55 -0.54,-0.91 1.4,-3.03 2.41,-4.81 0.82,-1.43 -1.4,-3.59 -0.38,-3.75 z"/>
|
||||
<path id="head_glare" fill="#7c7c7c" filter="url(#blur_head_glare)" clip-path="url(#clip_body)"
|
||||
d="m 121.52,11.12 c -2.21,1.56 -1.25,3.51 -0.3,5.46 0.95,1.96 -2.09,7.59 -2.12,7.83 -0.03,0.24 5.98,-2.85 7.62,-4.87 1.94,-2.37 6.83,3.22 6.56,2.37 0.01,-1.52 -9.55,-12.34 -11.76,-10.79 z"/>
|
||||
<path id="neck_glare" fill="#838384" filter="url(#blur_neck_glare)" clip-path="url(#clip_body)"
|
||||
d="m 138.27,76.63 c -1.86,1.7 0.88,4.25 2.17,7.24 0.81,1.86 3.04,4.49 5.2,4.07 1.63,-0.32 2.63,-2.66 2.48,-4.3 -0.3,-3.18 -2.98,-3.93 -4.93,-5.02 -1.54,-0.86 -3.61,-3.18 -4.92,-1.99 z"/>
|
||||
</g>
|
||||
</g>
|
||||
<g id="wings">
|
||||
<g id="wing_left">
|
||||
<path id="wing_left_base" fill="#020204"
|
||||
d="m 63.98,100.91 c -6.1,6.92 -12.37,13.63 -15.81,21.12 -1.71,3.8 -2.51,7.93 -3.68,11.93 -1.32,4.54 -3.12,8.94 -5.14,13.22 -1.87,3.95 -3.93,7.81 -5.98,11.66 -1.5,2.81 -3.02,5.67 -3.54,8.81 -0.41,2.48 -0.18,5.04 0.46,7.47 0.63,2.43 1.64,4.75 2.79,6.98 4.88,9.55 12.21,17.77 20.89,24.07 3.94,2.85 8.15,5.32 12.58,7.35 2.4,1.09 4.92,2.07 7.56,2.11 1.32,0.03 2.65,-0.19 3.86,-0.72 1.2,-0.53 2.28,-1.38 3,-2.49 0.88,-1.36 1.18,-3.05 1,-4.66 -0.18,-1.61 -0.81,-3.15 -1.65,-4.53 -2.06,-3.38 -5.31,-5.83 -8.44,-8.25 -6.76,-5.23 -13.29,-10.76 -19.55,-16.58 -1.76,-1.65 -3.53,-3.34 -4.76,-5.42 -1.2,-2.02 -1.85,-4.32 -2.29,-6.63 -1.21,-6.33 -0.9,-12.99 1.25,-19.07 0.85,-2.38 1.96,-4.65 3.04,-6.93 1.86,-3.95 3.62,-7.98 6.07,-11.6 3.05,-4.51 7.13,-8.33 9.61,-13.17 2.1,-4.09 2.95,-8.68 3.76,-13.2 0.64,-3.54 1.85,-7 2.47,-10.54 -1.21,2.3 -5.11,6.07 -7.5,9.07 z"/>
|
||||
<path id="wing_left_glare" opacity="0.95" fill="#7c7c7c" filter="url(#blur_wing_left_glare)" clip-path="url(#clip_wing_left)"
|
||||
d="m 56.96,126.1 c -2,1.84 -3.73,3.97 -5.13,6.31 -2.3,3.84 -3.65,8.16 -5.33,12.31 -1.24,3.09 -2.69,6.2 -2.86,9.53 -0.09,1.71 0.16,3.42 0.22,5.13 0.06,1.71 -0.1,3.49 -0.94,4.98 -0.7,1.25 -1.87,2.23 -3.22,2.71 1.83,0.61 3.45,1.79 4.6,3.33 0.96,1.3 1.58,2.81 2.41,4.18 0.68,1.12 1.51,2.16 2.54,2.97 1.02,0.82 2.25,1.4 3.54,1.56 1.79,0.23 3.65,-0.36 4.97,-1.58 -1.66,-15.55 -0.14,-31.42 4.44,-46.37 0.29,-0.94 0.59,-1.89 0.67,-2.87 0.07,-0.99 -0.12,-2.03 -0.72,-2.81 -0.31,-0.42 -0.74,-0.75 -1.23,-0.96 -0.48,-0.2 -1.02,-0.28 -1.54,-0.21 -0.52,0.06 -1.03,0.26 -1.45,0.57 -0.42,0.32 -0.76,0.74 -0.97,1.22 z"/>
|
||||
</g>
|
||||
<g id="wing_right">
|
||||
<path id="wing_right_base" fill="#020204"
|
||||
d="m 162.76,127.12 c 5.24,4.22 8.57,10.59 9.6,17.24 0.8,5.18 0.28,10.51 -0.89,15.62 -1.17,5.12 -2.97,10.06 -4.77,15 -0.71,1.96 -1.43,3.95 -1.71,6.02 -0.29,2.08 -0.11,4.27 0.89,6.11 1.15,2.11 3.29,3.56 5.59,4.24 2.27,0.68 4.72,0.66 7.02,0.09 2.3,-0.57 6.17,-1.31 8.04,-2.77 4.75,-3.69 5.88,-10.1 7.01,-15.72 1.17,-5.87 0.6,-12.02 -0.43,-17.95 -1.41,-8.09 -3.78,-15.99 -6.79,-23.62 -2.22,-5.62 -5.06,-10.98 -8.44,-15.96 -3.32,-4.89 -8.02,-8.7 -11.5,-13.48 -1.21,-1.66 -2.66,-3.38 -3.84,-5.06 -2.56,-3.62 -1.98,-2.94 -3.57,-5.29 -1.15,-1.7 -2.97,-2.28 -4.88,-3.02 -1.92,-0.74 -4.06,-0.96 -6.04,-0.41 -2.6,0.73 -4.73,2.79 -5.86,5.24 -1.13,2.46 -1.33,5.28 -0.89,7.95 0.57,3.44 2.14,6.64 3.92,9.64 2,3.39 4.32,6.66 7.35,9.18 3.16,2.63 6.98,4.37 10.19,6.95 z"/>
|
||||
<path id="wing_right_glare" fill="#838384" filter="url(#blur_wing_right_glare)" clip-path="url(#clip_wing_right)"
|
||||
d="m 150.42,118.99 c 0.42,0.4 0.86,0.81 1.31,1.19 3.22,2.63 4.93,5.58 8.2,8.16 5.34,4.22 10.75,11.5 11.8,18.15 0.82,5.19 -0.26,8.01 -1.58,14.12 -1.32,6.12 -5.06,14.78 -7.09,20.68 -0.8,2.35 1.64,1.38 1.32,3.86 -0.16,1.22 -0.18,2.45 -0.03,3.67 0.02,-0.23 0.03,-0.48 0.06,-0.71 0.39,-3.38 1.42,-6.63 2.55,-9.82 2.17,-6.13 4.66,-12.15 6.38,-18.45 1.72,-6.29 1.53,-10.82 0.63,-16.23 -1.13,-6.81 -5.09,-13.09 -10.69,-17.24 -3.97,-2.93 -8.64,-4.81 -12.86,-7.38 z"/>
|
||||
</g>
|
||||
</g>
|
||||
<g id="feet">
|
||||
<g id="foot_left">
|
||||
<path id="foot_left_base" fill="url(#fill_foot_left_base)"
|
||||
d="m 34.98,175.33 c 1.38,-0.57 2.93,-0.68 4.39,-0.41 1.47,0.27 2.86,0.91 4.09,1.74 2.47,1.68 4.3,4.12 6.05,6.54 4.03,5.54 7.9,11.2 11.42,17.08 2.85,4.78 5.46,9.71 8.76,14.18 2.15,2.93 4.57,5.64 6.73,8.55 2.16,2.92 4.07,6.08 5.03,9.58 1.25,4.55 0.76,9.56 -1.4,13.75 -1.52,2.95 -3.86,5.48 -6.7,7.19 -2.84,1.71 -5.83,2.47 -9.15,2.47 -5.27,0 -10.42,-2.83 -15.32,-4.78 -9.98,-3.98 -20.82,-5.22 -31.11,-8.32 -3.16,-0.95 -6.27,-2.08 -9.45,-2.95 -1.42,-0.39 -2.85,-0.73 -4.19,-1.34 -1.34,-0.6 -2.59,-1.51 -3.33,-2.77 -0.57,-0.98 -0.8,-2.13 -0.8,-3.26 0,-1.14 0.28,-2.26 0.67,-3.32 0.77,-2.13 2.02,-4.06 2.86,-6.17 1.37,-3.44 1.62,-7.23 1.43,-10.93 -0.18,-3.69 -0.78,-7.36 -1.03,-11.05 -0.12,-1.65 -0.16,-3.32 0.16,-4.95 0.31,-1.62 1.01,-3.21 2.2,-4.35 1.1,-1.06 2.55,-1.69 4.05,-2 1.49,-0.31 3.03,-0.32 4.55,-0.29 1.52,0.03 3.05,0.12 4.57,-0.01 1.52,-0.12 3.05,-0.46 4.37,-1.22 1.26,-0.72 2.29,-1.79 3.14,-2.96 0.85,-1.17 1.54,-2.45 2.25,-3.72 0.7,-1.26 1.43,-2.52 2.36,-3.64 0.92,-1.12 2.06,-2.09 3.4,-2.64 z"/>
|
||||
<path id="foot_left_layer_1" fill="#d99a03" filter="url(#blur_foot_left_layer_1)" clip-path="url(#clip_foot_left)"
|
||||
d="m 37.16,177.7 c 1.25,-0.5 2.67,-0.56 3.98,-0.26 1.32,0.3 2.55,0.94 3.61,1.77 2.14,1.65 3.62,3.97 5.05,6.26 3.42,5.54 6.76,11.15 9.92,16.86 2.4,4.31 4.68,8.7 7.62,12.65 1.95,2.62 4.18,5.03 6.17,7.62 1.99,2.59 3.76,5.41 4.64,8.56 1.14,4.05 0.68,8.54 -1.28,12.26 -1.42,2.68 -3.58,4.96 -6.2,6.48 -2.61,1.52 -5.67,2.28 -8.69,2.14 -4.82,-0.22 -9.23,-2.63 -13.77,-4.26 -8.71,-3.16 -18.14,-3.59 -27.08,-6.05 -3.2,-0.87 -6.32,-2.03 -9.53,-2.84 -1.43,-0.36 -2.88,-0.66 -4.23,-1.23 -1.35,-0.57 -2.62,-1.45 -3.36,-2.72 -0.54,-0.95 -0.76,-2.06 -0.73,-3.15 0.04,-1.09 0.31,-2.17 0.7,-3.19 0.78,-2.04 2,-3.88 2.78,-5.92 1.19,-3.08 1.34,-6.47 1.12,-9.76 -0.22,-3.29 -0.8,-6.56 -1,-9.85 -0.08,-1.48 -0.1,-2.97 0.2,-4.41 0.3,-1.45 0.93,-2.85 1.98,-3.89 1.14,-1.13 2.7,-1.74 4.29,-1.99 1.58,-0.24 3.19,-0.13 4.78,0.01 1.6,0.14 3.2,0.32 4.8,0.23 1.6,-0.1 3.22,-0.49 4.54,-1.39 1.2,-0.81 2.1,-2 2.79,-3.27 0.69,-1.27 1.18,-2.64 1.71,-3.98 0.52,-1.35 1.09,-2.69 1.91,-3.89 0.82,-1.19 1.93,-2.24 3.28,-2.79 z"/>
|
||||
<path id="foot_left_layer_2" fill="#f5bd0c" filter="url(#blur_foot_left_layer_2)" clip-path="url(#clip_foot_left)"
|
||||
d="m 35.99,174.57 c 1.22,-0.6 2.65,-0.72 3.98,-0.45 1.33,0.27 2.57,0.92 3.62,1.77 2.09,1.7 3.43,4.13 4.67,6.51 2.84,5.46 5.5,11.04 8.9,16.19 2.48,3.73 5.33,7.2 7.83,10.92 3.39,5.03 6.15,10.57 7.29,16.5 0.76,4 0.74,8.31 -1.18,11.9 -1.27,2.37 -3.32,4.31 -5.75,5.52 -2.42,1.22 -5.21,1.71 -7.92,1.47 -4.27,-0.37 -8.14,-2.47 -12.16,-3.94 -7.13,-2.59 -14.84,-3.22 -22.18,-5.18 -3.09,-0.82 -6.13,-1.89 -9.26,-2.54 -1.39,-0.29 -2.8,-0.5 -4.12,-1 -1.32,-0.5 -2.57,-1.33 -3.25,-2.55 -0.47,-0.86 -0.63,-1.86 -0.56,-2.84 0.07,-0.97 0.36,-1.92 0.74,-2.83 0.77,-1.8 1.9,-3.46 2.49,-5.32 0.88,-2.75 0.52,-5.72 -0.14,-8.53 -0.65,-2.8 -1.6,-5.55 -1.89,-8.41 -0.13,-1.27 -0.13,-2.57 0.17,-3.82 0.29,-1.25 0.88,-2.45 1.81,-3.34 1.2,-1.15 2.88,-1.73 4.56,-1.89 1.67,-0.16 3.35,0.06 5.01,0.3 1.66,0.24 3.34,0.5 5.01,0.42 1.68,-0.07 3.39,-0.51 4.7,-1.54 1.3,-1.02 2.12,-2.53 2.59,-4.09 0.47,-1.57 0.62,-3.2 0.81,-4.82 0.19,-1.62 0.43,-3.26 1.06,-4.77 0.63,-1.51 1.69,-2.9 3.17,-3.64 z"/>
|
||||
<path id="foot_left_glare" fill="url(#fill_foot_left_glare)" filter="url(#blur_foot_left_glare)" clip-path="url(#clip_foot_left)"
|
||||
d="m 51.2,188.21 c 2.25,4.06 3.62,8.72 5.85,12.82 2.05,3.77 4.38,7.65 6.46,11.12 0.93,1.55 3.09,3.93 5.27,7.62 1.98,3.34 3.98,8.01 5.1,9.58 -0.64,-1.84 -1.96,-6.77 -3.54,-10.28 -1.47,-3.28 -3.19,-5.15 -4.24,-6.92 -2.08,-3.47 -4.33,-6.6 -6.47,-9.91 -2.95,-4.57 -5.2,-9.68 -8.43,-14.03 z"/>
|
||||
</g>
|
||||
<g id="foot_right">
|
||||
<path id="foot_right_shadow" opacity="0.2" fill="url(#fill_foot_right_shadow)" filter="url(#blur_foot_right_shadow)" clip-path="url(#clip_body)"
|
||||
d="m 198.7,215.61 c -0.4,1.33 -1.02,2.62 -1.81,3.8 -1.75,2.59 -4.3,4.55 -6.84,6.35 -4.33,3.07 -8.85,5.89 -12.89,9.38 -2.7,2.34 -5.17,4.97 -7.45,7.73 -1.95,2.36 -3.79,4.84 -6.02,6.94 -2.25,2.12 -4.89,3.84 -7.74,4.77 -3.47,1.13 -7.13,1.08 -10.47,0.22 -2.34,-0.6 -4.63,-1.64 -6.08,-3.53 -1.45,-1.89 -1.92,-4.44 -2.09,-6.94 -0.3,-4.42 0.23,-8.93 0.71,-13.42 0.4,-3.73 0.77,-7.46 0.92,-11.18 0.27,-6.77 -0.18,-13.47 -1.09,-20.05 -0.16,-1.11 -0.32,-2.22 -0.23,-3.35 0.09,-1.14 0.47,-2.32 1.27,-3.2 0.74,-0.81 1.77,-1.29 2.79,-1.52 1.02,-0.24 2.06,-0.25 3.09,-0.28 2.43,-0.06 4.86,-0.21 7.25,0.01 1.51,0.13 2.99,0.41 4.49,0.55 2.51,0.24 5.12,0.12 7.64,-0.62 2.71,-0.8 5.29,-2.29 8.05,-2.7 1.13,-0.17 2.26,-0.15 3.36,0.01 1.12,0.15 2.24,0.46 3.1,1.15 0.66,0.52 1.14,1.23 1.51,1.99 0.56,1.14 0.9,2.39 1.1,3.68 0.17,1.14 0.24,2.31 0.53,3.41 0.48,1.81 1.58,3.35 2.89,4.6 1.32,1.25 2.85,2.24 4.39,3.22 1.53,0.97 3.07,1.93 4.7,2.73 0.77,0.38 1.56,0.72 2.29,1.15 0.74,0.44 1.42,0.97 1.91,1.67 0.66,0.95 0.92,2.2 0.72,3.43 z"/>
|
||||
<path id="foot_right_base" fill="url(#fill_foot_right_base)"
|
||||
d="m 213.47,222.92 c -2.26,2.68 -5.4,4.45 -8.53,6.05 -5.33,2.71 -10.86,5.1 -15.87,8.37 -3.36,2.19 -6.46,4.76 -9.36,7.53 -2.48,2.37 -4.83,4.9 -7.61,6.91 -2.81,2.03 -6.05,3.5 -9.48,4.01 -0.95,0.14 -1.9,0.21 -2.86,0.21 -3.24,0 -6.48,-0.78 -9.46,-2.08 -2.7,-1.17 -5.3,-2.86 -6.86,-5.36 -1.56,-2.52 -1.92,-5.59 -1.92,-8.56 -0.01,-5.23 0.96,-10.41 1.87,-15.57 0.76,-4.29 1.48,-8.58 1.95,-12.91 0.85,-7.86 0.84,-15.81 0.28,-23.71 -0.1,-1.32 -0.21,-2.65 -0.01,-3.96 0.2,-1.31 0.74,-2.62 1.74,-3.48 0.93,-0.8 2.17,-1.16 3.4,-1.22 1.22,-0.07 2.44,0.12 3.65,0.3 2.85,0.42 5.73,0.74 8.52,1.48 1.76,0.46 3.48,1.08 5.23,1.56 2.94,0.79 6.01,1.17 9.02,0.82 3.25,-0.38 6.41,-1.6 9.68,-1.52 1.34,0.03 2.67,0.28 3.95,0.69 1.3,0.41 2.59,1 3.55,1.98 0.73,0.74 1.24,1.67 1.62,2.64 0.57,1.44 0.88,2.98 1.01,4.52 0.11,1.37 0.09,2.76 0.35,4.11 0.43,2.21 1.6,4.24 3.04,5.97 1.45,1.74 3.18,3.21 4.91,4.66 1.73,1.45 3.46,2.89 5.32,4.16 0.87,0.6 1.77,1.16 2.6,1.81 0.83,0.66 1.59,1.42 2.11,2.34 0.45,0.81 0.69,1.72 0.69,2.65 0,0.52 -0.07,1.04 -0.23,1.56 -0.45,1.43 -1.28,2.82 -2.3,4.04 z"/>
|
||||
<path id="foot_right_layer_1" fill="#cd8907" filter="url(#blur_foot_right_layer_1)" clip-path="url(#clip_foot_right)"
|
||||
d="m 213.21,216.12 c -0.53,1.33 -1.28,2.58 -2.22,3.67 -2.07,2.42 -4.93,4.01 -7.78,5.44 -4.88,2.44 -9.92,4.58 -14.5,7.52 -3.06,1.97 -5.9,4.28 -8.55,6.78 -2.26,2.13 -4.41,4.41 -6.95,6.21 -2.57,1.83 -5.53,3.14 -8.65,3.6 -3.8,0.56 -7.72,-0.16 -11.25,-1.67 -2.46,-1.06 -4.84,-2.56 -6.27,-4.83 -1.42,-2.26 -1.75,-5.02 -1.75,-7.69 -0.02,-4.71 0.87,-9.37 1.71,-14 0.7,-3.85 1.36,-7.71 1.78,-11.6 0.76,-7.08 0.73,-14.22 0.25,-21.32 -0.08,-1.19 -0.17,-2.39 0.01,-3.57 0.18,-1.18 0.67,-2.35 1.57,-3.13 0.85,-0.73 1.99,-1.05 3.11,-1.1 1.11,-0.06 2.22,0.12 3.33,0.28 2.61,0.38 5.23,0.67 7.78,1.33 1.61,0.42 3.18,0.98 4.78,1.4 2.68,0.72 5.49,1.06 8.24,0.74 2.97,-0.34 5.85,-1.44 8.83,-1.37 1.23,0.03 2.44,0.26 3.61,0.62 1.19,0.37 2.37,0.9 3.25,1.78 0.66,0.67 1.11,1.51 1.48,2.38 0.53,1.29 0.89,2.67 0.91,4.07 0.03,1.46 -0.28,2.92 -0.09,4.37 0.16,1.17 0.66,2.28 1.3,3.28 0.63,1 1.4,1.91 2.17,2.81 1.48,1.75 2.96,3.53 4.82,4.87 2.11,1.53 4.62,2.43 6.8,3.85 0.65,0.43 1.28,0.91 1.74,1.54 0.78,1.06 0.98,2.5 0.54,3.74 z"/>
|
||||
<path id="foot_right_layer_2" fill="#f5c021" filter="url(#blur_foot_right_layer_2)" clip-path="url(#clip_foot_right)"
|
||||
d="m 212.91,214.61 c -0.6,1.35 -1.37,2.6 -2.28,3.71 -2.12,2.58 -4.99,4.35 -8,5.49 -4.97,1.88 -10.39,2.13 -15.26,4.27 -2.97,1.3 -5.65,3.26 -8.36,5.12 -2.18,1.49 -4.42,2.94 -6.82,3.98 -2.72,1.19 -5.6,1.85 -8.5,2.32 -1.84,0.29 -3.71,0.51 -5.57,0.41 -1.86,-0.1 -3.72,-0.54 -5.37,-1.49 -1.24,-0.72 -2.36,-1.75 -3.03,-3.1 -0.73,-1.49 -0.86,-3.24 -0.85,-4.94 0.05,-4.5 1.02,-8.96 0.99,-13.47 -0.03,-3.93 -0.81,-7.8 -1.03,-11.72 -0.43,-7.54 1.19,-15.2 -0.24,-22.59 -0.22,-1.19 -0.53,-2.37 -0.52,-3.58 0.01,-0.6 0.1,-1.21 0.31,-1.77 0.22,-0.55 0.56,-1.06 1.01,-1.42 0.39,-0.29 0.84,-0.47 1.31,-0.56 0.46,-0.08 0.94,-0.06 1.41,0.01 0.93,0.15 1.82,0.51 2.73,0.78 2.6,0.78 5.35,0.76 8,1.35 1.66,0.36 3.26,0.97 4.91,1.41 2.75,0.76 5.63,1.08 8.46,0.75 3.04,-0.36 6.01,-1.46 9.07,-1.38 1.26,0.03 2.5,0.26 3.71,0.62 1.21,0.36 2.42,0.87 3.34,1.8 0.65,0.67 1.13,1.52 1.51,2.4 0.57,1.29 0.96,2.69 0.95,4.11 -0.01,0.74 -0.12,1.47 -0.19,2.21 -0.06,0.74 -0.08,1.49 0.09,2.2 0.18,0.72 0.55,1.37 0.97,1.96 0.42,0.59 0.9,1.12 1.34,1.7 1.22,1.61 2.1,3.49 3.05,5.3 0.95,1.81 2.02,3.6 3.53,4.91 2.05,1.77 4.7,2.48 6.99,3.89 0.67,0.41 1.31,0.89 1.78,1.55 0.38,0.52 0.63,1.15 0.73,1.81 0.09,0.65 0.03,1.34 -0.17,1.96 z"/>
|
||||
<path id="foot_right_glare" fill="url(#fill_foot_right_glare)" filter="url(#blur_foot_right_glare)" clip-path="url(#clip_foot_right)"
|
||||
d="m 148.08,181.58 c 2.82,-0.76 5.22,1.38 7.27,2.99 1.32,1.13 3.24,0.85 4.86,0.9 2.69,-0.09 5.36,0.45 8.05,0.12 5.3,-0.45 10.49,-1.75 15.81,-1.97 2.54,-0.16 5.4,-0.31 7.59,1.17 0.89,0.62 2.2,3.23 3.07,2.25 -0.36,-2.74 -2.39,-5.39 -5.11,-6.12 -2.14,-0.34 -4.3,0.25 -6.46,0.06 -6.39,-0.15 -12.75,-1.34 -19.16,-1 -4.46,0.04 -8.91,-0.17 -13.37,-0.34 -1.75,-0.36 -2.37,1.19 -3.32,1.79 0.25,0.19 0.34,0.25 0.77,0.15 z"/>
|
||||
</g>
|
||||
</g>
|
||||
<g id="wing_tip_right">
|
||||
<g id="wing_tip_right_shadow">
|
||||
<path id="wing_tip_right_shadow_lower" opacity="0.35" fill="url(#fill_wing_tip_right_shadow_lower)" filter="url(#blur_wing_tip_right_shadow_lower)" clip-path="url(#clip_foot_right)"
|
||||
d="m 185.49,187.61 c -0.48,-0.95 -1.36,-1.66 -2.35,-2.07 -0.98,-0.41 -2.06,-0.55 -3.13,-0.54 -2.13,0.02 -4.25,0.57 -6.38,0.39 -1.79,-0.16 -3.49,-0.83 -5.24,-1.26 -1.81,-0.44 -3.73,-0.61 -5.52,-0.12 -1.92,0.52 -3.61,1.81 -4.67,3.49 -0.94,1.48 -1.38,3.23 -1.52,4.98 -0.14,1.75 0.01,3.5 0.19,5.25 0.12,1.26 0.27,2.52 0.57,3.75 0.31,1.23 0.78,2.43 1.52,3.46 1.07,1.48 2.66,2.54 4.37,3.17 2.8,1.03 5.98,0.98 8.73,-0.15 4.88,-2.12 9.01,-5.92 11.52,-10.6 0.91,-1.68 1.61,-3.47 2.06,-5.31 0.18,-0.74 0.32,-1.49 0.32,-2.25 0.01,-0.75 -0.12,-1.52 -0.47,-2.19 z"/>
|
||||
<path id="wing_tip_right_shadow_upper" opacity="0.35" fill="url(#fill_wing_tip_right_shadow_upper)" filter="url(#blur_wing_tip_right_shadow_upper)" clip-path="url(#clip_foot_right)"
|
||||
d="m 185.49,184.89 c -0.48,-0.69 -1.36,-1.2 -2.35,-1.5 -0.98,-0.3 -2.06,-0.39 -3.13,-0.39 -2.13,0.02 -4.25,0.42 -6.38,0.28 -1.79,-0.11 -3.49,-0.6 -5.24,-0.9 -1.81,-0.32 -3.73,-0.45 -5.52,-0.09 -1.92,0.37 -3.61,1.3 -4.67,2.52 -0.94,1.07 -1.38,2.34 -1.52,3.6 -0.14,1.26 0.01,2.53 0.19,3.79 0.12,0.91 0.27,1.83 0.57,2.72 0.31,0.89 0.78,1.76 1.52,2.5 1.07,1.07 2.66,1.83 4.37,2.29 2.8,0.75 5.98,0.71 8.73,-0.11 4.88,-1.53 9.01,-4.28 11.52,-7.66 0.91,-1.22 1.61,-2.51 2.06,-3.84 0.18,-0.54 0.32,-1.08 0.32,-1.62 0.01,-0.55 -0.12,-1.11 -0.47,-1.59 z"/>
|
||||
</g>
|
||||
<path id="wing_tip_right_base" fill="#020204"
|
||||
d="m 189.55,178.72 c -0.35,-0.95 -0.97,-1.79 -1.72,-2.47 -0.75,-0.68 -1.64,-1.2 -2.57,-1.6 -1.86,-0.79 -3.89,-1.09 -5.89,-1.46 -1.87,-0.35 -3.74,-0.78 -5.62,-1.1 -1.96,-0.33 -3.98,-0.55 -5.92,-0.11 -1.69,0.38 -3.26,1.26 -4.54,2.43 -1.28,1.17 -2.28,2.63 -3,4.21 -1.27,2.79 -1.67,5.92 -1.43,8.97 0.18,2.27 0.76,4.61 2.25,6.32 1.21,1.39 2.92,2.26 4.68,2.78 3.04,0.9 6.35,0.85 9.36,-0.13 4.97,-1.67 9.37,-4.98 12.35,-9.29 0.98,-1.43 1.82,-2.98 2.2,-4.66 0.29,-1.28 0.3,-2.66 -0.15,-3.89 z"/>
|
||||
<g id="wing_tip_right_glare">
|
||||
<defs>
|
||||
<path id="path_wing_tip_right_glare"
|
||||
d="m 168.89,171.07 c -0.47,0.03 -0.93,0.08 -1.4,0.17 -2.99,0.53 -5.73,2.42 -7.27,5.03 -1.09,1.85 -1.58,4.03 -1.43,6.17 0.07,-1.5 0.46,-2.97 1.19,-4.28 1.23,-2.23 3.47,-3.91 5.98,-4.37 1.54,-0.28 3.13,-0.11 4.68,0.08 1.5,0.19 3,0.39 4.47,0.7 2.28,0.5 4.53,1.26 6.44,2.59 0.44,0.31 0.86,0.66 1.21,1.08 0.35,0.41 0.62,0.89 0.73,1.42 0.15,0.78 -0.07,1.6 -0.46,2.29 -0.39,0.7 -0.92,1.3 -1.48,1.86 -0.46,0.46 -0.94,0.89 -1.43,1.32 2.21,-0.43 4.44,-1.03 6.28,-2.31 0.77,-0.55 1.48,-1.2 1.94,-2.02 0.46,-0.83 0.65,-1.83 0.43,-2.75 -0.16,-0.62 -0.5,-1.19 -0.92,-1.67 -0.42,-0.48 -0.93,-0.87 -1.45,-1.24 -2.31,-1.62 -5.01,-2.65 -7.81,-2.99 -1.8,-0.33 -3.61,-0.61 -5.42,-0.83 -1.41,-0.18 -2.86,-0.33 -4.28,-0.25 z"/>
|
||||
</defs>
|
||||
<use id="wing_tip_right_glare_1" href="#path_wing_tip_right_glare" xlink:href="#path_wing_tip_right_glare"
|
||||
fill="url(#fill_wing_tip_right_glare_1)" filter="url(#blur_wing_tip_right_glare)" clip-path="url(#clip_wing_tip_right)"/>
|
||||
<use id="wing_tip_right_glare_2" href="#path_wing_tip_right_glare" xlink:href="#path_wing_tip_right_glare"
|
||||
fill="url(#fill_wing_tip_right_glare_2)" filter="url(#blur_wing_tip_right_glare)" clip-path="url(#clip_wing_tip_right)"/>
|
||||
</g>
|
||||
</g>
|
||||
<g id="face">
|
||||
<g id="eyes">
|
||||
<g id="eye_left">
|
||||
<path id="eyeball_left" fill="url(#fill_eyeball_left)"
|
||||
d="m 84.45,38.28 c -1.53,0.08 -3,0.79 -4.12,1.84 -1.13,1.05 -1.92,2.43 -2.41,3.88 -0.97,2.92 -0.75,6.08 -0.53,9.15 0.2,2.77 0.41,5.6 1.45,8.18 0.52,1.3 1.25,2.51 2.22,3.51 0.97,0.99 2.2,1.76 3.55,2.09 1.26,0.32 2.62,0.26 3.86,-0.13 1.25,-0.4 2.38,-1.11 3.32,-2.02 1.36,-1.33 2.27,-3.07 2.8,-4.9 0.53,-1.83 0.68,-3.75 0.65,-5.66 -0.04,-2.38 -0.35,-4.77 -1.09,-7.03 -0.75,-2.26 -1.94,-4.4 -3.6,-6.11 -0.8,-0.83 -1.72,-1.55 -2.75,-2.06 -1.04,-0.51 -2.2,-0.8 -3.35,-0.74 z"/>
|
||||
<g id="pupil_left">
|
||||
<path id="pupil_left_base" fill="#020204"
|
||||
d="m 80.75,50.99 c -0.32,1.94 -0.33,3.97 0.33,5.81 0.44,1.22 1.17,2.33 2.05,3.28 0.57,0.62 1.23,1.18 1.99,1.55 0.77,0.37 1.65,0.52 2.48,0.32 0.76,-0.19 1.42,-0.68 1.91,-1.29 0.49,-0.61 0.82,-1.34 1.05,-2.09 0.69,-2.21 0.58,-4.62 -0.11,-6.83 -0.49,-1.61 -1.32,-3.16 -2.6,-4.24 -0.62,-0.52 -1.34,-0.93 -2.12,-1.11 -0.78,-0.19 -1.63,-0.14 -2.36,0.19 -0.81,0.37 -1.44,1.07 -1.85,1.86 -0.41,0.79 -0.62,1.67 -0.77,2.55 z"/>
|
||||
<path id="pupil_left_glare" fill="url(#fill_pupil_left_glare)" filter="url(#blur_pupil_left_glare)" clip-path="url(#clip_pupil_left)"
|
||||
d="m 84.84,49.59 c 0.21,0.55 0.91,0.75 1.3,1.19 0.37,0.42 0.76,0.87 0.97,1.4 0.39,1.01 -0.39,2.51 0.43,3.23 0.25,0.22 0.77,0.23 1.02,0 0.99,-0.9 0.77,-2.71 0.38,-3.99 -0.36,-1.15 -1.23,-2.25 -2.31,-2.8 -0.5,-0.26 -1.25,-0.47 -1.68,-0.11 -0.27,0.24 -0.24,0.74 -0.11,1.08 z"/>
|
||||
</g>
|
||||
<path id="eyelid_left" fill="url(#fill_eyelid_left)" clip-path="url(#clip_eye_left)"
|
||||
d="m 81.14,44.46 c 2.32,-1.38 5.13,-1.7 7.82,-1.45 2.68,0.26 5.27,1.04 7.87,1.75 1.91,0.52 3.84,1 5.63,1.84 1.78,0.84 3.44,2.08 4.43,3.8 0.16,0.27 0.29,0.56 0.46,0.83 0.17,0.27 0.37,0.52 0.62,0.71 0.25,0.19 0.57,0.32 0.88,0.3 0.16,-0.01 0.32,-0.05 0.45,-0.13 0.14,-0.08 0.26,-0.2 0.33,-0.34 0.08,-0.16 0.11,-0.35 0.1,-0.53 -0.01,-0.18 -0.05,-0.36 -0.1,-0.54 -0.65,-2.37 -2.19,-4.38 -3.35,-6.55 -0.7,-1.3 -1.28,-2.66 -1.98,-3.96 -2.43,-4.45 -6.42,-7.94 -10.95,-10.21 -4.53,-2.27 -9.59,-3.36 -14.65,-3.65 -5.86,-0.35 -11.73,0.35 -17.51,1.37 -2.51,0.44 -5.06,0.96 -7.27,2.21 -1.11,0.62 -2.13,1.42 -2.92,2.42 -0.8,0.99 -1.36,2.18 -1.55,3.44 -0.17,1.22 0.01,2.47 0.44,3.62 0.42,1.15 1.08,2.2 1.86,3.15 1.54,1.91 3.53,3.39 5.36,5.03 1.83,1.63 3.52,3.44 5.57,4.79 1.02,0.68 2.13,1.24 3.31,1.57 1.18,0.33 2.44,0.42 3.64,0.17 1.24,-0.25 2.4,-0.86 3.41,-1.64 1.01,-0.77 1.88,-1.7 2.71,-2.66 1.66,-1.93 3.21,-4.04 5.39,-5.34 z"/>
|
||||
<path id="eyebrow_left" fill="url(#fill_eyebrow_left)" filter="url(#blur_eyebrow_left)"
|
||||
d="m 90.77,36.57 c 2.16,2.02 3.76,4.52 4.85,7.16 -0.48,-2.91 -1.23,-5.26 -3.13,-7.16 -1.16,-1.09 -2.49,-2.05 -3.98,-2.72 -1.32,-0.59 -2.77,-0.96 -3.61,-0.97 -0.83,-0.02 -1.03,0 -1.2,0.01 -0.18,0.01 -0.31,0.01 0.23,0.08 0.54,0.06 1.75,0.39 3.05,0.97 1.3,0.58 2.62,1.54 3.79,2.63 z"/>
|
||||
</g>
|
||||
<g id="eye_right">
|
||||
<path id="eyeball_right" fill="url(#fill_eyeball_right)"
|
||||
d="m 111.61,38.28 c -2.39,1.65 -4.4,3.94 -5.38,6.68 -1.24,3.45 -0.77,7.31 0.43,10.77 1.22,3.55 3.27,6.93 6.36,9.06 1.54,1.07 3.33,1.8 5.19,2.02 1.87,0.22 3.8,-0.09 5.47,-0.95 2.02,-1.06 3.57,-2.91 4.53,-4.98 0.96,-2.08 1.37,-4.37 1.5,-6.66 0.16,-2.9 -0.12,-5.86 -1.08,-8.61 -1.04,-2.99 -2.92,-5.75 -5.58,-7.47 -1.32,-0.86 -2.83,-1.45 -4.4,-1.67 -1.57,-0.22 -3.19,-0.05 -4.67,0.52 -0.84,0.33 -1.62,0.78 -2.37,1.29 z"/>
|
||||
<g id="pupil_right">
|
||||
<path id="pupil_right_base" fill="#020204"
|
||||
d="m 117.14,45.52 c -0.9,0.06 -1.78,0.37 -2.55,0.85 -0.76,0.48 -1.41,1.13 -1.92,1.88 -1.03,1.49 -1.48,3.31 -1.55,5.12 -0.05,1.35 0.1,2.72 0.55,4 0.45,1.28 1.2,2.47 2.25,3.33 1.07,0.89 2.42,1.42 3.81,1.49 1.39,0.06 2.79,-0.34 3.93,-1.13 0.91,-0.63 1.64,-1.5 2.16,-2.48 0.52,-0.97 0.84,-2.05 0.98,-3.15 0.25,-1.93 -0.03,-3.95 -0.93,-5.69 -0.89,-1.74 -2.41,-3.17 -4.24,-3.84 -0.8,-0.29 -1.65,-0.44 -2.49,-0.38 z"/>
|
||||
<path id="pupil_right_glare" fill="url(#fill_pupil_right_glare)" filter="url(#blur_pupil_right_glare)" clip-path="url(#clip_pupil_right)"
|
||||
d="m 122.71,53.36 c 1,-1 -0.71,-3.65 -2.05,-4.74 -0.97,-0.78 -3.78,-1.61 -3.66,-0.75 0.12,0.85 1.39,1.95 2.23,2.79 1.05,1.03 3,3.18 3.48,2.7 z"/>
|
||||
</g>
|
||||
<path id="eyelid_right" fill="url(#fill_eyelid_right)" clip-path="url(#clip_eye_right)"
|
||||
d="m 102.56,47.01 c 2.06,-1.71 4.45,-3.01 7,-3.8 5.25,-1.62 11.2,-0.98 15.84,1.97 1.6,1.01 3.03,2.27 4.52,3.45 1.48,1.17 3.06,2.27 4.85,2.9 0.97,0.34 2,0.54 3.02,0.43 0.92,-0.09 1.81,-0.44 2.57,-0.96 0.76,-0.53 1.4,-1.23 1.88,-2.02 0.96,-1.58 1.27,-3.5 1.1,-5.34 -0.33,-3.69 -2.41,-6.94 -4.15,-10.21 -0.55,-1.02 -1.07,-2.06 -1.73,-3.01 -2.01,-2.93 -5.23,-4.86 -8.6,-5.99 -3.37,-1.13 -6.93,-1.54 -10.46,-1.98 -1.58,-0.2 -3.17,-0.41 -4.74,-0.22 -1.81,0.22 -3.51,0.95 -5.28,1.4 -0.84,0.22 -1.69,0.37 -2.52,0.61 -0.83,0.24 -1.65,0.57 -2.33,1.11 -0.98,0.79 -1.6,1.98 -1.87,3.21 -0.27,1.24 -0.21,2.52 -0.01,3.77 0.39,2.5 1.33,4.93 1.24,7.46 -0.06,1.73 -0.61,3.44 -0.54,5.17 0.02,0.51 0.12,1.55 0.21,2.05 z"/>
|
||||
<path id="eyebrow_right" fill="url(#fill_eyebrow_right)" filter="url(#blur_eyebrow_right)"
|
||||
d="m 119.93,31.18 c -0.41,0.52 -0.78,1.08 -1.07,1.7 1.85,0.4 3.61,1.16 5.19,2.21 3.06,2.03 5.38,4.99 7.01,8.29 0.38,-0.42 0.72,-0.87 1.02,-1.37 -1.64,-3.44 -4,-6.55 -7.16,-8.65 -1.52,-1 -3.21,-1.77 -4.99,-2.18 z"/>
|
||||
</g>
|
||||
</g>
|
||||
<g id="beak">
|
||||
<g id="beak_shadow">
|
||||
<path id="beak_shadow_lower" fill="#000000" fill-opacity="0.258824" filter="url(#blur_beak_shadow_lower)" clip-path="url(#clip_body)"
|
||||
d="m 81.12,89.33 c 1.47,4.26 4.42,7.89 7.92,10.72 1.16,0.95 2.39,1.82 3.76,2.43 1.36,0.62 2.87,0.97 4.36,0.84 1.46,-0.12 2.85,-0.7 4.13,-1.42 1.28,-0.72 2.46,-1.59 3.7,-2.37 2.12,-1.35 4.39,-2.44 6.6,-3.64 2.65,-1.45 5.23,-3.1 7.46,-5.14 1.03,-0.93 1.98,-1.95 3.11,-2.75 1.13,-0.81 2.49,-1.39 3.87,-1.29 1.04,0.07 2.01,0.51 3.03,0.73 0.51,0.11 1.03,0.16 1.55,0.08 0.51,-0.08 1.01,-0.29 1.37,-0.67 0.44,-0.46 0.64,-1.12 0.61,-1.76 -0.02,-0.63 -0.24,-1.25 -0.54,-1.81 -0.59,-1.13 -1.49,-2.1 -1.89,-3.31 -0.36,-1.08 -0.29,-2.24 -0.26,-3.37 0.03,-1.14 0.01,-2.32 -0.51,-3.33 -0.4,-0.76 -1.07,-1.37 -1.83,-1.77 -0.76,-0.41 -1.62,-0.62 -2.48,-0.7 -1.72,-0.16 -3.44,0.18 -5.17,0.27 -2.28,0.13 -4.58,-0.15 -6.87,-0.02 -2.85,0.18 -5.65,1 -8.51,1.01 -3.26,0.01 -6.52,-1.06 -9.74,-0.55 -1.39,0.22 -2.71,0.72 -4.03,1.16 -1.33,0.45 -2.7,0.84 -4.1,0.82 -1.59,-0.03 -3.13,-0.58 -4.72,-0.69 -0.79,-0.06 -1.6,0 -2.35,0.28 -0.74,0.28 -1.41,0.79 -1.78,1.5 -0.21,0.4 -0.31,0.86 -0.33,1.31 -0.02,0.46 0.04,0.91 0.15,1.36 0.22,0.88 0.63,1.71 0.96,2.55 1.2,3.07 1.46,6.42 2.53,9.53 z"/>
|
||||
<path id="beak_shadow_upper" opacity="0.3" fill="#000000" filter="url(#blur_beak_shadow_upper)" clip-path="url(#clip_body)"
|
||||
d="m 77.03,77.2 c 2.85,1.76 5.41,3.93 7.56,6.39 1.99,2.29 3.68,4.89 6.29,6.58 1.83,1.2 4.04,1.87 6.28,2.08 2.63,0.24 5.29,-0.15 7.83,-0.84 2.35,-0.63 4.62,-1.53 6.7,-2.71 3.97,-2.25 7.28,-5.55 11.65,-7.03 0.95,-0.33 1.94,-0.56 2.86,-0.96 0.92,-0.39 1.79,-0.99 2.23,-1.83 0.42,-0.82 0.4,-1.75 0.54,-2.64 0.15,-0.96 0.48,-1.88 0.66,-2.83 0.18,-0.95 0.2,-1.96 -0.24,-2.83 -0.37,-0.72 -1.04,-1.29 -1.81,-1.66 -0.77,-0.36 -1.64,-0.52 -2.51,-0.56 -1.72,-0.08 -3.43,0.33 -5.16,0.47 -2.28,0.19 -4.58,-0.08 -6.87,-0.01 -2.85,0.08 -5.66,0.67 -8.51,0.8 -3.25,0.14 -6.49,-0.34 -9.74,-0.44 -1.41,-0.05 -2.83,-0.03 -4.21,0.2 -1.39,0.22 -2.75,0.65 -3.92,1.37 -1.14,0.69 -2.07,1.64 -3.11,2.45 -0.52,0.41 -1.08,0.78 -1.68,1.07 -0.61,0.28 -1.28,0.48 -1.96,0.51 -0.35,0.01 -0.71,-0.01 -1.05,0.04 -0.59,0.08 -1.13,0.39 -1.47,0.83 -0.34,0.45 -0.47,1.02 -0.36,1.55 z"/>
|
||||
</g>
|
||||
<path id="beak_base" fill="url(#fill_beak_base)"
|
||||
d="m 91.66,58.53 c 1.53,-1.71 2.57,-3.8 4.03,-5.56 0.73,-0.88 1.58,-1.69 2.57,-2.26 0.99,-0.57 2.15,-0.89 3.29,-0.79 1.27,0.11 2.46,0.74 3.39,1.61 0.93,0.87 1.62,1.97 2.17,3.12 0.53,1.11 0.95,2.28 1.71,3.24 0.81,1.02 1.94,1.71 2.97,2.52 0.51,0.4 1.01,0.83 1.41,1.34 0.41,0.51 0.72,1.1 0.86,1.74 0.13,0.65 0.06,1.33 -0.16,1.95 -0.23,0.62 -0.61,1.18 -1.09,1.64 -0.95,0.92 -2.25,1.42 -3.56,1.6 -2.62,0.37 -5.27,-0.41 -7.92,-0.34 -2.67,0.08 -5.29,1.02 -7.97,0.93 -1.33,-0.05 -2.69,-0.38 -3.79,-1.14 -0.55,-0.39 -1.03,-0.88 -1.38,-1.45 -0.34,-0.57 -0.55,-1.23 -0.58,-1.9 -0.02,-0.64 0.13,-1.28 0.39,-1.86 0.25,-0.59 0.61,-1.12 1.01,-1.62 0.81,-0.99 1.8,-1.81 2.65,-2.77 z"/>
|
||||
<g id="mandible_lower">
|
||||
<path id="mandible_lower_base" fill="url(#fill_mandible_lower_base)"
|
||||
d="m 77.14,75.05 c 0.06,0.26 0.15,0.5 0.28,0.73 0.23,0.38 0.57,0.69 0.93,0.95 0.36,0.27 0.75,0.49 1.13,0.72 2.01,1.27 3.65,3.04 5.11,4.92 1.95,2.52 3.68,5.31 6.29,7.14 1.84,1.3 4.04,2.03 6.28,2.26 2.63,0.26 5.29,-0.16 7.83,-0.91 2.35,-0.69 4.62,-1.66 6.7,-2.95 3.97,-2.44 7.28,-6.02 11.65,-7.63 0.95,-0.35 1.94,-0.6 2.86,-1.03 0.92,-0.44 1.79,-1.08 2.23,-2 0.42,-0.88 0.4,-1.9 0.54,-2.87 0.15,-1.03 0.48,-2.03 0.66,-3.06 0.18,-1.03 0.2,-2.13 -0.24,-3.08 -0.37,-0.78 -1.04,-1.4 -1.81,-1.79 -0.77,-0.4 -1.64,-0.58 -2.51,-0.62 -1.72,-0.08 -3.43,0.36 -5.16,0.52 -2.28,0.21 -4.58,-0.09 -6.87,-0.02 -2.85,0.09 -5.66,0.73 -8.51,0.87 -3.25,0.15 -6.49,-0.35 -9.74,-0.48 -1.41,-0.06 -2.83,-0.04 -4.22,0.2 -1.39,0.23 -2.75,0.71 -3.91,1.51 -1.13,0.78 -2.03,1.84 -3.07,2.74 -0.52,0.45 -1.08,0.86 -1.7,1.16 -0.61,0.3 -1.29,0.49 -1.98,0.47 -0.35,-0.01 -0.72,-0.06 -1.05,0.04 -0.21,0.07 -0.4,0.2 -0.56,0.35 -0.16,0.16 -0.29,0.34 -0.41,0.52 -0.29,0.42 -0.54,0.87 -0.75,1.34 z"/>
|
||||
<path id="mandible_lower_glare" fill="#d9b30d" filter="url(#blur_mandible_lower_glare)" clip-path="url(#clip_mandible_lower)"
|
||||
d="m 89.9,78.56 c -0.33,1.37 -0.13,2.87 0.56,4.11 0.68,1.24 1.84,2.2 3.19,2.65 1.7,0.57 3.62,0.29 5.21,-0.54 0.93,-0.48 1.77,-1.16 2.3,-2.06 0.27,-0.44 0.46,-0.94 0.53,-1.46 0.06,-0.51 0.02,-1.05 -0.16,-1.54 -0.2,-0.53 -0.56,-1 -0.99,-1.37 -0.44,-0.37 -0.95,-0.64 -1.5,-0.82 -1.08,-0.36 -2.77,-0.66 -3.91,-0.68 -2.02,-0.04 -4.9,0.34 -5.23,1.71 z"/>
|
||||
</g>
|
||||
<g id="mandible_upper">
|
||||
<path id="mandible_upper_shadow" fill="#604405" filter="url(#blur_mandible_upper_shadow)" clip-path="url(#clip_mandible_lower)"
|
||||
d="m 84.31,67.86 c -1.16,0.68 -2.27,1.43 -3.36,2.2 -0.57,0.41 -1.15,0.84 -1.45,1.47 -0.21,0.44 -0.26,0.94 -0.27,1.43 0,0.5 0.03,0.99 -0.04,1.48 -0.04,0.33 -0.13,0.66 -0.14,0.99 -0.01,0.17 0,0.34 0.04,0.5 0.05,0.16 0.13,0.32 0.24,0.44 0.15,0.16 0.35,0.26 0.56,0.32 0.21,0.06 0.42,0.09 0.64,0.14 1.01,0.24 1.89,0.86 2.66,1.56 0.77,0.69 1.47,1.48 2.28,2.13 2.18,1.78 5.07,2.52 7.89,2.56 2.82,0.05 5.61,-0.54 8.36,-1.16 2.16,-0.49 4.32,-0.99 6.39,-1.76 3.2,-1.18 6.16,-2.96 8.72,-5.19 1.17,-1.01 2.26,-2.12 3.57,-2.94 1.15,-0.73 2.44,-1.21 3.62,-1.9 0.11,-0.06 0.21,-0.13 0.3,-0.2 0.1,-0.08 0.18,-0.18 0.24,-0.28 0.09,-0.19 0.09,-0.42 0.03,-0.62 -0.06,-0.2 -0.18,-0.38 -0.31,-0.55 -0.15,-0.18 -0.31,-0.34 -0.49,-0.5 -1.23,-1.05 -2.89,-1.43 -4.51,-1.56 -1.61,-0.12 -3.24,-0.03 -4.83,-0.3 -1.5,-0.25 -2.92,-0.81 -4.37,-1.27 -1.52,-0.49 -3.07,-0.87 -4.64,-1.13 -3.71,-0.61 -7.52,-0.49 -11.19,0.27 -3.49,0.73 -6.87,2.05 -9.94,3.87 z"/>
|
||||
<path id="mandible_upper_base" fill="url(#fill_mandible_upper_base)"
|
||||
d="m 83.94,63.95 c -1.66,1.12 -3.16,2.49 -4.43,4.04 -0.72,0.89 -1.38,1.86 -1.74,2.94 -0.29,0.86 -0.39,1.76 -0.57,2.65 -0.07,0.33 -0.15,0.66 -0.14,1 0,0.16 0.02,0.33 0.07,0.5 0.05,0.16 0.14,0.31 0.25,0.43 0.2,0.2 0.47,0.31 0.74,0.37 0.28,0.05 0.56,0.06 0.84,0.09 1.25,0.15 2.4,0.75 3.44,1.47 1.04,0.71 2,1.55 3.07,2.22 2.35,1.49 5.16,2.15 7.95,2.26 2.78,0.11 5.56,-0.31 8.3,-0.86 2.17,-0.43 4.33,-0.95 6.39,-1.76 3.16,-1.25 6.01,-3.16 8.72,-5.19 1.24,-0.92 2.46,-1.87 3.57,-2.94 0.37,-0.37 0.74,-0.74 1.14,-1.08 0.4,-0.33 0.85,-0.62 1.35,-0.78 0.76,-0.24 1.58,-0.17 2.37,-0.04 0.59,0.1 1.18,0.23 1.78,0.21 0.3,-0.02 0.6,-0.07 0.88,-0.18 0.28,-0.11 0.54,-0.28 0.73,-0.52 0.25,-0.3 0.38,-0.7 0.38,-1.09 0,-0.4 -0.12,-0.79 -0.32,-1.13 -0.4,-0.68 -1.09,-1.14 -1.81,-1.46 -0.99,-0.44 -2.06,-0.65 -3.11,-0.91 -3.23,-0.78 -6.37,-1.93 -9.34,-3.41 -1.48,-0.73 -2.92,-1.54 -4.37,-2.32 -1.5,-0.8 -3.02,-1.57 -4.64,-2.07 -3.64,-1.1 -7.6,-0.74 -11.19,0.51 -3.98,1.38 -7.58,3.84 -10.31,7.05 z"/>
|
||||
<path id="mandible_upper_glare" fill="#f6da4a" filter="url(#blur_mandible_upper_glare)" clip-path="url(#clip_mandible_upper)"
|
||||
d="m 109.45,64.75 c -0.2,-0.24 -0.48,-0.42 -0.78,-0.51 -0.3,-0.09 -0.62,-0.09 -0.93,-0.04 -0.62,0.11 -1.18,0.44 -1.7,0.8 -1.47,1.01 -2.77,2.26 -3.91,3.64 -1.5,1.83 -2.74,3.94 -3.16,6.27 -0.07,0.39 -0.11,0.8 -0.07,1.19 0.05,0.4 0.2,0.79 0.49,1.07 0.24,0.25 0.58,0.4 0.92,0.45 0.35,0.05 0.71,0 1.04,-0.11 0.66,-0.22 1.21,-0.69 1.74,-1.15 2.87,-2.58 5.47,-5.66 6.51,-9.38 0.1,-0.37 0.19,-0.75 0.19,-1.14 0,-0.39 -0.1,-0.78 -0.34,-1.09 z"/>
|
||||
<path id="naris_left" opacity="0.8" fill="url(#fill_naris_left)" filter="url(#blur_naris_left)"
|
||||
d="m 92.72,59.06 c -0.77,-0.25 -2.03,1.1 -1.62,1.79 0.11,0.19 0.46,0.43 0.7,0.3 0.35,-0.19 0.64,-0.89 1.02,-1.16 0.25,-0.18 0.2,-0.84 -0.1,-0.93 z"/>
|
||||
<path id="naris_right" opacity="0.8" fill="url(#fill_naris_right)" filter="url(#blur_naris_right)"
|
||||
d="m 102.56,59.42 c 0.2,0.64 1.23,0.53 1.83,0.84 0.52,0.27 0.94,0.86 1.53,0.88 0.56,0.01 1.44,-0.2 1.51,-0.76 0.09,-0.73 -0.98,-1.2 -1.67,-1.47 -0.89,-0.34 -2.03,-0.52 -2.86,-0.06 -0.19,0.11 -0.4,0.36 -0.34,0.57 z"/>
|
||||
</g>
|
||||
<path id="beak_corner" fill="url(#fill_beak_corner)" filter="url(#blur_beak_corner)" clip-path="url(#clip_beak)"
|
||||
d="m 129.27,69.15 a 2.42,3.1 16.94 0 1 -2.81,3.04 2.42,3.1 16.94 0 1 -2.12,-3.04 2.42,3.1 16.94 0 1 2.81,-3.05 2.42,3.1 16.94 0 1 2.12,3.05 z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 49 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 225">
|
||||
<rect width="400" height="225" fill="#E6522C"/>
|
||||
<text x="200" y="112" font-family="system-ui, sans-serif" font-size="24" font-weight="bold" fill="white" text-anchor="middle">Prometheus & Grafana</text>
|
||||
<text x="200" y="140" font-family="system-ui, sans-serif" font-size="14" fill="rgba(255,255,255,0.8)" text-anchor="middle">Course Thumbnail</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 432 B |