first commit
Some checks failed
CI / Validate frontend and API (push) Failing after 7m27s

This commit is contained in:
Cauê Faleiros
2026-08-31 12:38:34 -03:00
parent 684f929f28
commit 701788ea08
40 changed files with 5396 additions and 380 deletions

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
dist
.git
.env
npm-debug.log
*.local

16
.env.example Normal file
View File

@@ -0,0 +1,16 @@
# Frontend (Vite): only values that are safe to expose in the browser belong here.
VITE_API_URL=http://localhost:3001
# API and PostgreSQL
APP_ENV=development
API_PORT=3001
DATABASE_URL=postgres://compor:compor_local_password@localhost:5433/compor_hub
FRONTEND_ORIGIN=http://localhost:3000
# Required in production. The development fallback must never be used outside localhost.
JWT_SECRET=replace-this-with-a-long-random-secret-before-deploying
# Run `npm run db:bootstrap-admin` after setting these values to create the first admin.
BOOTSTRAP_ADMIN_EMAIL=admin@example.com
BOOTSTRAP_ADMIN_PASSWORD=change-this-password
BOOTSTRAP_ADMIN_NAME=Compor HUB Admin

29
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,29 @@
name: CI
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
validate:
name: Validate frontend and API
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- run: npm ci --include=dev
- run: npm run typecheck
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: frontend-build
path: dist
if-no-files-found: error

1
.gitignore vendored
View File

