feat: add managed learning paths
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { CourseRow } from './CourseRow';
|
||||
import { Section, Course } from '../types';
|
||||
import { getCourses } from '../services/db';
|
||||
import { getCourses, getLearningPaths, PublicLearningPath } from '../services/db';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { Loader2, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { LearningPathRow } from './LearningPathRow';
|
||||
|
||||
interface CourseGridProps {
|
||||
onCourseSelect?: (course: Course) => void;
|
||||
@@ -15,6 +16,7 @@ export const CourseGrid: React.FC<CourseGridProps> = ({
|
||||
selectedCategoryFilter
|
||||
}) => {
|
||||
const [courses, setCourses] = useState<Course[]>([]);
|
||||
const [paths, setPaths] = useState<PublicLearningPath[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeCategory, setActiveCategory] = useState<string>('all');
|
||||
const { user } = useAuth();
|
||||
@@ -39,7 +41,9 @@ export const CourseGrid: React.FC<CourseGridProps> = ({
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setCourses(await getCourses());
|
||||
const [loadedCourses, loadedPaths] = await Promise.all([getCourses(), getLearningPaths()]);
|
||||
setCourses(loadedCourses);
|
||||
setPaths(loadedPaths);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -138,11 +142,6 @@ export const CourseGrid: React.FC<CourseGridProps> = ({
|
||||
variant: 'landscape',
|
||||
courses: courses.filter(c => (c.attachments && c.attachments.length > 0) || c.lessons.some(l => l.attachments && l.attachments.length > 0))
|
||||
},
|
||||
{
|
||||
title: 'Formações Recomendadas',
|
||||
variant: 'portrait',
|
||||
courses: courses
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -238,6 +237,7 @@ export const CourseGrid: React.FC<CourseGridProps> = ({
|
||||
/>
|
||||
)
|
||||
))}
|
||||
{activeCategory === 'all' && paths.length > 0 && <LearningPathRow paths={paths} courses={courses} onCourseSelect={onCourseSelect} />}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
38
components/LearningPathRow.tsx
Normal file
38
components/LearningPathRow.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ArrowRight, Check, Layers3, X } from 'lucide-react';
|
||||
import { Course } from '../types';
|
||||
import { PublicLearningPath } from '../services/db';
|
||||
import { CourseCoverImage } from './CourseCoverImage';
|
||||
|
||||
interface LearningPathRowProps {
|
||||
paths: PublicLearningPath[];
|
||||
courses: Course[];
|
||||
onCourseSelect?: (course: Course) => void;
|
||||
}
|
||||
|
||||
export const LearningPathRow: React.FC<LearningPathRowProps> = ({ paths, courses, onCourseSelect }) => {
|
||||
const [selectedPath, setSelectedPath] = useState<PublicLearningPath | null>(null);
|
||||
const courseById = new Map(courses.map((course) => [course.id, course]));
|
||||
|
||||
return <>
|
||||
<section className="mx-auto w-full max-w-[1440px] px-6 py-6 md:px-12">
|
||||
<div className="mb-4 flex items-baseline gap-3">
|
||||
<h2 className="font-display text-xl font-bold text-gray-100 md:text-2xl">Formações</h2>
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-gray-500">{paths.length} {paths.length === 1 ? 'formação' : 'formações'}</span>
|
||||
</div>
|
||||
<div className="flex gap-5 overflow-x-auto pb-4 hide-scrollbar snap-x snap-mandatory">
|
||||
{paths.map((path) => <button key={path.id} onClick={() => setSelectedPath(path)} className="group relative w-[300px] shrink-0 snap-start overflow-hidden rounded-xl border border-white/10 bg-zinc-950 text-left shadow-xl transition hover:-translate-y-1 hover:border-orange-500/60">
|
||||
<div className="relative aspect-[16/8] overflow-hidden bg-zinc-900">
|
||||
{path.coverImageUrl ? <CourseCoverImage source={path.coverImageUrl} alt={path.title} className="h-full w-full object-cover transition duration-500 group-hover:scale-105" style={{ transform: `scale(${path.coverImageZoom || 1})`, objectPosition: `${path.coverImagePositionX ?? 50}% ${path.coverImagePositionY ?? 50}%` }} /> : <div className="flex h-full items-center justify-center bg-gradient-to-br from-orange-500/30 to-zinc-900"><Layers3 className="h-9 w-9 text-orange-300" /></div>}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 to-transparent" />
|
||||
<span className="absolute bottom-3 left-3 rounded-full bg-black/60 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider text-white backdrop-blur">{path.courses.length} {path.courses.length === 1 ? 'curso' : 'cursos'}</span>
|
||||
</div>
|
||||
<div className="p-4"><h3 className="truncate font-semibold text-white group-hover:text-orange-300">{path.title}</h3><p className="mt-1 line-clamp-2 min-h-10 text-sm leading-relaxed text-gray-500">{path.description || 'Uma trilha organizada para avançar passo a passo.'}</p><span className="mt-3 inline-flex items-center gap-1 text-sm font-semibold text-orange-400">Ver formação <ArrowRight className="h-4 w-4" /></span></div>
|
||||
</button>)}
|
||||
</div>
|
||||
</section>
|
||||
{selectedPath && <div className="fixed inset-0 z-[90] flex items-center justify-center bg-black/80 p-4 backdrop-blur-sm" role="dialog" aria-modal="true" aria-label={selectedPath.title}>
|
||||
<div className="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-2xl border border-white/10 bg-zinc-950 shadow-2xl"><div className="flex items-start justify-between border-b border-white/10 p-6"><div><div className="flex items-center gap-2 text-xs font-bold uppercase tracking-wider text-orange-400"><Layers3 className="h-4 w-4" /> Formação</div><h2 className="mt-2 text-2xl font-bold text-white">{selectedPath.title}</h2><p className="mt-2 text-sm leading-relaxed text-gray-400">{selectedPath.description}</p></div><button onClick={() => setSelectedPath(null)} className="rounded-full p-2 text-gray-400 hover:bg-white/10 hover:text-white" aria-label="Fechar"><X className="h-5 w-5" /></button></div><ol className="space-y-2 p-5">{selectedPath.courses.map((pathCourse, index) => { const course = courseById.get(pathCourse.id); return <li key={pathCourse.id}><button onClick={() => { if (course) { setSelectedPath(null); onCourseSelect?.(course); } }} disabled={!course} className="flex w-full items-center gap-4 rounded-xl bg-white/[0.03] px-4 py-3 text-left transition hover:bg-white/[0.07] disabled:cursor-default"><span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-orange-500/15 text-xs font-bold text-orange-400">{index + 1}</span><span className="min-w-0 flex-1"><span className="block truncate font-semibold text-white">{pathCourse.title}</span><span className="mt-0.5 block text-xs text-gray-500">{pathCourse.category}</span></span>{course && <span className="text-xs font-semibold text-orange-400">Abrir curso</span>}</button></li>; })}</ol><div className="border-t border-white/10 p-4 text-center text-xs text-gray-500"><Check className="mr-1 inline h-3.5 w-3.5 text-emerald-400" /> Siga os cursos nesta ordem.</div></div>
|
||||
</div>}
|
||||
</>;
|
||||
};
|
||||
15
index.css
15
index.css
@@ -68,6 +68,21 @@ h1, h2, h3, h4, h5, h6, .font-display, .font-heading {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Shared native controls used inside focused admin dialogs. */
|
||||
.input {
|
||||
width: 100%;
|
||||
border: 1px solid rgb(255 255 255 / 0.1);
|
||||
border-radius: 0.75rem;
|
||||
background: #18181b;
|
||||
padding: 0.625rem 0.75rem;
|
||||
color: #fff;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: #71717a;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
File diff suppressed because one or more lines are too long
32
server/migrations/009_learning_paths.sql
Normal file
32
server/migrations/009_learning_paths.sql
Normal file
@@ -0,0 +1,32 @@
|
||||
-- A learning path is deliberately only a curated, ordered collection of
|
||||
-- existing courses. Lessons remain owned by their courses, so a course edit
|
||||
-- is reflected everywhere that course appears.
|
||||
create table learning_paths (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
title text not null check (char_length(trim(title)) between 1 and 160),
|
||||
description text not null default '',
|
||||
cover_image_url text,
|
||||
cover_image_zoom real not null default 1 check (cover_image_zoom between 1 and 2.5),
|
||||
cover_image_position_x real not null default 50 check (cover_image_position_x between 0 and 100),
|
||||
cover_image_position_y real not null default 50 check (cover_image_position_y between 0 and 100),
|
||||
status course_status not null default 'draft',
|
||||
published_at timestamptz,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
constraint learning_paths_published_at_check check (
|
||||
(status = 'published' and published_at is not null) or status <> 'published'
|
||||
)
|
||||
);
|
||||
|
||||
create table learning_path_courses (
|
||||
learning_path_id uuid not null references learning_paths(id) on delete cascade,
|
||||
course_id uuid not null references courses(id) on delete restrict,
|
||||
position integer not null check (position > 0),
|
||||
primary key (learning_path_id, course_id),
|
||||
unique (learning_path_id, position)
|
||||
);
|
||||
|
||||
create index learning_paths_published_index on learning_paths (published_at desc) where status = 'published';
|
||||
create index learning_path_courses_position_index on learning_path_courses (learning_path_id, position);
|
||||
|
||||
create trigger learning_paths_set_updated_at before update on learning_paths for each row execute function set_updated_at();
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { FastifyPluginAsync } from 'fastify';
|
||||
import type { PoolClient } from 'pg';
|
||||
import { z } from 'zod';
|
||||
import { pool } from '../db/pool.js';
|
||||
import { createRawToken, hashToken } from '../auth/account-tokens.js';
|
||||
@@ -23,6 +24,64 @@ const homeConfigurationSchema = z.object({
|
||||
courseOrder: z.array(z.string().uuid()).max(500),
|
||||
defaultCoverImageUrl: z.string().url().nullable(),
|
||||
});
|
||||
const learningPathParamsSchema = z.object({ pathId: z.string().uuid() });
|
||||
const learningPathSchema = z.object({
|
||||
title: z.string().trim().min(1).max(160),
|
||||
description: z.string().trim().max(2_000).default(''),
|
||||
coverImageUrl: z.string().url().nullable().default(null),
|
||||
coverImageZoom: z.number().min(1).max(2.5).default(1),
|
||||
coverImagePositionX: z.number().min(0).max(100).default(50),
|
||||
coverImagePositionY: z.number().min(0).max(100).default(50),
|
||||
status: z.enum(['draft', 'published']).default('draft'),
|
||||
courseIds: z.array(z.string().uuid()).min(1).max(100),
|
||||
});
|
||||
|
||||
type LearningPathInput = z.infer<typeof learningPathSchema>;
|
||||
|
||||
const getLearningPaths = async () => {
|
||||
const result = await pool.query(
|
||||
`select
|
||||
p.id, p.title, p.description, p.cover_image_url as "coverImageUrl",
|
||||
p.cover_image_zoom as "coverImageZoom",
|
||||
p.cover_image_position_x as "coverImagePositionX",
|
||||
p.cover_image_position_y as "coverImagePositionY",
|
||||
p.status, p.published_at as "publishedAt", p.created_at as "createdAt",
|
||||
coalesce(jsonb_agg(jsonb_build_object(
|
||||
'id', c.id, 'title', c.title, 'status', c.status,
|
||||
'coverImageUrl', c.cover_image_url, 'position', pc.position
|
||||
) order by pc.position) filter (where c.id is not null), '[]'::jsonb) as courses
|
||||
from learning_paths p
|
||||
left join learning_path_courses pc on pc.learning_path_id = p.id
|
||||
left join courses c on c.id = pc.course_id
|
||||
group by p.id
|
||||
order by p.created_at desc`,
|
||||
);
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
const validatePathCourses = async (client: PoolClient, input: LearningPathInput) => {
|
||||
const courseIds = [...new Set(input.courseIds)];
|
||||
if (courseIds.length !== input.courseIds.length) throw new Error('A course can only appear once in a path');
|
||||
const courses = await client.query<{ id: string; status: string }>(
|
||||
`select id, status from courses where id = any($1::uuid[]) and status <> 'archived'`,
|
||||
[courseIds],
|
||||
);
|
||||
if (courses.rowCount !== courseIds.length) throw new Error('Every path course must exist and cannot be archived');
|
||||
if (input.status === 'published' && courses.rows.some((course) => course.status !== 'published')) {
|
||||
throw new Error('Publish every course in this path before publishing the path');
|
||||
}
|
||||
return courseIds;
|
||||
};
|
||||
|
||||
const writePathCourses = async (client: PoolClient, pathId: string, courseIds: string[]) => {
|
||||
await client.query('delete from learning_path_courses where learning_path_id = $1', [pathId]);
|
||||
for (const [index, courseId] of courseIds.entries()) {
|
||||
await client.query(
|
||||
`insert into learning_path_courses (learning_path_id, course_id, position) values ($1, $2, $3)`,
|
||||
[pathId, courseId, index + 1],
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const adminRoutes: FastifyPluginAsync = async (app) => {
|
||||
const adminAccess = { preHandler: app.requireRoles(['admin']) };
|
||||
@@ -57,6 +116,77 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
|
||||
return { data: result.rows };
|
||||
});
|
||||
|
||||
app.get('/paths', adminAccess, async () => ({ data: await getLearningPaths() }));
|
||||
|
||||
app.post('/paths', adminAccess, async (request, reply) => {
|
||||
const input = learningPathSchema.parse(request.body);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('begin');
|
||||
const courseIds = await validatePathCourses(client, input);
|
||||
const path = await client.query<{ id: string }>(
|
||||
`insert into learning_paths (
|
||||
title, description, cover_image_url, cover_image_zoom, cover_image_position_x, cover_image_position_y, status, published_at
|
||||
) values ($1, $2, $3, $4, $5, $6, $7::course_status, case when $7 = 'published' then now() else null end)
|
||||
returning id`,
|
||||
[input.title, input.description, input.coverImageUrl, input.coverImageZoom, input.coverImagePositionX, input.coverImagePositionY, input.status],
|
||||
);
|
||||
await writePathCourses(client, path.rows[0].id, courseIds);
|
||||
await client.query('commit');
|
||||
await recordAudit({ actorId: request.user.id, action: 'path.created', subjectType: 'learning_path', subjectId: path.rows[0].id, metadata: { title: input.title }, ipAddress: request.ip });
|
||||
const paths = await getLearningPaths();
|
||||
return reply.code(201).send({ data: paths.find((item) => item.id === path.rows[0].id) });
|
||||
} catch (error) {
|
||||
await client.query('rollback');
|
||||
if (error instanceof Error && /course|Publish/.test(error.message)) return reply.code(400).send({ error: error.message });
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/paths/:pathId', adminAccess, async (request, reply) => {
|
||||
const { pathId } = learningPathParamsSchema.parse(request.params);
|
||||
const input = learningPathSchema.parse(request.body);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('begin');
|
||||
const courseIds = await validatePathCourses(client, input);
|
||||
const path = await client.query<{ id: string }>(
|
||||
`update learning_paths set
|
||||
title = $2, description = $3, cover_image_url = $4, cover_image_zoom = $5,
|
||||
cover_image_position_x = $6, cover_image_position_y = $7, status = $8::course_status,
|
||||
published_at = case when $8 = 'published' then coalesce(published_at, now()) else null end
|
||||
where id = $1
|
||||
returning id`,
|
||||
[pathId, input.title, input.description, input.coverImageUrl, input.coverImageZoom, input.coverImagePositionX, input.coverImagePositionY, input.status],
|
||||
);
|
||||
if (!path.rows[0]) {
|
||||
await client.query('rollback');
|
||||
return reply.code(404).send({ error: 'Learning path not found' });
|
||||
}
|
||||
await writePathCourses(client, pathId, courseIds);
|
||||
await client.query('commit');
|
||||
await recordAudit({ actorId: request.user.id, action: 'path.updated', subjectType: 'learning_path', subjectId: pathId, metadata: { title: input.title }, ipAddress: request.ip });
|
||||
const paths = await getLearningPaths();
|
||||
return { data: paths.find((item) => item.id === pathId) };
|
||||
} catch (error) {
|
||||
await client.query('rollback');
|
||||
if (error instanceof Error && /course|Publish/.test(error.message)) return reply.code(400).send({ error: error.message });
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/paths/:pathId', adminAccess, async (request, reply) => {
|
||||
const { pathId } = learningPathParamsSchema.parse(request.params);
|
||||
const result = await pool.query(`delete from learning_paths where id = $1 returning id, title`, [pathId]);
|
||||
if (!result.rows[0]) return reply.code(404).send({ error: 'Learning path not found' });
|
||||
await recordAudit({ actorId: request.user.id, action: 'path.deleted', subjectType: 'learning_path', subjectId: pathId, metadata: { title: result.rows[0].title }, ipAddress: request.ip });
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
app.post('/categories', adminAccess, async (request, reply) => {
|
||||
const input = categorySchema.parse(request.body);
|
||||
const result = await pool.query(
|
||||
|
||||
@@ -148,6 +148,34 @@ export const courseRoutes: FastifyPluginAsync = async (app) => {
|
||||
return { data: authenticated ? result.rows : result.rows.map(withoutProtectedMedia) };
|
||||
});
|
||||
|
||||
// Paths intentionally reference existing courses instead of copying them.
|
||||
// Only a path and its published courses are visible to visitors.
|
||||
app.get('/paths', async () => {
|
||||
const result = await pool.query(
|
||||
`select
|
||||
p.id, p.title, p.description,
|
||||
coalesce(p.cover_image_url, (
|
||||
select value #>> '{}' from platform_settings where key = 'media.defaultCoverImageUrl'
|
||||
)) as "coverImageUrl",
|
||||
p.cover_image_zoom as "coverImageZoom",
|
||||
p.cover_image_position_x as "coverImagePositionX",
|
||||
p.cover_image_position_y as "coverImagePositionY",
|
||||
coalesce(jsonb_agg(jsonb_build_object(
|
||||
'id', c.id, 'title', c.title, 'category', c.category,
|
||||
'coverImageUrl', coalesce(c.cover_image_url, (
|
||||
select value #>> '{}' from platform_settings where key = 'media.defaultCoverImageUrl'
|
||||
)), 'position', pc.position
|
||||
) order by pc.position) filter (where c.id is not null), '[]'::jsonb) as courses
|
||||
from learning_paths p
|
||||
join learning_path_courses pc on pc.learning_path_id = p.id
|
||||
join courses c on c.id = pc.course_id and c.status = 'published'
|
||||
where p.status = 'published'
|
||||
group by p.id
|
||||
order by p.published_at desc`,
|
||||
);
|
||||
return { data: result.rows };
|
||||
});
|
||||
|
||||
app.get('/me/learning', { preHandler: app.authenticate }, async (request) => {
|
||||
const result = await pool.query(
|
||||
`select base.*, coalesce(progress.progress, 0)::int as progress, progress."lastActivity"
|
||||
|
||||
@@ -27,6 +27,36 @@ export interface InstructorAnalytics { courses: number; lessons: number; learner
|
||||
export interface AuditEntry { id: string; action: string; subjectType: string; subjectId: string | null; metadata: Record<string, unknown>; createdAt: string; actorName: string; }
|
||||
export interface BunnyVideo { id: string; title: string; status: 'processing' | 'ready' | 'failed'; providerStatus: number; encodeProgress: number; durationSeconds: number | null; }
|
||||
export interface LessonPlayback { provider: string; kind: 'embed' | 'video'; embedUrl?: string; source?: string; expiresAt?: string; }
|
||||
export interface LearningPathCourse {
|
||||
id: string;
|
||||
title: string;
|
||||
status: 'draft' | 'published' | 'archived';
|
||||
coverImageUrl: string | null;
|
||||
position: number;
|
||||
}
|
||||
export interface LearningPath {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
coverImageUrl: string | null;
|
||||
coverImageZoom: number;
|
||||
coverImagePositionX: number;
|
||||
coverImagePositionY: number;
|
||||
status: 'draft' | 'published' | 'archived';
|
||||
publishedAt: string | null;
|
||||
createdAt: string;
|
||||
courses: LearningPathCourse[];
|
||||
}
|
||||
export interface LearningPathInput {
|
||||
title: string;
|
||||
description: string;
|
||||
coverImageUrl: string | null;
|
||||
coverImageZoom: number;
|
||||
coverImagePositionX: number;
|
||||
coverImagePositionY: number;
|
||||
status: 'draft' | 'published';
|
||||
courseIds: string[];
|
||||
}
|
||||
|
||||
interface Session {
|
||||
token: string;
|
||||
@@ -134,6 +164,10 @@ export const adminApi = {
|
||||
async categories() { return apiRequest<{ data: Array<{ id: string; name: string; position: number; isActive: boolean }> }>('/admin/categories'); },
|
||||
async createCategory(name: string) { return apiRequest<{ data: { id: string; name: string; position: number; isActive: boolean } }>('/admin/categories', { method: 'POST', body: JSON.stringify({ name }) }); },
|
||||
async updateCategory(categoryId: string, update: { name: string; isActive?: boolean; position?: number }) { return apiRequest<{ data: { id: string; name: string; position: number; isActive: boolean } }>(`/admin/categories/${categoryId}`, { method: 'PATCH', body: JSON.stringify(update) }); },
|
||||
async listPaths() { return apiRequest<{ data: LearningPath[] }>('/admin/paths'); },
|
||||
async createPath(input: LearningPathInput) { return apiRequest<{ data: LearningPath }>('/admin/paths', { method: 'POST', body: JSON.stringify(input) }); },
|
||||
async updatePath(pathId: string, input: LearningPathInput) { return apiRequest<{ data: LearningPath }>(`/admin/paths/${pathId}`, { method: 'PATCH', body: JSON.stringify(input) }); },
|
||||
async deletePath(pathId: string) { return apiRequest<void>(`/admin/paths/${pathId}`, { method: 'DELETE' }); },
|
||||
async homeConfiguration() { return apiRequest<{ data: { featuredCourseId: string | null; courseOrder: string[]; defaultCoverImageUrl: string | null; courses: Array<{ id: string; title: string; status: string }> } }>('/admin/home-configuration'); },
|
||||
async updateHomeConfiguration(input: { featuredCourseId: string | null; courseOrder: string[]; defaultCoverImageUrl: string | null }) { return apiRequest<{ data: typeof input }>('/admin/home-configuration', { method: 'PUT', body: JSON.stringify(input) }); },
|
||||
};
|
||||
|
||||
@@ -44,6 +44,23 @@ type ApiCourse = {
|
||||
assets: ApiAsset[];
|
||||
};
|
||||
|
||||
export type PublicLearningPath = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
coverImageUrl: string | null;
|
||||
coverImageZoom: number;
|
||||
coverImagePositionX: number;
|
||||
coverImagePositionY: number;
|
||||
courses: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
category: string;
|
||||
coverImageUrl: string | null;
|
||||
position: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
const toAttachmentType = (kind: ApiAsset['kind']): AttachmentType => {
|
||||
if (kind === 'document') return 'doc';
|
||||
if (kind === 'archive') return 'zip';
|
||||
@@ -173,6 +190,11 @@ export const getCourses = async (): Promise<Course[]> => {
|
||||
return response.data.map(toCourse);
|
||||
};
|
||||
|
||||
export const getLearningPaths = async (): Promise<PublicLearningPath[]> => {
|
||||
const response = await apiRequest<{ data: PublicLearningPath[] }>('/courses/paths');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getManagedCourses = async (): Promise<Course[]> => {
|
||||
const response = await apiRequest<{ data: ApiCourse[] }>('/manage/courses');
|
||||
return response.data.map(toCourse);
|
||||
|
||||
Reference in New Issue
Block a user