305 lines
10 KiB
TypeScript
305 lines
10 KiB
TypeScript
import { Attachment, AttachmentType, Comment, Course, Lesson, LessonMedia } from '../types';
|
|
import { apiRequest } from './api';
|
|
|
|
export const initDB = async (): Promise<void> => undefined;
|
|
|
|
type ApiAsset = {
|
|
id: string;
|
|
name: string;
|
|
kind: 'document' | 'spreadsheet' | 'archive' | 'image' | 'link';
|
|
url: string;
|
|
description: string;
|
|
};
|
|
|
|
type ApiCourse = {
|
|
id: string;
|
|
title: string;
|
|
description: string;
|
|
category: string;
|
|
coverImageUrl: string | null;
|
|
coverImageZoom: number;
|
|
coverImagePositionX: number;
|
|
coverImagePositionY: number;
|
|
status: 'draft' | 'published' | 'archived';
|
|
progress?: number;
|
|
lastActivity?: string | null;
|
|
instructor: { name: string };
|
|
lessons: Array<{
|
|
id: string;
|
|
title: string;
|
|
description: string;
|
|
durationSeconds: number | null;
|
|
accessLevel: 'public' | 'enrolled';
|
|
media: Array<{
|
|
id: string;
|
|
provider: string;
|
|
externalId: string;
|
|
playbackUrl: string | null;
|
|
embedUrl: string | null;
|
|
status: 'processing' | 'ready' | 'failed';
|
|
durationSeconds: number | null;
|
|
}>;
|
|
assets: ApiAsset[];
|
|
}>;
|
|
assets: ApiAsset[];
|
|
};
|
|
|
|
export type PublicLearningPath = {
|
|
id: string;
|
|
title: string;
|
|
description: string;
|
|
kind: 'trail' | 'formation';
|
|
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';
|
|
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 || seconds < 1) return '—';
|
|
const totalSeconds = Math.round(seconds);
|
|
const hours = Math.floor(totalSeconds / 3600);
|
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
const remainingSeconds = totalSeconds % 60;
|
|
if (hours) return `${hours}h ${String(minutes).padStart(2, '0')}min`;
|
|
if (minutes) return `${minutes}min`;
|
|
return `${remainingSeconds}s`;
|
|
};
|
|
|
|
const toCourseDuration = (lessons: ApiCourse['lessons']) => {
|
|
const seconds = lessons.reduce((total, lesson) => total + (lesson.media[0]?.durationSeconds ?? lesson.durationSeconds ?? 0), 0);
|
|
if (seconds) return toDuration(seconds);
|
|
return lessons.length ? 'Processando' : '—';
|
|
};
|
|
|
|
const toCourse = (course: ApiCourse): Course => ({
|
|
id: course.id,
|
|
title: course.title,
|
|
description: course.description,
|
|
category: course.category,
|
|
thumbnail: course.coverImageUrl || '/course-placeholder.svg',
|
|
coverImageZoom: course.coverImageZoom || 1,
|
|
coverImagePositionX: course.coverImagePositionX ?? 50,
|
|
coverImagePositionY: course.coverImagePositionY ?? 50,
|
|
instructor: course.instructor.name,
|
|
status: course.status === 'draft' ? 'draft' : 'published',
|
|
progress: course.progress,
|
|
lastActivity: course.lastActivity || undefined,
|
|
duration: toCourseDuration(course.lessons),
|
|
lessons: course.lessons.map((lesson): Lesson => {
|
|
const media: LessonMedia[] = lesson.media.map((item) => ({
|
|
id: item.id,
|
|
provider: item.provider,
|
|
externalId: item.externalId,
|
|
playbackUrl: item.playbackUrl,
|
|
embedUrl: item.embedUrl,
|
|
status: item.status,
|
|
durationSeconds: item.durationSeconds,
|
|
}));
|
|
return {
|
|
id: lesson.id,
|
|
title: lesson.title,
|
|
description: lesson.description,
|
|
duration: toDuration(media[0]?.durationSeconds ?? lesson.durationSeconds),
|
|
isFree: lesson.accessLevel === 'public',
|
|
videoUrl: media[0]?.playbackUrl || media[0]?.embedUrl || '',
|
|
mediaProvider: media[0]?.provider,
|
|
media,
|
|
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 && /^https?:\/\//.test(course.thumbnail) ? course.thumbnail : null,
|
|
coverImageZoom: course.coverImageZoom || 1,
|
|
coverImagePositionX: course.coverImagePositionX ?? 50,
|
|
coverImagePositionY: course.coverImagePositionY ?? 50,
|
|
status: course.status || 'published',
|
|
assets: (course.attachments || []).filter((attachment) => attachment.url && attachment.url !== '#').map(toAssetPayload),
|
|
lessons: course.lessons.map((lesson) => ({
|
|
id: /^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(lesson.id) ? lesson.id : undefined,
|
|
title: lesson.title,
|
|
description: lesson.description || '',
|
|
durationSeconds: lesson.media?.[0]?.durationSeconds ?? toSeconds(lesson.duration),
|
|
accessLevel: lesson.isFree ? 'public' as const : 'enrolled' as const,
|
|
media: lesson.media?.length
|
|
? lesson.media.map((media) => ({
|
|
provider: media.provider,
|
|
externalId: media.externalId,
|
|
playbackUrl: media.playbackUrl || null,
|
|
embedUrl: media.embedUrl || null,
|
|
status: media.status,
|
|
durationSeconds: media.durationSeconds || null,
|
|
}))
|
|
: lesson.videoUrl && !lesson.videoUrl.startsWith('blob:')
|
|
? [{ provider: lesson.mediaProvider || 'external', externalId: lesson.videoUrl, playbackUrl: lesson.videoUrl, status: 'ready' as const }]
|
|
: [],
|
|
assets: (lesson.attachments || []).filter((attachment) => attachment.url && attachment.url !== '#').map(toAssetPayload),
|
|
})),
|
|
});
|
|
|
|
export const getCourses = async (): Promise<Course[]> => {
|
|
const response = await apiRequest<{ data: ApiCourse[] }>('/courses');
|
|
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);
|
|
};
|
|
|
|
export const getMyLearningCourses = async (): Promise<Course[]> => {
|
|
const response = await apiRequest<{ data: ApiCourse[] }>('/courses/me/learning');
|
|
return response.data.map(toCourse);
|
|
};
|
|
|
|
export const getCourseById = async (id: string): Promise<Course | null> => {
|
|
try {
|
|
const response = await apiRequest<{ data: ApiCourse }>(`/courses/${id}`);
|
|
return toCourse(response.data);
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
export const saveCourse = async (course: Course): Promise<void> => {
|
|
const payload = toCoursePayload(course);
|
|
const isExisting = /^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(course.id);
|
|
if (isExisting) {
|
|
await apiRequest(`/manage/courses/${course.id}`, { method: 'PATCH', body: JSON.stringify(payload) });
|
|
} else {
|
|
await apiRequest('/manage/courses', { method: 'POST', body: JSON.stringify(payload) });
|
|
}
|
|
};
|
|
|
|
export const deleteCourse = async (id: string): Promise<void> => {
|
|
await apiRequest(`/manage/courses/${id}`, { method: 'DELETE' });
|
|
};
|
|
|
|
export const duplicateCourse = async (id: string): Promise<void> => {
|
|
await apiRequest(`/manage/courses/${id}/duplicate`, { method: 'POST' });
|
|
};
|
|
|
|
type ApiComment = {
|
|
id: string;
|
|
courseId: string;
|
|
lessonId: string | null;
|
|
user: string;
|
|
text: string;
|
|
createdAt: string;
|
|
reply?: string | null;
|
|
replyCreatedAt?: string | null;
|
|
};
|
|
|
|
const toComment = (comment: ApiComment): Comment => ({
|
|
id: comment.id,
|
|
courseId: comment.courseId,
|
|
lessonId: comment.lessonId || undefined,
|
|
user: comment.user,
|
|
text: comment.text,
|
|
date: new Intl.DateTimeFormat('pt-BR').format(new Date(comment.createdAt)),
|
|
reply: comment.reply || undefined,
|
|
replyDate: comment.replyCreatedAt
|
|
? new Intl.DateTimeFormat('pt-BR').format(new Date(comment.replyCreatedAt))
|
|
: undefined,
|
|
});
|
|
|
|
export const getComments = async (courseId: string): Promise<Comment[]> => {
|
|
const response = await apiRequest<{ data: ApiComment[] }>(`/courses/${courseId}/comments`);
|
|
return response.data.map(toComment);
|
|
};
|
|
|
|
export const saveComment = async (comment: Pick<Comment, 'courseId' | 'lessonId' | 'text'>): Promise<Comment> => {
|
|
const response = await apiRequest<{ data: ApiComment }>(`/courses/${comment.courseId}/comments`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ lessonId: comment.lessonId || null, text: comment.text }),
|
|
});
|
|
return toComment(response.data);
|
|
};
|
|
|
|
export const replyToComment = async (commentId: string, text: string): Promise<void> => {
|
|
await apiRequest(`/comments/${commentId}/reply`, { method: 'PUT', body: JSON.stringify({ text }) });
|
|
};
|
|
|
|
export const moderateComment = async (commentId: string): Promise<void> => {
|
|
await apiRequest(`/comments/${commentId}`, { method: 'DELETE' });
|
|
};
|
|
|
|
export const getCompletedLessonIds = async (courseId: string): Promise<string[]> => {
|
|
const progress = await getLessonProgress(courseId);
|
|
return progress.filter((item) => item.completedAt).map((item) => item.lessonId);
|
|
};
|
|
|
|
export type LessonProgress = { lessonId: string; watchedSeconds: number; completedAt: string | null };
|
|
|
|
export const getLessonProgress = async (courseId: string): Promise<LessonProgress[]> => {
|
|
const response = await apiRequest<{ data: LessonProgress[] }>(`/courses/${courseId}/progress`);
|
|
return response.data;
|
|
};
|
|
|
|
export const saveLessonCompletion = async (lessonId: string, completed: boolean, watchedSeconds?: number): Promise<void> => {
|
|
await apiRequest(`/lessons/${lessonId}/progress`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ completed, watchedSeconds }),
|
|
});
|
|
};
|
|
|
|
export const saveLessonProgress = async (lessonId: string, watchedSeconds: number): Promise<void> => {
|
|
await apiRequest(`/lessons/${lessonId}/progress`, { method: 'PUT', body: JSON.stringify({ watchedSeconds }) });
|
|
};
|
|
|
|
export const incrementViews = async (_courseId: string): Promise<void> => undefined;
|