This commit is contained in:
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
npm-debug.log
|
||||||
|
*.local
|
||||||
16
.env.example
Normal file
16
.env.example
Normal 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
29
.github/workflows/ci.yml
vendored
Normal 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
1
.gitignore
vendored
@@ -11,6 +11,7 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
.env
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
|
|||||||
6
App.tsx
6
App.tsx
@@ -65,9 +65,9 @@ function AppContent() {
|
|||||||
<Route
|
<Route
|
||||||
path="/materiais"
|
path="/materiais"
|
||||||
element={
|
element={
|
||||||
<MaterialsPage
|
<ProtectedRoute>
|
||||||
onPlayCourse={setSelectedCourse}
|
<MaterialsPage onPlayCourse={setSelectedCourse} />
|
||||||
/>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
35
BACKEND.md
Normal file
35
BACKEND.md
Normal 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
13
Dockerfile.api
Normal 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
14
Dockerfile.web
Normal 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
24
PORTAINER.md
Normal 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.
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { CourseRow } from './CourseRow';
|
import { CourseRow } from './CourseRow';
|
||||||
import { Section, Course } from '../types';
|
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 { Loader2, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
import { CATEGORIES } from '../constants';
|
import { CATEGORIES } from '../constants';
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ export const CourseGrid: React.FC<CourseGridProps> = ({
|
|||||||
const [courses, setCourses] = useState<Course[]>([]);
|
const [courses, setCourses] = useState<Course[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [activeCategory, setActiveCategory] = useState<string>('all');
|
const [activeCategory, setActiveCategory] = useState<string>('all');
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
// Slider State & Refs for Categories
|
// Slider State & Refs for Categories
|
||||||
const categoryScrollRef = useRef<HTMLDivElement>(null);
|
const categoryScrollRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -37,14 +39,15 @@ export const CourseGrid: React.FC<CourseGridProps> = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
await initDB();
|
try {
|
||||||
const allCourses = await getCourses();
|
setCourses(await getCourses());
|
||||||
setCourses(allCourses);
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadData();
|
loadData();
|
||||||
}, []);
|
}, [user]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
checkCategoryBoundaries();
|
checkCategoryBoundaries();
|
||||||
@@ -225,4 +228,3 @@ export const CourseGrid: React.FC<CourseGridProps> = ({
|
|||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export const CoursePlayerModal: React.FC<CoursePlayerModalProps> = ({ course, on
|
|||||||
const currentIndex = course.lessons.findIndex(l => l.id === currentLesson.id);
|
const currentIndex = course.lessons.findIndex(l => l.id === currentLesson.id);
|
||||||
if (currentIndex >= 0 && currentIndex < course.lessons.length - 1) {
|
if (currentIndex >= 0 && currentIndex < course.lessons.length - 1) {
|
||||||
const nextLesson = course.lessons[currentIndex + 1];
|
const nextLesson = course.lessons[currentIndex + 1];
|
||||||
if (nextLesson.isFree || user) {
|
if (user) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setCurrentLesson(nextLesson);
|
setCurrentLesson(nextLesson);
|
||||||
}, 1000);
|
}, 1000);
|
||||||
@@ -181,7 +181,7 @@ export const CoursePlayerModal: React.FC<CoursePlayerModalProps> = ({ course, on
|
|||||||
const currentLessonAttachments = currentLesson?.attachments || [];
|
const currentLessonAttachments = currentLesson?.attachments || [];
|
||||||
|
|
||||||
// Access Control Logic
|
// Access Control Logic
|
||||||
const canWatch = currentLesson && (currentLesson.isFree || user);
|
const canWatch = currentLesson && Boolean(user);
|
||||||
|
|
||||||
if (!course) return null;
|
if (!course) return null;
|
||||||
|
|
||||||
@@ -398,7 +398,7 @@ export const CoursePlayerModal: React.FC<CoursePlayerModalProps> = ({ course, on
|
|||||||
{course.lessons.map((lesson, index) => {
|
{course.lessons.map((lesson, index) => {
|
||||||
const isActive = currentLesson?.id === lesson.id;
|
const isActive = currentLesson?.id === lesson.id;
|
||||||
const isCompleted = completedLessonIds.includes(lesson.id);
|
const isCompleted = completedLessonIds.includes(lesson.id);
|
||||||
const isLocked = !lesson.isFree && !user;
|
const isLocked = !user;
|
||||||
const hasAttachments = lesson.attachments && lesson.attachments.length > 0;
|
const hasAttachments = lesson.attachments && lesson.attachments.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
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 { useAuth } from '../context/AuthContext';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { UserRole } from '../types';
|
import { UserRole } from '../types';
|
||||||
@@ -12,62 +12,70 @@ interface LoginModalProps {
|
|||||||
export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose }) => {
|
export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose }) => {
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = 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 [error, setError] = useState('');
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const { login } = useAuth();
|
const { login, register, logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (event: React.FormEvent) => {
|
||||||
e.preventDefault();
|
event.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
// Simulate network delay for "premium" feel
|
if (isRegistering && role === 'professor') {
|
||||||
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);
|
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
|
setError('Contas de instrutor são liberadas pelo administrador da plataforma.');
|
||||||
if (success) {
|
return;
|
||||||
onClose();
|
}
|
||||||
if (email === 'admin@prof.com') {
|
|
||||||
// Force check: if they selected student but logged in as admin credentials, redirect correctly or warn.
|
const signedInUser = isRegistering
|
||||||
// For UX simplicity here, we trust the credential mapping in AuthContext
|
? await register(name, email, password)
|
||||||
navigate('/gerenciar');
|
: await login(email, password);
|
||||||
} else {
|
setIsSubmitting(false);
|
||||||
navigate('/');
|
|
||||||
}
|
if (!signedInUser) {
|
||||||
} else {
|
setError(isRegistering
|
||||||
setError('Credenciais inválidas. Verifique o email ou a função selecionada.');
|
? '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.');
|
||||||
}, 800);
|
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 handleDemoLogin = (roleType: UserRole) => {
|
||||||
const demoEmail = roleType === 'professor' ? 'admin@prof.com' : 'aluno@test.com';
|
|
||||||
setEmail(demoEmail);
|
|
||||||
setPassword('demo123');
|
|
||||||
setRole(roleType);
|
setRole(roleType);
|
||||||
|
setIsRegistering(false);
|
||||||
|
if (roleType === 'professor') {
|
||||||
|
setEmail('admin@compor.local');
|
||||||
|
setPassword('ComporAdmin123!');
|
||||||
|
} else {
|
||||||
|
setEmail('student@compor.local');
|
||||||
|
setPassword('ComporStudent123!');
|
||||||
|
}
|
||||||
|
setError('');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
<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">
|
<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
|
<button onClick={onClose} className="absolute top-5 right-5 text-white/40 hover:text-white transition-colors" aria-label="Fechar">
|
||||||
onClick={onClose}
|
|
||||||
className="absolute top-5 right-5 text-white/40 hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -76,91 +84,48 @@ 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>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Role Toggle */}
|
|
||||||
<div className="flex bg-black/40 p-1 rounded-[8px] mb-6 border border-white/5 relative">
|
<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)]'}`} />
|
||||||
<div
|
<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'}`}>
|
||||||
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)]'}`}
|
<GraduationCap className="w-4 h-4" />
|
||||||
/>
|
<span className="text-body-sm font-semibold tracking-body-sm">Aluno</span>
|
||||||
|
</button>
|
||||||
<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'}`}>
|
||||||
type="button"
|
<School className="w-4 h-4" />
|
||||||
onClick={() => setRole('student')}
|
<span className="text-body-sm font-semibold tracking-body-sm">Instrutor</span>
|
||||||
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'}`}
|
</button>
|
||||||
>
|
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<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
|
<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} />
|
||||||
type="email"
|
<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 />
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Demo Buttons */}
|
{!isRegistering && (
|
||||||
<div className="flex gap-2 justify-center">
|
<div className="flex gap-2 justify-center">
|
||||||
<button
|
<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">
|
||||||
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)
|
<KeyRound className="w-3 h-3" /> Acesso Aluno (Demo)
|
||||||
</button>
|
</button>
|
||||||
<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">
|
||||||
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)
|
<KeyRound className="w-3 h-3" /> Acesso Instrutor (Demo)
|
||||||
</button>
|
</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>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
{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>}
|
||||||
type="submit"
|
|
||||||
disabled={isSubmitting}
|
<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">
|
||||||
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'}
|
||||||
{isSubmitting ? (
|
|
||||||
<Loader2 className="w-5 h-5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
'Entrar'
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||||
import { User, UserRole } from '../types';
|
import { User, UserRole } from '../types';
|
||||||
|
import { authApi, clearSession, getSession, saveSession } from '../services/api';
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
user: User | null;
|
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;
|
logout: () => void;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
}
|
}
|
||||||
@@ -15,55 +17,73 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
|||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check localStorage on mount with error handling
|
const restoreSession = async () => {
|
||||||
const storedUser = localStorage.getItem('educlean_user');
|
const session = getSession();
|
||||||
if (storedUser) {
|
if (!session) {
|
||||||
try {
|
setIsLoading(false);
|
||||||
const parsedUser = JSON.parse(storedUser);
|
return;
|
||||||
if (parsedUser && parsedUser.email) {
|
|
||||||
setUser(parsedUser);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to parse user session, clearing storage:", error);
|
|
||||||
localStorage.removeItem('educlean_user');
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
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 => {
|
const login = async (email: string, password: string): Promise<User | null> => {
|
||||||
let newUser: User | null = null;
|
try {
|
||||||
|
const session = await authApi.login(email, password);
|
||||||
if (email === 'admin@prof.com') {
|
const signedInUser = toFrontendUser(session.user);
|
||||||
newUser = { email, name: 'Prof. Anderson', role: 'professor' };
|
saveSession(session);
|
||||||
} else if (email === 'aluno@test.com') {
|
setUser(signedInUser);
|
||||||
newUser = { email, name: 'Aluno Demo', role: 'student' };
|
return signedInUser;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (newUser) {
|
const register = async (name: string, email: string, password: string): Promise<User | null> => {
|
||||||
setUser(newUser);
|
try {
|
||||||
localStorage.setItem('educlean_user', JSON.stringify(newUser));
|
const session = await authApi.register(name, email, password);
|
||||||
return true;
|
const signedInUser = toFrontendUser(session.user);
|
||||||
|
saveSession(session);
|
||||||
|
setUser(signedInUser);
|
||||||
|
return signedInUser;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
localStorage.removeItem('educlean_user');
|
clearSession();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, login, logout, isLoading }}>
|
<AuthContext.Provider value={{ user, login, register, logout, isLoading }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</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 = () => {
|
export const useAuth = () => {
|
||||||
const context = useContext(AuthContext);
|
const context = useContext(AuthContext);
|
||||||
if (context === undefined) {
|
if (context === undefined) {
|
||||||
throw new Error('useAuth must be used within an AuthProvider');
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
};
|
};
|
||||||
|
|||||||
43
docker-compose.portainer.yml
Normal file
43
docker-compose.portainer.yml
Normal 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
19
docker-compose.yml
Normal 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
19
docker/nginx.conf
Normal 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
3899
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
package.json
22
package.json
@@ -6,7 +6,15 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"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": {
|
"dependencies": {
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
@@ -14,12 +22,20 @@
|
|||||||
"react-router-dom": "6.22.3",
|
"react-router-dom": "6.22.3",
|
||||||
"lucide-react": "0.344.0",
|
"lucide-react": "0.344.0",
|
||||||
"localforage": "1.10.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": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
"typescript": "~5.8.2",
|
"typescript": "~5.8.2",
|
||||||
"vite": "^6.2.0"
|
"vite": "^6.2.0",
|
||||||
|
"@types/pg": "^8.15.4",
|
||||||
|
"tsx": "^4.19.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
Send,
|
Send,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
Video,
|
Video,
|
||||||
Sparkles,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
FileText,
|
FileText,
|
||||||
FileSpreadsheet,
|
FileSpreadsheet,
|
||||||
@@ -23,14 +22,12 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
Lock,
|
Lock,
|
||||||
Unlock,
|
Unlock,
|
||||||
UploadCloud,
|
|
||||||
Globe,
|
Globe,
|
||||||
Download
|
Download
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Course, Comment, Attachment, Lesson, AttachmentType } from '../types';
|
import { Course, Comment, Attachment, Lesson, AttachmentType } from '../types';
|
||||||
import { useToast } from '../context/ToastContext';
|
import { useToast } from '../context/ToastContext';
|
||||||
import { getCourses, saveCourse, deleteCourse, getComments, saveComment } from '../services/db';
|
import { getManagedCourses, saveCourse, deleteCourse, getComments, saveComment } from '../services/db';
|
||||||
import { generateStudentReply } from '../services/ai';
|
|
||||||
import { CATEGORIES } from '../constants';
|
import { CATEGORIES } from '../constants';
|
||||||
import { getAttachmentIcon, getAttachmentBadge } from '../components/MaterialCard';
|
import { getAttachmentIcon, getAttachmentBadge } from '../components/MaterialCard';
|
||||||
|
|
||||||
@@ -38,7 +35,7 @@ import { getAttachmentIcon, getAttachmentBadge } from '../components/MaterialCar
|
|||||||
const CourseModal: React.FC<{
|
const CourseModal: React.FC<{
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: (course: Course) => void;
|
onSave: (course: Course) => Promise<void>;
|
||||||
initialData?: Course;
|
initialData?: Course;
|
||||||
}> = ({ isOpen, onClose, onSave, initialData }) => {
|
}> = ({ isOpen, onClose, onSave, initialData }) => {
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
@@ -55,10 +52,8 @@ const CourseModal: React.FC<{
|
|||||||
const [newLessonTitle, setNewLessonTitle] = useState('');
|
const [newLessonTitle, setNewLessonTitle] = useState('');
|
||||||
const [newLessonDuration, setNewLessonDuration] = useState('');
|
const [newLessonDuration, setNewLessonDuration] = useState('');
|
||||||
const [newLessonVideoUrl, setNewLessonVideoUrl] = useState('');
|
const [newLessonVideoUrl, setNewLessonVideoUrl] = useState('');
|
||||||
|
const [newLessonProvider, setNewLessonProvider] = useState('external');
|
||||||
const [newLessonIsFree, setNewLessonIsFree] = useState(false);
|
const [newLessonIsFree, setNewLessonIsFree] = useState(false);
|
||||||
const [videoSourceType, setVideoSourceType] = useState<'upload' | 'link'>('upload');
|
|
||||||
const [selectedFileName, setSelectedFileName] = useState('');
|
|
||||||
const lessonFileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
// Tips & Global Attachments
|
// Tips & Global Attachments
|
||||||
const [tips, setTips] = useState<string[]>([]);
|
const [tips, setTips] = useState<string[]>([]);
|
||||||
@@ -73,7 +68,6 @@ const CourseModal: React.FC<{
|
|||||||
const [newAttDesc, setNewAttDesc] = useState('');
|
const [newAttDesc, setNewAttDesc] = useState('');
|
||||||
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
@@ -101,31 +95,13 @@ const CourseModal: React.FC<{
|
|||||||
}
|
}
|
||||||
}, [isOpen, initialData]);
|
}, [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 = () => {
|
const handleAddLesson = () => {
|
||||||
if (!newLessonTitle.trim()) {
|
if (!newLessonTitle.trim()) {
|
||||||
alert("Digite um título para a aula.");
|
alert("Digite um título para a aula.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!newLessonVideoUrl.trim()) {
|
if (!newLessonVideoUrl.trim()) {
|
||||||
alert("Selecione um vídeo ou insira um link.");
|
alert("Enter a hosted video URL.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +110,7 @@ const CourseModal: React.FC<{
|
|||||||
title: newLessonTitle,
|
title: newLessonTitle,
|
||||||
duration: newLessonDuration || '15m',
|
duration: newLessonDuration || '15m',
|
||||||
videoUrl: newLessonVideoUrl,
|
videoUrl: newLessonVideoUrl,
|
||||||
|
mediaProvider: newLessonProvider || 'external',
|
||||||
isFree: newLessonIsFree,
|
isFree: newLessonIsFree,
|
||||||
description: ''
|
description: ''
|
||||||
};
|
};
|
||||||
@@ -143,9 +120,8 @@ const CourseModal: React.FC<{
|
|||||||
setNewLessonTitle('');
|
setNewLessonTitle('');
|
||||||
setNewLessonDuration('');
|
setNewLessonDuration('');
|
||||||
setNewLessonVideoUrl('');
|
setNewLessonVideoUrl('');
|
||||||
setSelectedFileName('');
|
setNewLessonProvider('external');
|
||||||
setNewLessonIsFree(false);
|
setNewLessonIsFree(false);
|
||||||
if (lessonFileInputRef.current) lessonFileInputRef.current.value = '';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeLesson = (id: string) => {
|
const removeLesson = (id: string) => {
|
||||||
@@ -193,7 +169,7 @@ const CourseModal: React.FC<{
|
|||||||
setAttachments(attachments.filter(a => a.id !== id));
|
setAttachments(attachments.filter(a => a.id !== id));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (lessons.length === 0) {
|
if (lessons.length === 0) {
|
||||||
alert("Adicione pelo menos uma aula à trilha.");
|
alert("Adicione pelo menos uma aula à trilha.");
|
||||||
@@ -204,24 +180,28 @@ const CourseModal: React.FC<{
|
|||||||
|
|
||||||
const durationStr = `${lessons.length} Aulas`;
|
const durationStr = `${lessons.length} Aulas`;
|
||||||
|
|
||||||
onSave({
|
try {
|
||||||
id: initialData?.id || `course-${Date.now()}`,
|
await onSave({
|
||||||
title,
|
id: initialData?.id || `course-${Date.now()}`,
|
||||||
description,
|
title,
|
||||||
category: category || CATEGORIES[0],
|
description,
|
||||||
thumbnail: thumbnailPreview || 'https://images.unsplash.com/photo-1460925895917-afdab827c52f?auto=format&fit=crop&w=800&q=80',
|
category: category || CATEGORIES[0],
|
||||||
duration: durationStr,
|
thumbnail: thumbnailPreview || 'https://images.unsplash.com/photo-1460925895917-afdab827c52f?auto=format&fit=crop&w=800&q=80',
|
||||||
instructor,
|
duration: durationStr,
|
||||||
instructorRole,
|
instructor,
|
||||||
views: initialData?.views || 0,
|
instructorRole,
|
||||||
progress: initialData?.progress || 0,
|
views: initialData?.views || 0,
|
||||||
lessons: lessons,
|
progress: initialData?.progress || 0,
|
||||||
tips: tips,
|
lessons: lessons,
|
||||||
attachments: attachments
|
tips: tips,
|
||||||
});
|
attachments: attachments
|
||||||
|
});
|
||||||
setIsSubmitting(false);
|
onClose();
|
||||||
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;
|
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>
|
<label className="text-xs text-gray-400 uppercase font-bold tracking-wider mb-2 block">Imagem de Capa (16:9)</label>
|
||||||
<div
|
<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"
|
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 ? (
|
{thumbnailPreview ? (
|
||||||
<>
|
<>
|
||||||
@@ -255,17 +234,17 @@ const CourseModal: React.FC<{
|
|||||||
) : (
|
) : (
|
||||||
<div className="text-center p-6">
|
<div className="text-center p-6">
|
||||||
<ImageIcon className="w-10 h-10 text-orange-400/60 mx-auto mb-2" />
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
className="hidden"
|
|
||||||
onChange={handleFileChange}
|
|
||||||
/>
|
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -492,52 +471,21 @@ const CourseModal: React.FC<{
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Video Source Tabs */}
|
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<div className="flex bg-white/5 p-0.5 rounded-lg border border-white/5">
|
<input
|
||||||
<button
|
type="text"
|
||||||
type="button"
|
value={newLessonProvider}
|
||||||
onClick={() => setVideoSourceType('upload')}
|
onChange={(event) => setNewLessonProvider(event.target.value)}
|
||||||
className={`px-3 py-1 rounded-md text-xs font-bold transition-all ${videoSourceType === 'upload' ? 'bg-orange-500 text-white' : 'text-gray-400'}`}
|
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"
|
||||||
Vídeo MP4
|
/>
|
||||||
</button>
|
<input
|
||||||
<button
|
type="url"
|
||||||
type="button"
|
value={newLessonVideoUrl}
|
||||||
onClick={() => setVideoSourceType('link')}
|
onChange={(event) => setNewLessonVideoUrl(event.target.value)}
|
||||||
className={`px-3 py-1 rounded-md text-xs font-bold transition-all ${videoSourceType === 'link' ? 'bg-orange-500 text-white' : 'text-gray-400'}`}
|
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"
|
||||||
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>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -577,13 +525,12 @@ export const ManageCourses: React.FC = () => {
|
|||||||
const [allComments, setAllComments] = useState<{comment: Comment, courseTitle: string}[]>([]);
|
const [allComments, setAllComments] = useState<{comment: Comment, courseTitle: string}[]>([]);
|
||||||
const [replyingTo, setReplyingTo] = useState<string | null>(null);
|
const [replyingTo, setReplyingTo] = useState<string | null>(null);
|
||||||
const [replyText, setReplyText] = useState('');
|
const [replyText, setReplyText] = useState('');
|
||||||
const [isGeneratingReply, setIsGeneratingReply] = useState(false);
|
|
||||||
|
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await getCourses();
|
const data = await getManagedCourses();
|
||||||
setCourses(data);
|
setCourses(data);
|
||||||
|
|
||||||
// Load all comments
|
// 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
|
// Metrics
|
||||||
const totalViews = courses.reduce((acc, c) => acc + (c.views || 0), 0);
|
const totalViews = courses.reduce((acc, c) => acc + (c.views || 0), 0);
|
||||||
const totalLessons = courses.reduce((acc, c) => acc + c.lessons.length, 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"
|
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}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex justify-end gap-2">
|
||||||
<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">
|
|
||||||
<button
|
<button
|
||||||
onClick={() => { setReplyingTo(null); setReplyText(''); }}
|
onClick={() => { setReplyingTo(null); setReplyText(''); }}
|
||||||
className="px-3.5 py-2 rounded-[980px] text-body-sm text-gray-400 hover:text-white font-semibold"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => setReplyingTo(comment.id)}
|
onClick={() => setReplyingTo(comment.id)}
|
||||||
|
|||||||
140
server/migrations/001_initial_schema.sql
Normal file
140
server/migrations/001_initial_schema.sql
Normal 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();
|
||||||
2
server/migrations/002_allow_reused_media_references.sql
Normal file
2
server/migrations/002_allow_reused_media_references.sql
Normal 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
43
server/src/app.ts
Normal 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;
|
||||||
|
}
|
||||||
20
server/src/auth/passwords.ts
Normal file
20
server/src/auth/passwords.ts
Normal 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
53
server/src/auth/plugin.ts
Normal 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
19
server/src/config.ts
Normal 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.');
|
||||||
|
}
|
||||||
29
server/src/db/bootstrap-admin.ts
Normal file
29
server/src/db/bootstrap-admin.ts
Normal 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
50
server/src/db/migrate.ts
Normal 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
8
server/src/db/pool.ts
Normal 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();
|
||||||
71
server/src/db/seed-demo-content.ts
Normal file
71
server/src/db/seed-demo-content.ts
Normal 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
25
server/src/index.ts
Normal 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
72
server/src/routes/auth.ts
Normal 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 }));
|
||||||
|
};
|
||||||
115
server/src/routes/courses.ts
Normal file
115
server/src/routes/courses.ts
Normal 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) };
|
||||||
|
});
|
||||||
|
};
|
||||||
188
server/src/routes/manage-courses.ts
Normal file
188
server/src/routes/manage-courses.ts
Normal 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
13
server/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
@@ -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
69
services/api.ts
Normal 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');
|
||||||
|
},
|
||||||
|
};
|
||||||
189
services/db.ts
189
services/db.ts
@@ -1,81 +1,166 @@
|
|||||||
import localforage from 'localforage';
|
import localforage from 'localforage';
|
||||||
import { Course, Comment, Section } from '../types';
|
import { Attachment, AttachmentType, Comment, Course, Lesson } from '../types';
|
||||||
import { INITIAL_COURSES, generateMockSections } from '../constants';
|
import { apiRequest } from './api';
|
||||||
|
|
||||||
const COURSES_KEY = 'compor_hub_courses_v1';
|
|
||||||
const COMMENTS_KEY = 'compor_hub_comments_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> => undefined;
|
||||||
export const initDB = async (): Promise<void> => {
|
|
||||||
const existingCourses = await localforage.getItem<Course[]>(COURSES_KEY);
|
type ApiAsset = {
|
||||||
if (!existingCourses || existingCourses.length === 0) {
|
id: string;
|
||||||
await localforage.setItem(COURSES_KEY, INITIAL_COURSES);
|
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[]> => {
|
export const getCourses = async (): Promise<Course[]> => {
|
||||||
const courses = await localforage.getItem<Course[]>(COURSES_KEY);
|
const response = await apiRequest<{ data: ApiCourse[] }>('/courses');
|
||||||
if (!courses || courses.length === 0) {
|
return response.data.map(toCourse);
|
||||||
await initDB();
|
};
|
||||||
return INITIAL_COURSES;
|
|
||||||
}
|
export const getManagedCourses = async (): Promise<Course[]> => {
|
||||||
return courses;
|
const response = await apiRequest<{ data: ApiCourse[] }>('/manage/courses');
|
||||||
|
return response.data.map(toCourse);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getCourseById = async (id: string): Promise<Course | null> => {
|
export const getCourseById = async (id: string): Promise<Course | null> => {
|
||||||
const courses = await getCourses();
|
try {
|
||||||
return courses.find(c => c.id === id) || null;
|
const response = await apiRequest<{ data: ApiCourse }>(`/courses/${id}`);
|
||||||
|
return toCourse(response.data);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const saveCourse = async (course: Course): Promise<void> => {
|
export const saveCourse = async (course: Course): Promise<void> => {
|
||||||
const courses = await getCourses();
|
const payload = toCoursePayload(course);
|
||||||
const index = courses.findIndex(c => c.id === course.id);
|
const isExisting = /^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(course.id);
|
||||||
if (index >= 0) {
|
if (isExisting) {
|
||||||
courses[index] = course;
|
await apiRequest(`/manage/courses/${course.id}`, { method: 'PATCH', body: JSON.stringify(payload) });
|
||||||
} else {
|
} 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> => {
|
export const deleteCourse = async (id: string): Promise<void> => {
|
||||||
const courses = await getCourses();
|
await apiRequest(`/manage/courses/${id}`, { method: 'DELETE' });
|
||||||
const filtered = courses.filter(c => c.id !== id);
|
|
||||||
await localforage.setItem(COURSES_KEY, filtered);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Comments and learner progress move to PostgreSQL in the next milestone.
|
||||||
export const getComments = async (courseId: string): Promise<Comment[]> => {
|
export const getComments = async (courseId: string): Promise<Comment[]> => {
|
||||||
const allComments = (await localforage.getItem<Comment[]>(COMMENTS_KEY)) || [];
|
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> => {
|
export const saveComment = async (comment: Comment): Promise<void> => {
|
||||||
const allComments = (await localforage.getItem<Comment[]>(COMMENTS_KEY)) || [];
|
const allComments = (await localforage.getItem<Comment[]>(COMMENTS_KEY)) || [];
|
||||||
const index = allComments.findIndex(c => c.id === comment.id);
|
const index = allComments.findIndex((item) => item.id === comment.id);
|
||||||
|
if (index >= 0) allComments[index] = comment;
|
||||||
if (index >= 0) {
|
else allComments.unshift(comment);
|
||||||
allComments[index] = comment;
|
|
||||||
} else {
|
|
||||||
allComments.unshift(comment);
|
|
||||||
}
|
|
||||||
await localforage.setItem(COMMENTS_KEY, allComments);
|
await localforage.setItem(COMMENTS_KEY, allComments);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const saveProgress = async (courseId: string, progress: number): Promise<void> => {
|
export const saveProgress = async (_courseId: string, _progress: number): Promise<void> => undefined;
|
||||||
const courses = await getCourses();
|
export const incrementViews = async (_courseId: string): Promise<void> => undefined;
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
],
|
],
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"types": [
|
"types": [
|
||||||
"node"
|
"node",
|
||||||
|
"vite/client"
|
||||||
],
|
],
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
@@ -25,5 +26,8 @@
|
|||||||
},
|
},
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"noEmit": true
|
"noEmit": true
|
||||||
}
|
},
|
||||||
}
|
"exclude": [
|
||||||
|
"server"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
3
types.ts
3
types.ts
@@ -18,6 +18,7 @@ export interface Lesson {
|
|||||||
title: string;
|
title: string;
|
||||||
duration: string;
|
duration: string;
|
||||||
videoUrl: string;
|
videoUrl: string;
|
||||||
|
mediaProvider?: string;
|
||||||
isFree: boolean; // true = Public, false = Pro (Login required)
|
isFree: boolean; // true = Public, false = Pro (Login required)
|
||||||
description?: string;
|
description?: string;
|
||||||
attachments?: Attachment[]; // Lesson-specific downloadable materials
|
attachments?: Attachment[]; // Lesson-specific downloadable materials
|
||||||
@@ -63,4 +64,4 @@ export interface Comment {
|
|||||||
date: string;
|
date: string;
|
||||||
reply?: string; // Professor's reply
|
reply?: string; // Professor's reply
|
||||||
replyDate?: string;
|
replyDate?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { defineConfig, loadEnv } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(() => {
|
||||||
const env = loadEnv(mode, '.', '');
|
|
||||||
return {
|
return {
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:3001',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
plugins: [react()],
|
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: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, '.'),
|
'@': path.resolve(__dirname, '.'),
|
||||||
|
|||||||
Reference in New Issue
Block a user