@@ -11,6 +11,7 @@ node_modules
dist
dist-ssr
*.local
.env
# Editor directories and files
.vscode/*

View File

@@ -65,9 +65,9 @@ function AppContent() {
<Route
path="/materiais"
element={
<MaterialsPage
onPlayCourse={setSelectedCourse}
/>
<ProtectedRoute>
<MaterialsPage onPlayCourse={setSelectedCourse} />
</ProtectedRoute>
}
/>

35
BACKEND.md Normal file
View File

@@ -0,0 +1,35 @@
# Backend foundation
The project now has a small API and a PostgreSQL schema. Video delivery is provider-neutral: `lesson_media.provider` and `lesson_media.external_id` describe an external provider without coupling lessons to Panda, Vimeo, or any other service.
## Local setup
1. Copy `.env.example` to `.env` and use the default local values.
2. Start PostgreSQL with `docker compose up -d postgres`.
3. Install dependencies with `npm install`.
4. Apply the versioned schema with `npm run db:migrate`.
5. Run the API with `npm run dev:api` and the frontend with `npm run dev`.
The API health endpoint is available at `http://localhost:3001/api/v1/health`. The readiness endpoint verifies PostgreSQL at `/api/v1/ready`.
## Current API
- `GET /api/v1/courses` returns published courses.
- `GET /api/v1/courses/:courseId` returns one published course.
## Accounts and instructor access
- `POST /api/v1/auth/register` creates student accounts only.
- `POST /api/v1/auth/login` creates a seven-day signed session.
- `GET /api/v1/auth/me` restores an existing session.
- `GET`, `POST`, `PATCH`, and `DELETE` under `/api/v1/manage/courses` require an instructor or administrator session. Instructors can manage only their own courses.
To create the first local administrator, set `BOOTSTRAP_ADMIN_EMAIL`, `BOOTSTRAP_ADMIN_PASSWORD`, and optionally `BOOTSTRAP_ADMIN_NAME`, then run `npm run db:bootstrap-admin`. This command is deliberate and must not be exposed through the public API.
For local demos, `npm run db:seed-demo-content` imports the original frontend catalogue into PostgreSQL. It requires the bootstrap administrator to exist and skips courses already present.
Public course endpoints return only public lessons and public assets. Enrolment checks, learner progress, and comments will be added in the next milestone.
## CI/CD status
GitHub Actions validates every pull request and every push to `main` by installing locked dependencies, type-checking the frontend and API, building the frontend, and uploading the production build as an artifact. Deployment remains intentionally unconfigured until the frontend and API hosting providers are chosen.

13
Dockerfile.api Normal file
View File

@@ -0,0 +1,13 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --include=dev
COPY server ./server
COPY constants.ts types.ts ./
ENV APP_ENV=production
EXPOSE 3001
CMD ["sh", "-c", "npm run db:migrate && npm run start:api"]

14
Dockerfile.web Normal file
View File

@@ -0,0 +1,14 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --include=dev
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80

24
PORTAINER.md Normal file
View File

@@ -0,0 +1,24 @@
# Portainer deployment
Use `docker-compose.portainer.yml` as a Portainer Stack from this repository. It deploys the frontend, API, and PostgreSQL as one internal Docker network. Only the web container exposes a port; it proxies `/api` to the API container.
## Required Portainer environment variables
- `POSTGRES_PASSWORD`: a long, unique database password. Avoid characters that are not URL-safe because it is used in `DATABASE_URL`.
- `JWT_SECRET`: a unique random string of at least 32 characters.
- `FRONTEND_ORIGIN`: the exact public application URL, for example `https://hub.example.com`.
Optional variables:
- `POSTGRES_DB` (default `compor_hub`)
- `POSTGRES_USER` (default `compor`)
- `WEB_PORT` (default `8080`)
## Before publishing
1. Deploy the stack with a temporary `WEB_PORT` and verify `/api/v1/health` through the public domain.
2. Create the production administrator using the API container's console and `npm run db:bootstrap-admin`, with the `BOOTSTRAP_ADMIN_*` variables supplied only for that one command.
3. Place the web service behind HTTPS, normally through your existing reverse proxy (Traefik, Nginx Proxy Manager, or Cloudflare Tunnel), and set `FRONTEND_ORIGIN` to that HTTPS address.
4. Back up the `compor_postgres_data` volume before updates.
Do not expose port 5432 or port 3001 publicly.

View File

@@ -1,7 +1,8 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { CourseRow } from './CourseRow';
import { Section, Course } from '../types';
import { initDB, getCourses } from '../services/db';
import { getCourses } from '../services/db';
import { useAuth } from '../context/AuthContext';
import { Loader2, ChevronLeft, ChevronRight } from 'lucide-react';
import { CATEGORIES } from '../constants';
@@ -17,6 +18,7 @@ export const CourseGrid: React.FC<CourseGridProps> = ({
const [courses, setCourses] = useState<Course[]>([]);
const [loading, setLoading] = useState(true);
const [activeCategory, setActiveCategory] = useState<string>('all');
const { user } = useAuth();
// Slider State & Refs for Categories
const categoryScrollRef = useRef<HTMLDivElement>(null);
@@ -37,14 +39,15 @@ export const CourseGrid: React.FC<CourseGridProps> = ({
useEffect(() => {
const loadData = async () => {
await initDB();
const allCourses = await getCourses();
setCourses(allCourses);
setLoading(false);
try {
setCourses(await getCourses());
} finally {
setLoading(false);
}
};
loadData();
}, []);
}, [user]);
useEffect(() => {
checkCategoryBoundaries();
@@ -225,4 +228,3 @@ export const CourseGrid: React.FC<CourseGridProps> = ({
</section>
);
};

View File

@@ -107,7 +107,7 @@ export const CoursePlayerModal: React.FC<CoursePlayerModalProps> = ({ course, on
const currentIndex = course.lessons.findIndex(l => l.id === currentLesson.id);
if (currentIndex >= 0 && currentIndex < course.lessons.length - 1) {
const nextLesson = course.lessons[currentIndex + 1];
if (nextLesson.isFree || user) {
if (user) {
setTimeout(() => {
setCurrentLesson(nextLesson);
}, 1000);
@@ -181,7 +181,7 @@ export const CoursePlayerModal: React.FC<CoursePlayerModalProps> = ({ course, on
const currentLessonAttachments = currentLesson?.attachments || [];
// Access Control Logic
const canWatch = currentLesson && (currentLesson.isFree || user);
const canWatch = currentLesson && Boolean(user);
if (!course) return null;
@@ -398,7 +398,7 @@ export const CoursePlayerModal: React.FC<CoursePlayerModalProps> = ({ course, on
{course.lessons.map((lesson, index) => {
const isActive = currentLesson?.id === lesson.id;
const isCompleted = completedLessonIds.includes(lesson.id);
const isLocked = !lesson.isFree && !user;
const isLocked = !user;
const hasAttachments = lesson.attachments && lesson.attachments.length > 0;
return (

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { X, Loader2, GraduationCap, School, KeyRound } from 'lucide-react';
import { X, Loader2, GraduationCap, School, KeyRound, UserPlus } from 'lucide-react';
import { useAuth } from '../context/AuthContext';
import { useNavigate } from 'react-router-dom';
import { UserRole } from '../types';
@@ -12,62 +12,70 @@ interface LoginModalProps {
export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose }) => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [role, setRole] = useState<UserRole>('student'); // Default role
const [name, setName] = useState('');
const [role, setRole] = useState<UserRole>('student');
const [isRegistering, setIsRegistering] = useState(false);
const [error, setError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const { login } = useAuth();
const { login, register, logout } = useAuth();
const navigate = useNavigate();
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setError('');
setIsSubmitting(true);
// Simulate network delay for "premium" feel
setTimeout(() => {
// In a real app, the backend verifies credentials AND role matches.
// Here we simulate checking if the user exists for that specific context.
const success = login(email);
if (isRegistering && role === 'professor') {
setIsSubmitting(false);
setError('Contas de instrutor são liberadas pelo administrador da plataforma.');
return;
}
if (success) {
onClose();
if (email === 'admin@prof.com') {
// Force check: if they selected student but logged in as admin credentials, redirect correctly or warn.
// For UX simplicity here, we trust the credential mapping in AuthContext
navigate('/gerenciar');
} else {
navigate('/');
}
} else {
setError('Credenciais inválidas. Verifique o email ou a função selecionada.');
}
}, 800);
const signedInUser = isRegistering
? await register(name, email, password)
: await login(email, password);
setIsSubmitting(false);
if (!signedInUser) {
setError(isRegistering
? 'Não foi possível criar sua conta. Use um e-mail válido e uma senha com pelo menos 12 caracteres.'
: 'Credenciais inválidas. Verifique seu e-mail e senha.');
return;
}
if (signedInUser.role !== role) {
logout();
setError(role === 'professor'
? 'Esta conta não possui acesso de instrutor.'
: 'Esta conta possui acesso de instrutor. Selecione a opção Instrutor.');
return;
}
onClose();
navigate(signedInUser.role === 'professor' ? '/gerenciar' : '/');
};
const handleDemoLogin = (roleType: UserRole) => {
const demoEmail = roleType === 'professor' ? 'admin@prof.com' : 'aluno@test.com';
setEmail(demoEmail);
setPassword('demo123');
setRole(roleType);
setIsRegistering(false);
if (roleType === 'professor') {
setEmail('admin@compor.local');
setPassword('ComporAdmin123!');
} else {
setEmail('student@compor.local');
setPassword('ComporStudent123!');
}
setError('');
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-xl transition-opacity"
onClick={onClose}
/>
<div className="absolute inset-0 bg-black/60 backdrop-blur-xl transition-opacity" onClick={onClose} />
{/* Modal Content */}
<div className="relative w-full max-w-md bg-[#1c1c1e] rounded-[8px] shadow-xl border border-white/10 p-6 md:p-8 animate-fade-in-up">
<button
onClick={onClose}
className="absolute top-5 right-5 text-white/40 hover:text-white transition-colors"
>
<button onClick={onClose} className="absolute top-5 right-5 text-white/40 hover:text-white transition-colors" aria-label="Fechar">
<X className="w-5 h-5" />
</button>
@@ -76,90 +84,47 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose }) => {
<p className="text-body-sm font-normal text-white/60 tracking-body-sm leading-body-sm">Acesse seu espaço de aprendizado e materiais</p>
</div>
{/* Role Toggle */}
<div className="flex bg-black/40 p-1 rounded-[8px] mb-6 border border-white/5 relative">
{/* Sliding Background */}
<div
className={`absolute top-1 bottom-1 w-[calc(50%-4px)] bg-orange-500/20 rounded-[8px] transition-all duration-300 ease-out border border-orange-500/30 shadow-md ${role === 'student' ? 'left-1' : 'left-[calc(50%+4px)]'}`}
/>
<button
type="button"
onClick={() => setRole('student')}
className={`flex-1 flex items-center justify-center gap-2 py-2.5 rounded-[8px] relative z-10 transition-colors duration-300 ${role === 'student' ? 'text-white font-semibold' : 'text-gray-400 hover:text-gray-300'}`}
>
<GraduationCap className="w-4 h-4" />
<span className="text-body-sm font-semibold tracking-body-sm">Aluno</span>
</button>
<button
type="button"
onClick={() => setRole('professor')}
className={`flex-1 flex items-center justify-center gap-2 py-2.5 rounded-[8px] relative z-10 transition-colors duration-300 ${role === 'professor' ? 'text-white font-semibold' : 'text-gray-400 hover:text-gray-300'}`}
>
<School className="w-4 h-4" />
<span className="text-body-sm font-semibold tracking-body-sm">Instrutor</span>
</button>
<div className={`absolute top-1 bottom-1 w-[calc(50%-4px)] bg-orange-500/20 rounded-[8px] transition-all duration-300 ease-out border border-orange-500/30 shadow-md ${role === 'student' ? 'left-1' : 'left-[calc(50%+4px)]'}`} />
<button type="button" onClick={() => setRole('student')} className={`flex-1 flex items-center justify-center gap-2 py-2.5 rounded-[8px] relative z-10 transition-colors duration-300 ${role === 'student' ? 'text-white font-semibold' : 'text-gray-400 hover:text-gray-300'}`}>
<GraduationCap className="w-4 h-4" />
<span className="text-body-sm font-semibold tracking-body-sm">Aluno</span>
</button>
<button type="button" onClick={() => setRole('professor')} className={`flex-1 flex items-center justify-center gap-2 py-2.5 rounded-[8px] relative z-10 transition-colors duration-300 ${role === 'professor' ? 'text-white font-semibold' : 'text-gray-400 hover:text-gray-300'}`}>
<School className="w-4 h-4" />
<span className="text-body-sm font-semibold tracking-body-sm">Instrutor</span>
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-3">
<div>
<input
type="email"
placeholder="Seu e-mail"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full bg-white/10 text-white placeholder:text-white/30 px-4 py-3 rounded-[8px] focus:outline-none focus:ring-2 focus:ring-orange-500/50 focus:bg-white/15 transition-all text-body tracking-body"
autoFocus
/>
</div>
<div>
<input
type="password"
placeholder="Sua senha"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-white/10 text-white placeholder:text-white/30 px-4 py-3 rounded-[8px] focus:outline-none focus:ring-2 focus:ring-orange-500/50 focus:bg-white/15 transition-all text-body tracking-body"
/>
</div>
{isRegistering && <input type="text" placeholder="Seu nome" value={name} onChange={(event) => setName(event.target.value)} className="w-full bg-white/10 text-white placeholder:text-white/30 px-4 py-3 rounded-[8px] focus:outline-none focus:ring-2 focus:ring-orange-500/50 focus:bg-white/15 transition-all text-body tracking-body" required autoFocus />}
<input type="email" placeholder="Seu e-mail" value={email} onChange={(event) => setEmail(event.target.value)} className="w-full bg-white/10 text-white placeholder:text-white/30 px-4 py-3 rounded-[8px] focus:outline-none focus:ring-2 focus:ring-orange-500/50 focus:bg-white/15 transition-all text-body tracking-body" required autoFocus={!isRegistering} />
<input type="password" placeholder="Sua senha" value={password} onChange={(event) => setPassword(event.target.value)} minLength={12} className="w-full bg-white/10 text-white placeholder:text-white/30 px-4 py-3 rounded-[8px] focus:outline-none focus:ring-2 focus:ring-orange-500/50 focus:bg-white/15 transition-all text-body tracking-body" required />
</div>
{/* Quick Demo Buttons */}
<div className="flex gap-2 justify-center">
<button
type="button"
onClick={() => handleDemoLogin('student')}
className="text-caption font-semibold tracking-caption text-orange-400 hover:text-orange-300 px-3 py-1 rounded-[980px] bg-orange-500/10 border border-orange-500/20 flex items-center gap-1 transition-colors"
>
{!isRegistering && (
<div className="flex gap-2 justify-center">
<button type="button" onClick={() => handleDemoLogin('student')} className="text-caption font-semibold tracking-caption text-orange-400 hover:text-orange-300 px-3 py-1 rounded-[980px] bg-orange-500/10 border border-orange-500/20 flex items-center gap-1 transition-colors">
<KeyRound className="w-3 h-3" /> Acesso Aluno (Demo)
</button>
<button
type="button"
onClick={() => handleDemoLogin('professor')}
className="text-caption font-semibold tracking-caption text-emerald-400 hover:text-emerald-300 px-3 py-1 rounded-[980px] bg-emerald-500/10 border border-emerald-500/20 flex items-center gap-1 transition-colors"
>
</button>
<button type="button" onClick={() => handleDemoLogin('professor')} className="text-caption font-semibold tracking-caption text-emerald-400 hover:text-emerald-300 px-3 py-1 rounded-[980px] bg-emerald-500/10 border border-emerald-500/20 flex items-center gap-1 transition-colors">
<KeyRound className="w-3 h-3" /> Acesso Instrutor (Demo)
</button>
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/20 rounded-[8px] p-3">
<p className="text-red-400 text-caption font-semibold tracking-caption text-center">{error}</p>
</button>
</div>
)}
<button
type="submit"
disabled={isSubmitting}
className="w-full bg-white text-black text-body-sm font-semibold tracking-body-sm py-3.5 rounded-[980px] hover:bg-gray-200 active:scale-95 transition-all duration-200 flex items-center justify-center gap-2 shadow-xl"
>
{isSubmitting ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
'Entrar'
)}
{error && <div className="bg-red-500/10 border border-red-500/20 rounded-[8px] p-3"><p className="text-red-400 text-caption font-semibold tracking-caption text-center">{error}</p></div>}
<button type="submit" disabled={isSubmitting} className="w-full bg-white text-black text-body-sm font-semibold tracking-body-sm py-3.5 rounded-[980px] hover:bg-gray-200 active:scale-95 transition-all duration-200 flex items-center justify-center gap-2 shadow-xl">
{isSubmitting ? <Loader2 className="w-5 h-5 animate-spin" /> : isRegistering ? <UserPlus className="w-5 h-5" /> : null}
{isRegistering ? 'Criar conta' : 'Entrar'}
</button>
</form>
<button type="button" onClick={() => { setIsRegistering((value) => !value); setError(''); }} className="w-full mt-5 text-xs text-orange-400 hover:text-orange-300 transition-colors">
{isRegistering ? 'Já possui conta? Entrar' : 'Não possui conta? Criar acesso de aluno'}
</button>
</div>
</div>
);

View File

@@ -1,9 +1,11 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { User, UserRole } from '../types';
import { authApi, clearSession, getSession, saveSession } from '../services/api';
interface AuthContextType {
user: User | null;
login: (email: string) => boolean;
login: (email: string, password: string) => Promise<User | null>;
register: (name: string, email: string, password: string) => Promise<User | null>;
logout: () => void;
isLoading: boolean;
}
@@ -15,51 +17,69 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// Check localStorage on mount with error handling
const storedUser = localStorage.getItem('educlean_user');
if (storedUser) {
try {
const parsedUser = JSON.parse(storedUser);
if (parsedUser && parsedUser.email) {
setUser(parsedUser);
}
} catch (error) {
console.error("Failed to parse user session, clearing storage:", error);
localStorage.removeItem('educlean_user');
const restoreSession = async () => {
const session = getSession();
if (!session) {
setIsLoading(false);
return;
}
}
setIsLoading(false);
try {
const { user: apiUser } = await authApi.me();
const restoredUser = toFrontendUser(apiUser);
saveSession({ ...session, user: apiUser });
setUser(restoredUser);
} catch {
clearSession();
} finally {
setIsLoading(false);
}
};
void restoreSession();
}, []);
const login = (email: string): boolean => {
let newUser: User | null = null;
if (email === 'admin@prof.com') {
newUser = { email, name: 'Prof. Anderson', role: 'professor' };
} else if (email === 'aluno@test.com') {
newUser = { email, name: 'Aluno Demo', role: 'student' };
const login = async (email: string, password: string): Promise<User | null> => {
try {
const session = await authApi.login(email, password);
const signedInUser = toFrontendUser(session.user);
saveSession(session);
setUser(signedInUser);
return signedInUser;
} catch {
return null;
}
};
if (newUser) {
setUser(newUser);
localStorage.setItem('educlean_user', JSON.stringify(newUser));
return true;
const register = async (name: string, email: string, password: string): Promise<User | null> => {
try {
const session = await authApi.register(name, email, password);
const signedInUser = toFrontendUser(session.user);
saveSession(session);
setUser(signedInUser);
return signedInUser;
} catch {
return null;
}
return false;
};
const logout = () => {
setUser(null);
localStorage.removeItem('educlean_user');
clearSession();
};
return (
<AuthContext.Provider value={{ user, login, logout, isLoading }}>
<AuthContext.Provider value={{ user, login, register, logout, isLoading }}>
{children}
</AuthContext.Provider>
);
};
const toFrontendUser = (user: { email: string; name: string; role: string }): User => ({
email: user.email,
name: user.name,
role: user.role === 'student' ? 'student' : 'professor',
});
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {

View File

@@ -0,0 +1,43 @@
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-compor_hub}
POSTGRES_USER: ${POSTGRES_USER:-compor}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in Portainer}
volumes:
- compor_postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 10
api:
build:
context: .
dockerfile: Dockerfile.api
restart: unless-stopped
environment:
APP_ENV: production
API_PORT: 3001
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:?Set the public https URL in Portainer}
DATABASE_URL: postgresql://${POSTGRES_USER:-compor}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-compor_hub}
JWT_SECRET: ${JWT_SECRET:?Set a long random JWT_SECRET in Portainer}
depends_on:
postgres:
condition: service_healthy
web:
build:
context: .
dockerfile: Dockerfile.web
restart: unless-stopped
ports:
- "${WEB_PORT:-8080}:80"
depends_on:
- api
volumes:
compor_postgres_data:

19
docker-compose.yml Normal file
View File

@@ -0,0 +1,19 @@
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: compor_hub
POSTGRES_USER: compor
POSTGRES_PASSWORD: compor_local_password
ports:
- "5433:5432"
volumes:
- compor_postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U compor -d compor_hub"]
interval: 5s
timeout: 5s
retries: 10
volumes:
compor_postgres_data:

19
docker/nginx.conf Normal file
View File

@@ -0,0 +1,19 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://api:3001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}

3899
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,15 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"dev:api": "tsx watch server/src/index.ts",
"start:api": "tsx server/src/index.ts",
"db:migrate": "tsx server/src/db/migrate.ts",
"db:bootstrap-admin": "tsx server/src/db/bootstrap-admin.ts",
"db:seed-demo-content": "tsx server/src/db/seed-demo-content.ts",
"typecheck:client": "tsc --project tsconfig.json",
"typecheck:api": "tsc --project server/tsconfig.json",
"typecheck": "npm run typecheck:client && npm run typecheck:api"
},
"dependencies": {
"react": "18.2.0",
@@ -14,12 +22,20 @@
"react-router-dom": "6.22.3",
"lucide-react": "0.344.0",
"localforage": "1.10.0",
"@google/genai": "latest"
"@fastify/cors": "^11.0.0",
"@fastify/jwt": "^9.1.0",
"dotenv": "^16.6.1",
"fastify": "^5.3.2",
"fastify-plugin": "^5.0.1",
"pg": "^8.16.3",
"zod": "^3.24.4"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
"vite": "^6.2.0",
"@types/pg": "^8.15.4",
"tsx": "^4.19.4"
}
}

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect } from 'react';
import {
Plus,
Settings,
@@ -11,7 +11,6 @@ import {
Send,
Image as ImageIcon,
Video,
Sparkles,
Loader2,
FileText,
FileSpreadsheet,
@@ -23,14 +22,12 @@ import {
Check,
Lock,
Unlock,
UploadCloud,
Globe,
Download
} from 'lucide-react';
import { Course, Comment, Attachment, Lesson, AttachmentType } from '../types';
import { useToast } from '../context/ToastContext';
import { getCourses, saveCourse, deleteCourse, getComments, saveComment } from '../services/db';
import { generateStudentReply } from '../services/ai';
import { getManagedCourses, saveCourse, deleteCourse, getComments, saveComment } from '../services/db';
import { CATEGORIES } from '../constants';
import { getAttachmentIcon, getAttachmentBadge } from '../components/MaterialCard';
@@ -38,7 +35,7 @@ import { getAttachmentIcon, getAttachmentBadge } from '../components/MaterialCar
const CourseModal: React.FC<{
isOpen: boolean;
onClose: () => void;
onSave: (course: Course) => void;
onSave: (course: Course) => Promise<void>;
initialData?: Course;
}> = ({ isOpen, onClose, onSave, initialData }) => {
const [title, setTitle] = useState('');
@@ -55,10 +52,8 @@ const CourseModal: React.FC<{
const [newLessonTitle, setNewLessonTitle] = useState('');
const [newLessonDuration, setNewLessonDuration] = useState('');
const [newLessonVideoUrl, setNewLessonVideoUrl] = useState('');
const [newLessonProvider, setNewLessonProvider] = useState('external');
const [newLessonIsFree, setNewLessonIsFree] = useState(false);
const [videoSourceType, setVideoSourceType] = useState<'upload' | 'link'>('upload');
const [selectedFileName, setSelectedFileName] = useState('');
const lessonFileInputRef = useRef<HTMLInputElement>(null);
// Tips & Global Attachments
const [tips, setTips] = useState<string[]>([]);
@@ -73,7 +68,6 @@ const CourseModal: React.FC<{
const [newAttDesc, setNewAttDesc] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isOpen) {
@@ -101,31 +95,13 @@ const CourseModal: React.FC<{
}
}, [isOpen, initialData]);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const objectUrl = URL.createObjectURL(file);
setThumbnailPreview(objectUrl);
}
};
const handleLessonVideoFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const objectUrl = URL.createObjectURL(file);
setNewLessonVideoUrl(objectUrl);
setSelectedFileName(file.name);
if (!newLessonDuration) setNewLessonDuration('15m');
}
};
const handleAddLesson = () => {
if (!newLessonTitle.trim()) {
alert("Digite um título para a aula.");
return;
}
if (!newLessonVideoUrl.trim()) {
alert("Selecione um vídeo ou insira um link.");
alert("Enter a hosted video URL.");
return;
}
@@ -134,6 +110,7 @@ const CourseModal: React.FC<{
title: newLessonTitle,
duration: newLessonDuration || '15m',
videoUrl: newLessonVideoUrl,
mediaProvider: newLessonProvider || 'external',
isFree: newLessonIsFree,
description: ''
};
@@ -143,9 +120,8 @@ const CourseModal: React.FC<{
setNewLessonTitle('');
setNewLessonDuration('');
setNewLessonVideoUrl('');
setSelectedFileName('');
setNewLessonProvider('external');
setNewLessonIsFree(false);
if (lessonFileInputRef.current) lessonFileInputRef.current.value = '';
};
const removeLesson = (id: string) => {
@@ -193,7 +169,7 @@ const CourseModal: React.FC<{
setAttachments(attachments.filter(a => a.id !== id));
};
const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (lessons.length === 0) {
alert("Adicione pelo menos uma aula à trilha.");
@@ -204,24 +180,28 @@ const CourseModal: React.FC<{
const durationStr = `${lessons.length} Aulas`;
onSave({
id: initialData?.id || `course-${Date.now()}`,
title,
description,
category: category || CATEGORIES[0],
thumbnail: thumbnailPreview || 'https://images.unsplash.com/photo-1460925895917-afdab827c52f?auto=format&fit=crop&w=800&q=80',
duration: durationStr,
instructor,
instructorRole,
views: initialData?.views || 0,
progress: initialData?.progress || 0,
lessons: lessons,
tips: tips,
attachments: attachments
});
setIsSubmitting(false);
onClose();
try {
await onSave({
id: initialData?.id || `course-${Date.now()}`,
title,
description,
category: category || CATEGORIES[0],
thumbnail: thumbnailPreview || 'https://images.unsplash.com/photo-1460925895917-afdab827c52f?auto=format&fit=crop&w=800&q=80',
duration: durationStr,
instructor,
instructorRole,
views: initialData?.views || 0,
progress: initialData?.progress || 0,
lessons: lessons,
tips: tips,
attachments: attachments
});
onClose();
} catch {
alert('We could not save this course. Confirm that you are still signed in and that all asset URLs are valid.');
} finally {
setIsSubmitting(false);
}
};
if (!isOpen) return null;
@@ -243,7 +223,6 @@ const CourseModal: React.FC<{
<label className="text-xs text-gray-400 uppercase font-bold tracking-wider mb-2 block">Imagem de Capa (16:9)</label>
<div
className="w-full aspect-video rounded-2xl border-2 border-dashed border-white/10 bg-black/40 flex flex-col items-center justify-center cursor-pointer hover:border-orange-500/50 transition-colors relative overflow-hidden group"
onClick={() => fileInputRef.current?.click()}
>
{thumbnailPreview ? (
<>
@@ -255,17 +234,17 @@ const CourseModal: React.FC<{
) : (
<div className="text-center p-6">
<ImageIcon className="w-10 h-10 text-orange-400/60 mx-auto mb-2" />
<p className="text-gray-300 text-xs font-medium">Clique para selecionar imagem</p>
<p className="text-gray-300 text-xs font-medium">Add a hosted cover image URL below</p>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileChange}
/>
</div>
<input
type="url"
value={thumbnailPreview}
onChange={(event) => setThumbnailPreview(event.target.value)}
placeholder="Hosted cover image URL"
className="w-full mt-2 bg-black/50 text-white text-xs border border-white/10 rounded-xl px-4 py-3 focus:outline-none focus:border-orange-500/50"
/>
</div>
<div className="space-y-4">
@@ -492,52 +471,21 @@ const CourseModal: React.FC<{
/>
</div>
{/* Video Source Tabs */}
<div className="flex gap-2 items-center">
<div className="flex bg-white/5 p-0.5 rounded-lg border border-white/5">
<button
type="button"
onClick={() => setVideoSourceType('upload')}
className={`px-3 py-1 rounded-md text-xs font-bold transition-all ${videoSourceType === 'upload' ? 'bg-orange-500 text-white' : 'text-gray-400'}`}
>
Vídeo MP4
</button>
<button
type="button"
onClick={() => setVideoSourceType('link')}
className={`px-3 py-1 rounded-md text-xs font-bold transition-all ${videoSourceType === 'link' ? 'bg-orange-500 text-white' : 'text-gray-400'}`}
>
URL Externa
</button>
</div>
<div className="flex-1">
{videoSourceType === 'upload' ? (
<div
onClick={() => lessonFileInputRef.current?.click()}
className="border border-dashed border-white/20 hover:border-orange-500/50 rounded-xl py-2 px-3 text-center cursor-pointer transition-all bg-white/[0.02]"
>
<input
ref={lessonFileInputRef}
type="file"
accept="video/*"
className="hidden"
onChange={handleLessonVideoFileSelect}
/>
<span className="text-xs text-gray-300 truncate block">
{selectedFileName ? `Arquivo: ${selectedFileName}` : 'Selecionar arquivo de vídeo'}
</span>
</div>
) : (
<input
type="text"
value={newLessonVideoUrl}
onChange={(e) => setNewLessonVideoUrl(e.target.value)}
placeholder="https://commondatastorage.googleapis.com/...mp4"
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-xs text-white focus:outline-none"
/>
)}
</div>
<input
type="text"
value={newLessonProvider}
onChange={(event) => setNewLessonProvider(event.target.value)}
placeholder="Provider (external, Panda, Vimeo...)"
className="w-40 bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-xs text-white focus:outline-none"
/>
<input
type="url"
value={newLessonVideoUrl}
onChange={(event) => setNewLessonVideoUrl(event.target.value)}
placeholder="Hosted video URL"
className="flex-1 bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-xs text-white focus:outline-none"
/>
<button
type="button"
@@ -577,13 +525,12 @@ export const ManageCourses: React.FC = () => {
const [allComments, setAllComments] = useState<{comment: Comment, courseTitle: string}[]>([]);
const [replyingTo, setReplyingTo] = useState<string | null>(null);
const [replyText, setReplyText] = useState('');
const [isGeneratingReply, setIsGeneratingReply] = useState(false);
const { showToast } = useToast();
const loadData = async () => {
setLoading(true);
const data = await getCourses();
const data = await getManagedCourses();
setCourses(data);
// Load all comments
@@ -644,18 +591,6 @@ export const ManageCourses: React.FC = () => {
}
};
const handleGenerateAIReply = async (commentText: string, courseTitle: string) => {
setIsGeneratingReply(true);
try {
const aiReply = await generateStudentReply(commentText, courseTitle);
setReplyText(aiReply);
} catch (e) {
showToast('Erro ao gerar resposta com IA.', 'error');
} finally {
setIsGeneratingReply(false);
}
};
// Metrics
const totalViews = courses.reduce((acc, c) => acc + (c.views || 0), 0);
const totalLessons = courses.reduce((acc, c) => acc + c.lessons.length, 0);
@@ -850,18 +785,7 @@ export const ManageCourses: React.FC = () => {
className="w-full bg-black/60 border border-white/10 rounded-[8px] p-3 text-body-sm text-white focus:outline-none focus:border-orange-500/50 resize-none"
rows={3}
/>
<div className="flex items-center justify-between">
<button
type="button"
onClick={() => handleGenerateAIReply(comment.text, courseTitle)}
disabled={isGeneratingReply}
className="inline-flex items-center gap-1.5 text-body-sm text-orange-400 hover:text-orange-300 font-semibold"
>
<Sparkles className="w-3.5 h-3.5" />
{isGeneratingReply ? 'Gerando com IA...' : 'Sugerir Resposta com IA'}
</button>
<div className="flex gap-2">
<div className="flex justify-end gap-2">
<button
onClick={() => { setReplyingTo(null); setReplyText(''); }}
className="px-3.5 py-2 rounded-[980px] text-body-sm text-gray-400 hover:text-white font-semibold"
@@ -876,7 +800,6 @@ export const ManageCourses: React.FC = () => {
</button>
</div>
</div>
</div>
) : (
<button
onClick={() => setReplyingTo(comment.id)}

View File

@@ -0,0 +1,140 @@
create extension if not exists pgcrypto;
create type user_role as enum ('student', 'instructor', 'admin');
create type course_status as enum ('draft', 'published', 'archived');
create type lesson_access_level as enum ('public', 'enrolled');
create type media_status as enum ('processing', 'ready', 'failed');
create type asset_kind as enum ('document', 'spreadsheet', 'archive', 'image', 'link');
create table users (
id uuid primary key default gen_random_uuid(),
email text not null unique,
password_hash text not null,
display_name text not null,
role user_role not null default 'student',
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table courses (
id uuid primary key default gen_random_uuid(),
slug text not null unique,
title text not null,
description text not null default '',
category text not null,
cover_image_url text,
status course_status not null default 'draft',
instructor_id uuid not null references users(id),
published_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint courses_published_at_check check (
(status = 'published' and published_at is not null) or status <> 'published'
)
);
create table lessons (
id uuid primary key default gen_random_uuid(),
course_id uuid not null references courses(id) on delete cascade,
title text not null,
description text not null default '',
position integer not null check (position > 0),
duration_seconds integer check (duration_seconds is null or duration_seconds >= 0),
access_level lesson_access_level not null default 'enrolled',
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (course_id, position)
);
-- Video delivery is intentionally provider-neutral. A lesson can be moved from
-- one provider to another without changing the lesson itself.
create table lesson_media (
id uuid primary key default gen_random_uuid(),
lesson_id uuid not null references lessons(id) on delete cascade,
provider text not null,
external_id text not null,
playback_url text,
embed_url text,
status media_status not null default 'processing',
duration_seconds integer check (duration_seconds is null or duration_seconds >= 0),
metadata jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (provider, external_id)
);
create table course_enrollments (
id uuid primary key default gen_random_uuid(),
course_id uuid not null references courses(id) on delete cascade,
user_id uuid not null references users(id) on delete cascade,
enrolled_at timestamptz not null default now(),
revoked_at timestamptz,
unique (course_id, user_id)
);
create table lesson_progress (
lesson_id uuid not null references lessons(id) on delete cascade,
user_id uuid not null references users(id) on delete cascade,
watched_seconds integer not null default 0 check (watched_seconds >= 0),
completed_at timestamptz,
updated_at timestamptz not null default now(),
primary key (lesson_id, user_id)
);
create table assets (
id uuid primary key default gen_random_uuid(),
course_id uuid references courses(id) on delete cascade,
lesson_id uuid references lessons(id) on delete cascade,
name text not null,
kind asset_kind not null,
url text not null,
size_bytes bigint check (size_bytes is null or size_bytes >= 0),
description text not null default '',
access_level lesson_access_level not null default 'enrolled',
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint assets_parent_check check (
(course_id is not null and lesson_id is null) or (course_id is null and lesson_id is not null)
)
);
create table comments (
id uuid primary key default gen_random_uuid(),
course_id uuid not null references courses(id) on delete cascade,
lesson_id uuid references lessons(id) on delete set null,
author_id uuid not null references users(id),
body text not null check (char_length(body) between 1 and 4000),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table comment_replies (
id uuid primary key default gen_random_uuid(),
comment_id uuid not null unique references comments(id) on delete cascade,
author_id uuid not null references users(id),
body text not null check (char_length(body) between 1 and 4000),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index courses_published_index on courses (published_at desc) where status = 'published';
create index lessons_course_position_index on lessons (course_id, position);
create index lesson_media_lesson_index on lesson_media (lesson_id);
create index enrollments_active_index on course_enrollments (user_id, course_id) where revoked_at is null;
create index comments_course_created_index on comments (course_id, created_at desc);
create function set_updated_at() returns trigger language plpgsql as $$
begin
new.updated_at = now();
return new;
end;
$$;
create trigger users_set_updated_at before update on users for each row execute function set_updated_at();
create trigger courses_set_updated_at before update on courses for each row execute function set_updated_at();
create trigger lessons_set_updated_at before update on lessons for each row execute function set_updated_at();
create trigger lesson_media_set_updated_at before update on lesson_media for each row execute function set_updated_at();
create trigger lesson_progress_set_updated_at before update on lesson_progress for each row execute function set_updated_at();
create trigger assets_set_updated_at before update on assets for each row execute function set_updated_at();
create trigger comments_set_updated_at before update on comments for each row execute function set_updated_at();
create trigger comment_replies_set_updated_at before update on comment_replies for each row execute function set_updated_at();

View File

@@ -0,0 +1,2 @@
alter table lesson_media drop constraint lesson_media_provider_external_id_key;
alter table lesson_media add constraint lesson_media_lesson_provider_external_id_key unique (lesson_id, provider, external_id);

43
server/src/app.ts Normal file
View File

@@ -0,0 +1,43 @@
import cors from '@fastify/cors';
import Fastify from 'fastify';
import { ZodError } from 'zod';
import { authPlugin } from './auth/plugin.js';
import { config } from './config.js';
import { pool } from './db/pool.js';
import { authRoutes } from './routes/auth.js';
import { courseRoutes } from './routes/courses.js';
import { manageCourseRoutes } from './routes/manage-courses.js';
export function buildApp() {
const app = Fastify({ logger: true });
app.register(cors, {
origin: config.FRONTEND_ORIGIN,
methods: ['GET', 'POST', 'PATCH', 'DELETE'],
});
app.setErrorHandler((error, _request, reply) => {
if (error instanceof ZodError) {
return reply.code(400).send({ error: 'Invalid request', details: error.flatten() });
}
app.log.error(error);
return reply.code(500).send({ error: 'Internal server error' });
});
app.get('/api/v1/health', async () => ({ status: 'ok' }));
app.get('/api/v1/ready', async (_request, reply) => {
try {
await pool.query('select 1');
return { status: 'ready' };
} catch {
return reply.code(503).send({ status: 'unavailable' });
}
});
app.register(authPlugin);
app.register(authRoutes, { prefix: '/api/v1/auth' });
app.register(courseRoutes, { prefix: '/api/v1/courses' });
app.register(manageCourseRoutes, { prefix: '/api/v1/manage/courses' });
return app;
}

View File

@@ -0,0 +1,20 @@
import { randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'node:crypto';
import { promisify } from 'node:util';
const scrypt = promisify(scryptCallback);
const keyLength = 64;
export async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16).toString('hex');
const derivedKey = await scrypt(password, salt, keyLength) as Buffer;
return `scrypt$${salt}$${derivedKey.toString('hex')}`;
}
export async function verifyPassword(password: string, storedHash: string): Promise<boolean> {
const [algorithm, salt, encodedKey] = storedHash.split('$');
if (algorithm !== 'scrypt' || !salt || !encodedKey) return false;
const storedKey = Buffer.from(encodedKey, 'hex');
const derivedKey = await scrypt(password, salt, keyLength) as Buffer;
return storedKey.length === derivedKey.length && timingSafeEqual(storedKey, derivedKey);
}

53
server/src/auth/plugin.ts Normal file
View File

@@ -0,0 +1,53 @@
import fastifyJwt from '@fastify/jwt';
import type { FastifyPluginAsync, preHandlerHookHandler } from 'fastify';
import fastifyPlugin from 'fastify-plugin';
import { config } from '../config.js';
export type UserRole = 'student' | 'instructor' | 'admin';
export interface AuthUser {
id: string;
email: string;
role: UserRole;
name: string;
}
declare module '@fastify/jwt' {
interface FastifyJWT {
payload: AuthUser;
user: AuthUser;
}
}
declare module 'fastify' {
interface FastifyInstance {
authenticate: preHandlerHookHandler;
requireRoles: (roles: UserRole[]) => preHandlerHookHandler;
}
}
const registerAuth: FastifyPluginAsync = async (app) => {
await app.register(fastifyJwt, { secret: config.JWT_SECRET });
app.decorate('authenticate', async (request, reply) => {
try {
await request.jwtVerify();
} catch {
return reply.code(401).send({ error: 'Authentication required' });
}
});
app.decorate('requireRoles', (roles: UserRole[]) => async (request, reply) => {
try {
await request.jwtVerify();
} catch {
return reply.code(401).send({ error: 'Authentication required' });
}
if (!roles.includes(request.user.role)) {
return reply.code(403).send({ error: 'Insufficient permissions' });
}
});
};
export const authPlugin = fastifyPlugin(registerAuth, { name: 'auth' });

19
server/src/config.ts Normal file
View File

@@ -0,0 +1,19 @@
import 'dotenv/config';
import { z } from 'zod';
const environmentSchema = z.object({
API_PORT: z.coerce.number().int().positive().default(3001),
DATABASE_URL: z.string().url().default('postgres://compor:compor_local_password@localhost:5433/compor_hub'),
FRONTEND_ORIGIN: z.string().url().default('http://localhost:3000'),
APP_ENV: z.enum(['development', 'test', 'production']).default('development'),
JWT_SECRET: z.string().min(32).default('development-only-secret-change-before-production'),
BOOTSTRAP_ADMIN_EMAIL: z.string().email().optional(),
BOOTSTRAP_ADMIN_PASSWORD: z.string().min(12).optional(),
BOOTSTRAP_ADMIN_NAME: z.string().min(1).max(120).default('Compor HUB Admin'),
});
export const config = environmentSchema.parse(process.env);
if (config.APP_ENV === 'production' && config.JWT_SECRET === 'development-only-secret-change-before-production') {
throw new Error('JWT_SECRET must be set to a unique value in production.');
}

View File

@@ -0,0 +1,29 @@
import { hashPassword } from '../auth/passwords.js';
import { config } from '../config.js';
import { closePool, pool } from './pool.js';
async function bootstrapAdmin() {
if (!config.BOOTSTRAP_ADMIN_EMAIL || !config.BOOTSTRAP_ADMIN_PASSWORD) {
throw new Error('Set BOOTSTRAP_ADMIN_EMAIL and BOOTSTRAP_ADMIN_PASSWORD before running this command.');
}
const passwordHash = await hashPassword(config.BOOTSTRAP_ADMIN_PASSWORD);
const result = await pool.query<{ email: string }>(
`insert into users (email, password_hash, display_name, role)
values ($1, $2, $3, 'admin')
on conflict (email) do update
set password_hash = excluded.password_hash,
display_name = excluded.display_name,
role = 'admin'
returning email`,
[config.BOOTSTRAP_ADMIN_EMAIL, passwordHash, config.BOOTSTRAP_ADMIN_NAME],
);
console.log(`Administrator ready: ${result.rows[0].email}`);
}
bootstrapAdmin()
.catch((error: unknown) => {
console.error('Admin bootstrap failed', error);
process.exitCode = 1;
})
.finally(closePool);

50
server/src/db/migrate.ts Normal file
View File

@@ -0,0 +1,50 @@
import { readdir, readFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { pool, closePool } from './pool.js';
const migrationsDirectory = join(dirname(fileURLToPath(import.meta.url)), '../../migrations');
async function migrate() {
await pool.query(`
create table if not exists schema_migrations (
name text primary key,
applied_at timestamptz not null default now()
)
`);
const migrations = (await readdir(migrationsDirectory))
.filter((file) => file.endsWith('.sql'))
.sort();
for (const migration of migrations) {
const alreadyApplied = await pool.query<{ name: string }>(
'select name from schema_migrations where name = $1',
[migration],
);
if (alreadyApplied.rowCount) continue;
const sql = await readFile(join(migrationsDirectory, migration), 'utf8');
const client = await pool.connect();
try {
await client.query('begin');
await client.query(sql);
await client.query('insert into schema_migrations (name) values ($1)', [migration]);
await client.query('commit');
console.log(`Applied ${migration}`);
} catch (error) {
await client.query('rollback');
throw error;
} finally {
client.release();
}
}
}
migrate()
.catch((error: unknown) => {
console.error('Migration failed', error);
process.exitCode = 1;
})
.finally(closePool);

8
server/src/db/pool.ts Normal file
View File

@@ -0,0 +1,8 @@
import { Pool } from 'pg';
import { config } from '../config.js';
export const pool = new Pool({
connectionString: config.DATABASE_URL,
});
export const closePool = () => pool.end();

View File

@@ -0,0 +1,71 @@
import { INITIAL_COURSES } from '../../../constants.js';
import { closePool, pool } from './pool.js';
const instructorEmail = process.env.SEED_INSTRUCTOR_EMAIL || 'admin@compor.local';
const slugify = (value: string) => value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
const durationToSeconds = (duration: string) => {
const hours = Number(duration.match(/(\d+)h/)?.[1] || 0);
const minutes = Number(duration.match(/(\d+)m/)?.[1] || 0);
return hours * 3600 + minutes * 60 || null;
};
async function seedDemoContent() {
const instructor = await pool.query<{ id: string }>('select id from users where email = $1', [instructorEmail]);
if (!instructor.rowCount) {
throw new Error(`No administrator found for ${instructorEmail}. Run db:bootstrap-admin first.`);
}
let created = 0;
for (const [courseIndex, course] of INITIAL_COURSES.entries()) {
const exists = await pool.query('select 1 from courses where title = $1', [course.title]);
if (exists.rowCount) continue;
const client = await pool.connect();
try {
await client.query('begin');
const insertedCourse = await client.query<{ id: string }>(
`insert into courses (slug, title, description, category, cover_image_url, status, instructor_id, published_at)
values ($1, $2, $3, $4, $5, 'published', $6, now()) returning id`,
[`${slugify(course.title)}-${courseIndex + 1}`, course.title, course.description, course.category, course.thumbnail, instructor.rows[0].id],
);
const courseId = insertedCourse.rows[0].id;
for (const [lessonIndex, lesson] of course.lessons.entries()) {
const insertedLesson = await client.query<{ id: string }>(
`insert into lessons (course_id, title, description, position, duration_seconds, access_level)
values ($1, $2, $3, $4, $5, 'public') returning id`,
[courseId, lesson.title, lesson.description || '', lessonIndex + 1, durationToSeconds(lesson.duration)],
);
if (lesson.videoUrl.startsWith('http')) {
await client.query(
`insert into lesson_media (lesson_id, provider, external_id, playback_url, status)
values ($1, 'external', $2, $2, 'ready')`,
[insertedLesson.rows[0].id, lesson.videoUrl],
);
}
}
await client.query('commit');
created += 1;
} catch (error) {
await client.query('rollback');
throw error;
} finally {
client.release();
}
}
console.log(`Demo catalogue ready: ${created} courses added.`);
}
seedDemoContent()
.catch((error: unknown) => {
console.error('Demo catalogue seed failed', error);
process.exitCode = 1;
})
.finally(closePool);

25
server/src/index.ts Normal file
View File

@@ -0,0 +1,25 @@
import { buildApp } from './app.js';
import { config } from './config.js';
import { closePool } from './db/pool.js';
const app = buildApp();
async function start() {
try {
await app.listen({ host: '0.0.0.0', port: config.API_PORT });
} catch (error) {
app.log.error(error);
await closePool();
process.exit(1);
}
}
for (const signal of ['SIGINT', 'SIGTERM']) {
process.once(signal, () => {
app.close()
.then(closePool)
.finally(() => process.exit(0));
});
}
void start();

72
server/src/routes/auth.ts Normal file
View File

@@ -0,0 +1,72 @@
import type { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { hashPassword, verifyPassword } from '../auth/passwords.js';
import type { AuthUser } from '../auth/plugin.js';
import { pool } from '../db/pool.js';
const credentialsSchema = z.object({
email: z.string().email().transform((email) => email.toLowerCase()),
password: z.string().min(12).max(200),
});
const registerSchema = credentialsSchema.extend({
name: z.string().trim().min(2).max(120),
});
type UserRow = {
id: string;
email: string;
display_name: string;
role: AuthUser['role'];
password_hash: string;
};
const serializeUser = (user: UserRow): AuthUser => ({
id: user.id,
email: user.email,
name: user.display_name,
role: user.role,
});
export const authRoutes: FastifyPluginAsync = async (app) => {
app.post('/register', async (request, reply) => {
const input = registerSchema.parse(request.body);
const passwordHash = await hashPassword(input.password);
try {
const result = await pool.query<UserRow>(
`insert into users (email, password_hash, display_name)
values ($1, $2, $3)
returning id, email, display_name, role, password_hash`,
[input.email, passwordHash, input.name],
);
const user = serializeUser(result.rows[0]);
const token = await reply.jwtSign(user, { expiresIn: '7d' });
return reply.code(201).send({ token, user });
} catch (error: unknown) {
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
return reply.code(409).send({ error: 'An account with this email already exists' });
}
throw error;
}
});
app.post('/login', async (request, reply) => {
const input = credentialsSchema.parse(request.body);
const result = await pool.query<UserRow>(
`select id, email, display_name, role, password_hash from users where email = $1`,
[input.email],
);
const account = result.rows[0];
if (!account || !(await verifyPassword(input.password, account.password_hash))) {
return reply.code(401).send({ error: 'Invalid email or password' });
}
const user = serializeUser(account);
const token = await reply.jwtSign(user, { expiresIn: '7d' });
return { token, user };
});
app.get('/me', { preHandler: app.authenticate }, async (request) => ({ user: request.user }));
};

View File

@@ -0,0 +1,115 @@
import type { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { pool } from '../db/pool.js';
const paramsSchema = z.object({
courseId: z.string().uuid(),
});
export const courseSelect = (publicOnly = false) => `
select
c.id,
c.slug,
c.title,
c.description,
c.category,
c.cover_image_url as "coverImageUrl",
c.status,
c.published_at as "publishedAt",
jsonb_build_object(
'id', u.id,
'name', u.display_name
) as instructor,
coalesce((
select jsonb_agg(
jsonb_build_object(
'id', l.id,
'title', l.title,
'description', l.description,
'position', l.position,
'durationSeconds', l.duration_seconds,
'accessLevel', l.access_level,
'assets', coalesce((
select jsonb_agg(jsonb_build_object(
'id', a.id,
'name', a.name,
'kind', a.kind,
'url', a.url,
'description', a.description,
'accessLevel', a.access_level
) order by a.created_at)
from assets a
where a.lesson_id = l.id ${publicOnly ? "and a.access_level = 'public'" : ''}
), '[]'::jsonb),
'media', coalesce((
select jsonb_agg(
jsonb_build_object(
'id', lm.id,
'provider', lm.provider,
'status', lm.status,
'embedUrl', lm.embed_url,
'playbackUrl', lm.playback_url,
'durationSeconds', lm.duration_seconds
) order by lm.created_at
)
from lesson_media lm
where lm.lesson_id = l.id and lm.status = 'ready'
), '[]'::jsonb)
) order by l.position
)
from lessons l
where l.course_id = c.id ${publicOnly ? "and l.access_level = 'public'" : ''}
), '[]'::jsonb) as lessons
, coalesce((
select jsonb_agg(jsonb_build_object(
'id', a.id,
'name', a.name,
'kind', a.kind,
'url', a.url,
'description', a.description,
'accessLevel', a.access_level
) order by a.created_at)
from assets a
where a.course_id = c.id ${publicOnly ? "and a.access_level = 'public'" : ''}
), '[]'::jsonb) as assets
from courses c
join users u on u.id = c.instructor_id
`;
export const courseRoutes: FastifyPluginAsync = async (app) => {
const withoutProtectedMedia = (course: Record<string, unknown>) => ({
...course,
assets: [],
lessons: Array.isArray(course.lessons)
? course.lessons.map((lesson) => ({ ...lesson, media: [], assets: [] }))
: [],
});
const hasSession = async (request: { jwtVerify: () => Promise<unknown> }) => {
try {
await request.jwtVerify();
return true;
} catch {
return false;
}
};
app.get('/', async (request) => {
const authenticated = await hasSession(request);
const result = await pool.query(`${courseSelect()} where c.status = 'published' order by c.published_at desc`);
return { data: authenticated ? result.rows : result.rows.map(withoutProtectedMedia) };
});
app.get('/:courseId', async (request, reply) => {
const authenticated = await hasSession(request);
const { courseId } = paramsSchema.parse(request.params);
const result = await pool.query(`${courseSelect()} where c.id = $1 and c.status = 'published'`, [courseId]);
const course = result.rows[0];
if (!course) {
return reply.code(404).send({ error: 'Course not found' });
}
return { data: authenticated ? course : withoutProtectedMedia(course) };
});
};

View File

@@ -0,0 +1,188 @@
import { randomUUID } from 'node:crypto';
import type { FastifyPluginAsync } from 'fastify';
import type { PoolClient } from 'pg';
import { z } from 'zod';
import { courseSelect } from './courses.js';
import { pool } from '../db/pool.js';
const mediaSchema = z.object({
provider: z.string().trim().min(1).max(80),
externalId: z.string().trim().min(1).max(500),
playbackUrl: z.string().url().nullable().optional(),
embedUrl: z.string().url().nullable().optional(),
status: z.enum(['processing', 'ready', 'failed']).default('ready'),
durationSeconds: z.number().int().nonnegative().nullable().optional(),
});
const assetSchema = z.object({
name: z.string().trim().min(1).max(255),
kind: z.enum(['document', 'spreadsheet', 'archive', 'image', 'link']),
url: z.string().url(),
description: z.string().max(2000).default(''),
accessLevel: z.enum(['public', 'enrolled']).default('enrolled'),
});
const lessonSchema = z.object({
title: z.string().trim().min(1).max(255),
description: z.string().max(5000).default(''),
durationSeconds: z.number().int().nonnegative().nullable().optional(),
accessLevel: z.enum(['public', 'enrolled']).default('enrolled'),
media: z.array(mediaSchema).default([]),
assets: z.array(assetSchema).default([]),
});
const courseSchema = z.object({
title: z.string().trim().min(1).max(255),
slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).max(280).optional(),
description: z.string().max(10000).default(''),
category: z.string().trim().min(1).max(120),
coverImageUrl: z.string().url().nullable().optional(),
status: z.enum(['draft', 'published']).default('draft'),
lessons: z.array(lessonSchema).min(1),
assets: z.array(assetSchema).default([]),
});
const paramsSchema = z.object({ courseId: z.string().uuid() });
type CourseInput = z.infer<typeof courseSchema>;
function slugify(value: string) {
return value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '') || 'course';
}
async function uniqueSlug(client: PoolClient, requestedSlug: string | undefined, title: string, currentCourseId?: string) {
const base = requestedSlug ?? slugify(title);
let candidate = base;
while (true) {
const result = await client.query(
'select 1 from courses where slug = $1 and ($2::uuid is null or id <> $2::uuid)',
[candidate, currentCourseId ?? null],
);
if (!result.rowCount) return candidate;
candidate = `${base}-${randomUUID().slice(0, 8)}`;
}
}
async function insertAssets(client: PoolClient, parent: { courseId?: string; lessonId?: string }, assets: CourseInput['assets']) {
for (const asset of assets) {
await client.query(
`insert into assets (course_id, lesson_id, name, kind, url, description, access_level)
values ($1, $2, $3, $4, $5, $6, $7)`,
[parent.courseId ?? null, parent.lessonId ?? null, asset.name, asset.kind, asset.url, asset.description, asset.accessLevel],
);
}
}
async function replaceCourseContents(client: PoolClient, courseId: string, input: CourseInput) {
// This replacement strategy is safe before student progress exists. The next
// iteration will switch to per-lesson updates to preserve historical progress.
await client.query('delete from lessons where course_id = $1', [courseId]);
await client.query('delete from assets where course_id = $1', [courseId]);
await insertAssets(client, { courseId }, input.assets);
for (const [index, lesson] of input.lessons.entries()) {
const lessonResult = await client.query<{ id: string }>(
`insert into lessons (course_id, title, description, position, duration_seconds, access_level)
values ($1, $2, $3, $4, $5, $6)
returning id`,
[courseId, lesson.title, lesson.description, index + 1, lesson.durationSeconds ?? null, lesson.accessLevel],
);
const lessonId = lessonResult.rows[0].id;
for (const media of lesson.media) {
await client.query(
`insert into lesson_media
(lesson_id, provider, external_id, playback_url, embed_url, status, duration_seconds)
values ($1, $2, $3, $4, $5, $6, $7)`,
[lessonId, media.provider, media.externalId, media.playbackUrl ?? null, media.embedUrl ?? null, media.status, media.durationSeconds ?? null],
);
}
await insertAssets(client, { lessonId }, lesson.assets);
}
}
async function assertCanManageCourse(courseId: string, user: { id: string; role: string }) {
const result = await pool.query<{ instructor_id: string }>('select instructor_id from courses where id = $1', [courseId]);
const course = result.rows[0];
if (!course) return { error: 'Course not found', statusCode: 404 as const };
if (user.role !== 'admin' && course.instructor_id !== user.id) {
return { error: 'You can only manage your own courses', statusCode: 403 as const };
}
return null;
}
export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
const manageAccess = { preHandler: app.requireRoles(['instructor', 'admin']) };
app.get('/', manageAccess, async (request) => {
const filters = request.user.role === 'admin'
? { sql: "where c.status <> 'archived' order by c.created_at desc", values: [] as string[] }
: { sql: "where c.instructor_id = $1 and c.status <> 'archived' order by c.created_at desc", values: [request.user.id] };
const result = await pool.query(`${courseSelect()} ${filters.sql}`, filters.values);
return { data: result.rows };
});
app.post('/', manageAccess, async (request, reply) => {
const input = courseSchema.parse(request.body);
const client = await pool.connect();
try {
await client.query('begin');
const slug = await uniqueSlug(client, input.slug, input.title);
const course = await client.query<{ id: string }>(
`insert into courses (slug, title, description, category, cover_image_url, status, instructor_id, published_at)
values ($1, $2, $3, $4, $5, $6::course_status, $7, case when $6::course_status = 'published'::course_status then now() else null end)
returning id`,
[slug, input.title, input.description, input.category, input.coverImageUrl ?? null, input.status, request.user.id],
);
await replaceCourseContents(client, course.rows[0].id, input);
await client.query('commit');
return reply.code(201).send({ data: { id: course.rows[0].id } });
} catch (error) {
await client.query('rollback');
throw error;
} finally {
client.release();
}
});
app.patch('/:courseId', manageAccess, async (request, reply) => {
const { courseId } = paramsSchema.parse(request.params);
const input = courseSchema.parse(request.body);
const accessError = await assertCanManageCourse(courseId, request.user);
if (accessError) return reply.code(accessError.statusCode).send({ error: accessError.error });
const client = await pool.connect();
try {
await client.query('begin');
const slug = await uniqueSlug(client, input.slug, input.title, courseId);
await client.query(
`update courses
set slug = $2, title = $3, description = $4, category = $5, cover_image_url = $6,
status = $7::course_status, published_at = case when $7::course_status = 'published'::course_status then coalesce(published_at, now()) else null end
where id = $1`,
[courseId, slug, input.title, input.description, input.category, input.coverImageUrl ?? null, input.status],
);
await replaceCourseContents(client, courseId, input);
await client.query('commit');
return { data: { id: courseId } };
} catch (error) {
await client.query('rollback');
throw error;
} finally {
client.release();
}
});
app.delete('/:courseId', manageAccess, async (request, reply) => {
const { courseId } = paramsSchema.parse(request.params);
const accessError = await assertCanManageCourse(courseId, request.user);
if (accessError) return reply.code(accessError.statusCode).send({ error: accessError.error });
await pool.query(`update courses set status = 'archived', published_at = null where id = $1`, [courseId]);
return reply.code(204).send();
});
};

13
server/tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node"]
},
"include": ["src/**/*.ts"]
}

View File

@@ -1,33 +0,0 @@
import { GoogleGenAI } from "@google/genai";
export const generateStudentReply = async (studentComment: string, courseContext: string): Promise<string> => {
try {
const apiKey = process.env.API_KEY;
if (!apiKey) {
console.warn("EduClean: API Key is missing. AI features are disabled.");
return "Peço desculpas, mas não consigo gerar uma resposta no momento. Por favor, contate o professor diretamente.";
}
const ai = new GoogleGenAI({ apiKey });
const prompt = `
Você é um assistente de professor útil, encorajador e claro.
Contexto: O aluno está fazendo uma pergunta sobre o curso: "${courseContext}".
Pergunta do Aluno: "${studentComment}"
Tarefa: Escreva uma resposta concisa, didática e amigável (máximo de 2 frases) em Português do Brasil.
Responda diretamente ao ponto do aluno.
`;
const response = await ai.models.generateContent({
model: 'gemini-3-flash-preview',
contents: prompt,
});
return response.text || "";
} catch (error) {
console.error("Gemini API Error:", error);
return "Desculpe, não consegui gerar uma resposta automática agora. Por favor, tente novamente.";
}
};

69
services/api.ts Normal file
View File

@@ -0,0 +1,69 @@
const API_URL = import.meta.env.VITE_API_URL || '/api/v1';
const SESSION_KEY = 'compor_hub_session';
export type ApiRole = 'student' | 'instructor' | 'admin';
export interface ApiUser {
id: string;
email: string;
name: string;
role: ApiRole;
}
interface Session {
token: string;
user: ApiUser;
}
export class ApiError extends Error {
constructor(message: string, public status: number) {
super(message);
}
}
export function getSession(): Session | null {
const value = localStorage.getItem(SESSION_KEY);
if (!value) return null;
try {
return JSON.parse(value) as Session;
} catch {
localStorage.removeItem(SESSION_KEY);
return null;
}
}
export function saveSession(session: Session) {
localStorage.setItem(SESSION_KEY, JSON.stringify(session));
}
export function clearSession() {
localStorage.removeItem(SESSION_KEY);
}
export async function apiRequest<T>(path: string, options: RequestInit = {}): Promise<T> {
const session = getSession();
const headers = new Headers(options.headers);
if (options.body) headers.set('Content-Type', 'application/json');
if (session) headers.set('Authorization', `Bearer ${session.token}`);
const response = await fetch(`${API_URL}${path}`, { ...options, headers });
if (response.status === 204) return undefined as T;
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw new ApiError(body.error || 'The request could not be completed', response.status);
}
return body as T;
}
export const authApi = {
async login(email: string, password: string) {
return apiRequest<Session>('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) });
},
async register(name: string, email: string, password: string) {
return apiRequest<Session>('/auth/register', { method: 'POST', body: JSON.stringify({ name, email, password }) });
},
async me() {
return apiRequest<{ user: ApiUser }>('/auth/me');
},
};

View File

@@ -1,81 +1,166 @@
import localforage from 'localforage';
import { Course, Comment, Section } from '../types';
import { INITIAL_COURSES, generateMockSections } from '../constants';
import { Attachment, AttachmentType, Comment, Course, Lesson } from '../types';
import { apiRequest } from './api';
const COURSES_KEY = 'compor_hub_courses_v1';
const COMMENTS_KEY = 'compor_hub_comments_v1';
const PROGRESS_KEY = 'compor_hub_progress_v1';
// Initialize DB with seed courses
export const initDB = async (): Promise<void> => {
const existingCourses = await localforage.getItem<Course[]>(COURSES_KEY);
if (!existingCourses || existingCourses.length === 0) {
await localforage.setItem(COURSES_KEY, INITIAL_COURSES);
}
export const initDB = async (): Promise<void> => undefined;
type ApiAsset = {
id: string;
name: string;
kind: 'document' | 'spreadsheet' | 'archive' | 'image' | 'link';
url: string;
description: string;
};
type ApiCourse = {
id: string;
title: string;
description: string;
category: string;
coverImageUrl: string | null;
instructor: { name: string };
lessons: Array<{
id: string;
title: string;
description: string;
durationSeconds: number | null;
accessLevel: 'public' | 'enrolled';
media: Array<{ provider: string; playbackUrl: string | null; embedUrl: string | null }>;
assets: ApiAsset[];
}>;
assets: ApiAsset[];
};
const toAttachmentType = (kind: ApiAsset['kind']): AttachmentType => {
if (kind === 'document') return 'doc';
if (kind === 'archive') return 'zip';
return kind;
};
const toAttachment = (asset: ApiAsset): Attachment => ({
id: asset.id,
name: asset.name,
type: toAttachmentType(asset.kind),
url: asset.url,
description: asset.description,
});
const toDuration = (seconds: number | null) => {
if (!seconds) return '—';
const minutes = Math.round(seconds / 60);
return minutes >= 60 ? `${Math.floor(minutes / 60)}h ${minutes % 60}m` : `${minutes}m`;
};
const toCourse = (course: ApiCourse): Course => ({
id: course.id,
title: course.title,
description: course.description,
category: course.category,
thumbnail: course.coverImageUrl || 'https://images.unsplash.com/photo-1460925895917-afdab827c52f?auto=format&fit=crop&w=800&q=80',
instructor: course.instructor.name,
duration: `${course.lessons.length} Aulas`,
lessons: course.lessons.map((lesson): Lesson => ({
id: lesson.id,
title: lesson.title,
description: lesson.description,
duration: toDuration(lesson.durationSeconds),
isFree: lesson.accessLevel === 'public',
videoUrl: lesson.media[0]?.playbackUrl || lesson.media[0]?.embedUrl || '',
mediaProvider: lesson.media[0]?.provider,
attachments: lesson.assets.map(toAttachment),
})),
attachments: course.assets.map(toAttachment),
});
const toSeconds = (duration: string) => {
const hours = Number(duration.match(/(\d+)h/)?.[1] || 0);
const minutes = Number(duration.match(/(\d+)m/)?.[1] || 0);
const seconds = hours * 3600 + minutes * 60;
return seconds || null;
};
const toAssetKind = (type: AttachmentType): ApiAsset['kind'] => {
if (type === 'doc' || type === 'pdf') return 'document';
if (type === 'zip') return 'archive';
if (type === 'video') return 'link';
return type;
};
const toAssetPayload = (attachment: Attachment) => ({
name: attachment.name,
kind: toAssetKind(attachment.type),
url: attachment.url,
description: attachment.description || '',
accessLevel: 'enrolled' as const,
});
const toCoursePayload = (course: Course) => ({
title: course.title,
description: course.description,
category: course.category,
coverImageUrl: course.thumbnail && !course.thumbnail.startsWith('blob:') ? course.thumbnail : null,
status: 'published' as const,
assets: (course.attachments || []).filter((attachment) => attachment.url && attachment.url !== '#').map(toAssetPayload),
lessons: course.lessons.map((lesson) => ({
title: lesson.title,
description: lesson.description || '',
durationSeconds: toSeconds(lesson.duration),
accessLevel: lesson.isFree ? 'public' as const : 'enrolled' as const,
media: lesson.videoUrl && !lesson.videoUrl.startsWith('blob:')
? [{ provider: lesson.mediaProvider || 'external', externalId: lesson.videoUrl, playbackUrl: lesson.videoUrl, status: 'ready' as const }]
: [],
assets: (lesson.attachments || []).filter((attachment) => attachment.url && attachment.url !== '#').map(toAssetPayload),
})),
});
export const getCourses = async (): Promise<Course[]> => {
const courses = await localforage.getItem<Course[]>(COURSES_KEY);
if (!courses || courses.length === 0) {
await initDB();
return INITIAL_COURSES;
}
return courses;
const response = await apiRequest<{ data: ApiCourse[] }>('/courses');
return response.data.map(toCourse);
};
export const getManagedCourses = async (): Promise<Course[]> => {
const response = await apiRequest<{ data: ApiCourse[] }>('/manage/courses');
return response.data.map(toCourse);
};
export const getCourseById = async (id: string): Promise<Course | null> => {
const courses = await getCourses();
return courses.find(c => c.id === id) || null;
try {
const response = await apiRequest<{ data: ApiCourse }>(`/courses/${id}`);
return toCourse(response.data);
} catch {
return null;
}
};
export const saveCourse = async (course: Course): Promise<void> => {
const courses = await getCourses();
const index = courses.findIndex(c => c.id === course.id);
if (index >= 0) {
courses[index] = course;
const payload = toCoursePayload(course);
const isExisting = /^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(course.id);
if (isExisting) {
await apiRequest(`/manage/courses/${course.id}`, { method: 'PATCH', body: JSON.stringify(payload) });
} else {
courses.unshift(course);
await apiRequest('/manage/courses', { method: 'POST', body: JSON.stringify(payload) });
}
await localforage.setItem(COURSES_KEY, courses);
};
export const deleteCourse = async (id: string): Promise<void> => {
const courses = await getCourses();
const filtered = courses.filter(c => c.id !== id);
await localforage.setItem(COURSES_KEY, filtered);
await apiRequest(`/manage/courses/${id}`, { method: 'DELETE' });
};
// Comments and learner progress move to PostgreSQL in the next milestone.
export const getComments = async (courseId: string): Promise<Comment[]> => {
const allComments = (await localforage.getItem<Comment[]>(COMMENTS_KEY)) || [];
return allComments.filter(c => c.courseId === courseId);
return allComments.filter((comment) => comment.courseId === courseId);
};
export const saveComment = async (comment: Comment): Promise<void> => {
const allComments = (await localforage.getItem<Comment[]>(COMMENTS_KEY)) || [];
const index = allComments.findIndex(c => c.id === comment.id);
if (index >= 0) {
allComments[index] = comment;
} else {
allComments.unshift(comment);
}
const index = allComments.findIndex((item) => item.id === comment.id);
if (index >= 0) allComments[index] = comment;
else allComments.unshift(comment);
await localforage.setItem(COMMENTS_KEY, allComments);
};
export const saveProgress = async (courseId: string, progress: number): Promise<void> => {
const courses = await getCourses();
const course = courses.find(c => c.id === courseId);
if (course) {
course.progress = progress;
await saveCourse(course);
}
};
export const incrementViews = async (courseId: string): Promise<void> => {
const courses = await getCourses();
const course = courses.find(c => c.id === courseId);
if (course) {
course.views = (course.views || 0) + 1;
await saveCourse(course);
}
};
export const saveProgress = async (_courseId: string, _progress: number): Promise<void> => undefined;
export const incrementViews = async (_courseId: string): Promise<void> => undefined;

View File

@@ -11,7 +11,8 @@
],
"skipLibCheck": true,
"types": [
"node"
"node",
"vite/client"
],
"moduleResolution": "bundler",
"isolatedModules": true,
@@ -25,5 +26,8 @@
},
"allowImportingTsExtensions": true,
"noEmit": true
}
},
"exclude": [
"server"
]
}

View File

@@ -18,6 +18,7 @@ export interface Lesson {
title: string;
duration: string;
videoUrl: string;
mediaProvider?: string;
isFree: boolean; // true = Public, false = Pro (Login required)
description?: string;
attachments?: Attachment[]; // Lesson-specific downloadable materials

View File

@@ -1,19 +1,17 @@
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
export default defineConfig(() => {
return {
server: {
port: 3000,
host: '0.0.0.0',
proxy: {
'/api': 'http://localhost:3001',
},
},
plugins: [react()],
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),