feat: add course cover positioning
This commit is contained in:
@@ -44,7 +44,10 @@ export const CourseCard: React.FC<CourseCardProps> = ({
|
|||||||
draggable={false}
|
draggable={false}
|
||||||
onDragStart={(e) => e.preventDefault()}
|
onDragStart={(e) => e.preventDefault()}
|
||||||
className="h-full w-full object-cover transition-opacity duration-300 opacity-90 group-hover:opacity-100 rounded-[8px] select-none pointer-events-none"
|
className="h-full w-full object-cover transition-opacity duration-300 opacity-90 group-hover:opacity-100 rounded-[8px] select-none pointer-events-none"
|
||||||
style={{ transform: `scale(${course.coverImageZoom || 1})` }}
|
style={{
|
||||||
|
transform: `scale(${course.coverImageZoom || 1})`,
|
||||||
|
objectPosition: `${course.coverImagePositionX ?? 50}% ${course.coverImagePositionY ?? 50}%`,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Badges on Thumbnail */}
|
{/* Badges on Thumbnail */}
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ export const Hero: React.FC<HeroProps> = ({ onPlay }) => {
|
|||||||
src={featuredCourse.thumbnail}
|
src={featuredCourse.thumbnail}
|
||||||
alt={featuredCourse.title}
|
alt={featuredCourse.title}
|
||||||
className="w-full h-full object-cover object-center"
|
className="w-full h-full object-cover object-center"
|
||||||
style={{ transform: `scale(${featuredCourse.coverImageZoom || 1})` }}
|
style={{
|
||||||
|
transform: `scale(${featuredCourse.coverImageZoom || 1})`,
|
||||||
|
objectPosition: `${featuredCourse.coverImagePositionX ?? 50}% ${featuredCourse.coverImagePositionY ?? 50}%`,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{/* Gradients for text readability and seamless transition */}
|
{/* Gradients for text readability and seamless transition */}
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/60 to-black/75" />
|
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/60 to-black/75" />
|
||||||
|
|||||||
@@ -136,7 +136,10 @@ export const SearchModal: React.FC<SearchModalProps> = ({ isOpen, onClose, onPla
|
|||||||
src={course.thumbnail}
|
src={course.thumbnail}
|
||||||
alt={course.title}
|
alt={course.title}
|
||||||
className="w-full h-full object-cover opacity-80 group-hover:opacity-100 group-hover:scale-105 transition-all duration-300 rounded-[8px]"
|
className="w-full h-full object-cover opacity-80 group-hover:opacity-100 group-hover:scale-105 transition-all duration-300 rounded-[8px]"
|
||||||
style={{ transform: `scale(${course.coverImageZoom || 1})` }}
|
style={{
|
||||||
|
transform: `scale(${course.coverImageZoom || 1})`,
|
||||||
|
objectPosition: `${course.coverImagePositionX ?? 50}% ${course.coverImagePositionY ?? 50}%`,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity bg-black/40">
|
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity bg-black/40">
|
||||||
<PlayCircle className="w-10 h-10 text-orange-500" />
|
<PlayCircle className="w-10 h-10 text-orange-500" />
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
Move,
|
||||||
|
RotateCcw,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Course, Comment, Attachment, Lesson, AttachmentType, LessonMedia } from '../types';
|
import { Course, Comment, Attachment, Lesson, AttachmentType, LessonMedia } from '../types';
|
||||||
import { useToast } from '../context/ToastContext';
|
import { useToast } from '../context/ToastContext';
|
||||||
@@ -52,9 +54,13 @@ const CourseModal: React.FC<{
|
|||||||
const [thumbnailPreview, setThumbnailPreview] = useState<string>('');
|
const [thumbnailPreview, setThumbnailPreview] = useState<string>('');
|
||||||
const [coverImageUrl, setCoverImageUrl] = useState<string>('');
|
const [coverImageUrl, setCoverImageUrl] = useState<string>('');
|
||||||
const [coverImageZoom, setCoverImageZoom] = useState(1);
|
const [coverImageZoom, setCoverImageZoom] = useState(1);
|
||||||
|
const [coverImagePositionX, setCoverImagePositionX] = useState(50);
|
||||||
|
const [coverImagePositionY, setCoverImagePositionY] = useState(50);
|
||||||
|
const [isAdjustingCoverPosition, setIsAdjustingCoverPosition] = useState(false);
|
||||||
const [coverPreviewError, setCoverPreviewError] = useState(false);
|
const [coverPreviewError, setCoverPreviewError] = useState(false);
|
||||||
const coverFileRef = useRef<HTMLInputElement>(null);
|
const coverFileRef = useRef<HTMLInputElement>(null);
|
||||||
const localCoverPreviewRef = useRef<string | null>(null);
|
const localCoverPreviewRef = useRef<string | null>(null);
|
||||||
|
const coverDragStartRef = useRef<{ pointerX: number; pointerY: number; positionX: number; positionY: number } | null>(null);
|
||||||
const [categories, setCategories] = useState<string[]>([]);
|
const [categories, setCategories] = useState<string[]>([]);
|
||||||
const [coverConfigured, setCoverConfigured] = useState<boolean | null>(null);
|
const [coverConfigured, setCoverConfigured] = useState<boolean | null>(null);
|
||||||
const [coverUploadMessage, setCoverUploadMessage] = useState('');
|
const [coverUploadMessage, setCoverUploadMessage] = useState('');
|
||||||
@@ -98,6 +104,8 @@ const CourseModal: React.FC<{
|
|||||||
setThumbnailPreview(initialData.thumbnail);
|
setThumbnailPreview(initialData.thumbnail);
|
||||||
setCoverImageUrl(initialData.thumbnail);
|
setCoverImageUrl(initialData.thumbnail);
|
||||||
setCoverImageZoom(initialData.coverImageZoom || 1);
|
setCoverImageZoom(initialData.coverImageZoom || 1);
|
||||||
|
setCoverImagePositionX(initialData.coverImagePositionX ?? 50);
|
||||||
|
setCoverImagePositionY(initialData.coverImagePositionY ?? 50);
|
||||||
setTips(initialData.tips || []);
|
setTips(initialData.tips || []);
|
||||||
setAttachments(initialData.attachments || []);
|
setAttachments(initialData.attachments || []);
|
||||||
setLessons(initialData.lessons || []);
|
setLessons(initialData.lessons || []);
|
||||||
@@ -109,6 +117,8 @@ const CourseModal: React.FC<{
|
|||||||
setThumbnailPreview('');
|
setThumbnailPreview('');
|
||||||
setCoverImageUrl('');
|
setCoverImageUrl('');
|
||||||
setCoverImageZoom(1);
|
setCoverImageZoom(1);
|
||||||
|
setCoverImagePositionX(50);
|
||||||
|
setCoverImagePositionY(50);
|
||||||
setTips([]);
|
setTips([]);
|
||||||
setAttachments([]);
|
setAttachments([]);
|
||||||
setLessons([]);
|
setLessons([]);
|
||||||
@@ -118,6 +128,7 @@ const CourseModal: React.FC<{
|
|||||||
localCoverPreviewRef.current = null;
|
localCoverPreviewRef.current = null;
|
||||||
}
|
}
|
||||||
setCoverPreviewError(false);
|
setCoverPreviewError(false);
|
||||||
|
setIsAdjustingCoverPosition(false);
|
||||||
setNewLessonMedia(null);
|
setNewLessonMedia(null);
|
||||||
setBunnyUploadState('idle');
|
setBunnyUploadState('idle');
|
||||||
setBunnyUploadMessage('');
|
setBunnyUploadMessage('');
|
||||||
@@ -199,6 +210,9 @@ const CourseModal: React.FC<{
|
|||||||
localCoverPreviewRef.current = localPreviewUrl;
|
localCoverPreviewRef.current = localPreviewUrl;
|
||||||
setThumbnailPreview(localPreviewUrl);
|
setThumbnailPreview(localPreviewUrl);
|
||||||
setCoverImageUrl(response.data.coverImageUrl);
|
setCoverImageUrl(response.data.coverImageUrl);
|
||||||
|
setCoverImageZoom(1);
|
||||||
|
setCoverImagePositionX(50);
|
||||||
|
setCoverImagePositionY(50);
|
||||||
setCoverPreviewError(false);
|
setCoverPreviewError(false);
|
||||||
setCoverUploadMessage('Imagem de capa enviada com sucesso.');
|
setCoverUploadMessage('Imagem de capa enviada com sucesso.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -208,6 +222,32 @@ const CourseModal: React.FC<{
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const startCoverPositioning = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
coverDragStartRef.current = {
|
||||||
|
pointerX: event.clientX,
|
||||||
|
pointerY: event.clientY,
|
||||||
|
positionX: coverImagePositionX,
|
||||||
|
positionY: coverImagePositionY,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveCoverPosition = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
const start = coverDragStartRef.current;
|
||||||
|
if (!start) return;
|
||||||
|
const bounds = event.currentTarget.getBoundingClientRect();
|
||||||
|
const nextX = Math.min(100, Math.max(0, start.positionX - ((event.clientX - start.pointerX) / bounds.width) * 100));
|
||||||
|
const nextY = Math.min(100, Math.max(0, start.positionY - ((event.clientY - start.pointerY) / bounds.height) * 100));
|
||||||
|
setCoverImagePositionX(Number(nextX.toFixed(1)));
|
||||||
|
setCoverImagePositionY(Number(nextY.toFixed(1)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const endCoverPositioning = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
coverDragStartRef.current = null;
|
||||||
|
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
|
||||||
|
};
|
||||||
|
|
||||||
const refreshLessonMedia = async (lessonId: string) => {
|
const refreshLessonMedia = async (lessonId: string) => {
|
||||||
const lesson = lessons.find((item) => item.id === lessonId);
|
const lesson = lessons.find((item) => item.id === lessonId);
|
||||||
const media = lesson?.media?.[0];
|
const media = lesson?.media?.[0];
|
||||||
@@ -377,6 +417,8 @@ const CourseModal: React.FC<{
|
|||||||
status,
|
status,
|
||||||
thumbnail: coverImageUrl || '/course-placeholder.svg',
|
thumbnail: coverImageUrl || '/course-placeholder.svg',
|
||||||
coverImageZoom,
|
coverImageZoom,
|
||||||
|
coverImagePositionX,
|
||||||
|
coverImagePositionY,
|
||||||
duration: durationStr,
|
duration: durationStr,
|
||||||
instructor: initialData?.instructor || '',
|
instructor: initialData?.instructor || '',
|
||||||
views: initialData?.views || 0,
|
views: initialData?.views || 0,
|
||||||
@@ -448,10 +490,27 @@ const CourseModal: React.FC<{
|
|||||||
alt="Prévia da imagem de capa"
|
alt="Prévia da imagem de capa"
|
||||||
onError={() => setCoverPreviewError(true)}
|
onError={() => setCoverPreviewError(true)}
|
||||||
className="h-full w-full object-cover opacity-80 transition-transform duration-200"
|
className="h-full w-full object-cover opacity-80 transition-transform duration-200"
|
||||||
style={{ transform: `scale(${coverImageZoom})` }}
|
style={{
|
||||||
|
transform: `scale(${coverImageZoom})`,
|
||||||
|
objectPosition: `${coverImagePositionX}% ${coverImagePositionY}%`,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="absolute inset-x-3 bottom-3 flex items-center justify-between gap-3">
|
{isAdjustingCoverPosition && !coverPreviewError && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 z-10 cursor-grab touch-none active:cursor-grabbing"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onPointerDown={startCoverPositioning}
|
||||||
|
onPointerMove={moveCoverPosition}
|
||||||
|
onPointerUp={endCoverPositioning}
|
||||||
|
onPointerCancel={endCoverPositioning}
|
||||||
|
>
|
||||||
|
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-lg bg-black/75 px-4 py-2 text-center text-xs font-semibold text-white backdrop-blur-sm">
|
||||||
|
Arraste a imagem para ajustar o enquadramento
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-x-3 bottom-3 z-20 flex items-center justify-between gap-3">
|
||||||
<span className="rounded-lg bg-black/70 px-3 py-2 text-xs font-semibold text-white backdrop-blur-sm">Clique na imagem para alterar</span>
|
<span className="rounded-lg bg-black/70 px-3 py-2 text-xs font-semibold text-white backdrop-blur-sm">Clique na imagem para alterar</span>
|
||||||
<div className="flex items-center rounded-lg border border-white/10 bg-black/70 p-1 backdrop-blur-sm">
|
<div className="flex items-center rounded-lg border border-white/10 bg-black/70 p-1 backdrop-blur-sm">
|
||||||
<button
|
<button
|
||||||
@@ -469,6 +528,20 @@ const CourseModal: React.FC<{
|
|||||||
onClick={(event) => { event.stopPropagation(); setCoverImageZoom((current) => Math.min(2.5, Number((current + 0.1).toFixed(1)))); }}
|
onClick={(event) => { event.stopPropagation(); setCoverImageZoom((current) => Math.min(2.5, Number((current + 0.1).toFixed(1)))); }}
|
||||||
className="rounded-md p-1.5 text-white transition-colors hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-30"
|
className="rounded-md p-1.5 text-white transition-colors hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-30"
|
||||||
><PlusCircle className="h-4 w-4" /></button>
|
><PlusCircle className="h-4 w-4" /></button>
|
||||||
|
<span className="mx-1 h-5 w-px bg-white/10" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Ajustar posição da imagem"
|
||||||
|
aria-pressed={isAdjustingCoverPosition}
|
||||||
|
onClick={(event) => { event.stopPropagation(); setIsAdjustingCoverPosition((current) => !current); }}
|
||||||
|
className={`rounded-md p-1.5 transition-colors ${isAdjustingCoverPosition ? 'bg-orange-500 text-white' : 'text-white hover:bg-white/10'}`}
|
||||||
|
><Move className="h-4 w-4" /></button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Redefinir zoom e posição da imagem"
|
||||||
|
onClick={(event) => { event.stopPropagation(); setCoverImageZoom(1); setCoverImagePositionX(50); setCoverImagePositionY(50); setIsAdjustingCoverPosition(false); }}
|
||||||
|
className="rounded-md p-1.5 text-white transition-colors hover:bg-white/10"
|
||||||
|
><RotateCcw className="h-4 w-4" /></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -985,7 +1058,10 @@ export const ManageCourses: React.FC = () => {
|
|||||||
src={course.thumbnail}
|
src={course.thumbnail}
|
||||||
alt={course.title}
|
alt={course.title}
|
||||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||||
style={{ transform: `scale(${course.coverImageZoom || 1})` }}
|
style={{
|
||||||
|
transform: `scale(${course.coverImageZoom || 1})`,
|
||||||
|
objectPosition: `${course.coverImagePositionX ?? 50}% ${course.coverImagePositionY ?? 50}%`,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="absolute top-3 left-3 bg-black/70 backdrop-blur-md px-2.5 py-1 rounded-[980px] text-caption font-semibold uppercase tracking-caption text-white border border-white/10">
|
<div className="absolute top-3 left-3 bg-black/70 backdrop-blur-md px-2.5 py-1 rounded-[980px] text-caption font-semibold uppercase tracking-caption text-white border border-white/10">
|
||||||
{course.category}
|
{course.category}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export const MyLearning: React.FC<{ onPlay: (course: Course) => void }> = ({ onP
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
|
||||||
{courses.map((course) => (
|
{courses.map((course) => (
|
||||||
<button key={course.id} onClick={() => onPlay(course)} className="group overflow-hidden rounded-2xl border border-white/10 bg-zinc-950/80 text-left hover:border-orange-500/50 transition-colors">
|
<button key={course.id} onClick={() => onPlay(course)} className="group overflow-hidden rounded-2xl border border-white/10 bg-zinc-950/80 text-left hover:border-orange-500/50 transition-colors">
|
||||||
<div className="aspect-video relative overflow-hidden bg-zinc-900"><img src={course.thumbnail} alt="" className="h-full w-full object-cover opacity-75 transition-opacity group-hover:opacity-100" style={{ transform: `scale(${course.coverImageZoom || 1})` }} /><div className="absolute inset-0 bg-gradient-to-t from-black/80 to-transparent" /><span className="absolute left-4 bottom-4 inline-flex items-center gap-2 rounded-full bg-orange-500 px-3 py-2 text-xs font-bold text-white"><PlayCircle className="w-4 h-4" /> Continuar</span></div>
|
<div className="aspect-video relative overflow-hidden bg-zinc-900"><img src={course.thumbnail} alt="" className="h-full w-full object-cover opacity-75 transition-opacity group-hover:opacity-100" style={{ transform: `scale(${course.coverImageZoom || 1})`, objectPosition: `${course.coverImagePositionX ?? 50}% ${course.coverImagePositionY ?? 50}%` }} /><div className="absolute inset-0 bg-gradient-to-t from-black/80 to-transparent" /><span className="absolute left-4 bottom-4 inline-flex items-center gap-2 rounded-full bg-orange-500 px-3 py-2 text-xs font-bold text-white"><PlayCircle className="w-4 h-4" /> Continuar</span></div>
|
||||||
<div className="p-5"><p className="text-xs text-orange-400 font-semibold uppercase tracking-wider">{course.category}</p><h2 className="mt-2 font-bold text-white">{course.title}</h2><div className="mt-4 h-1.5 overflow-hidden rounded-full bg-white/10"><div className="h-full rounded-full bg-orange-500" style={{ width: `${course.progress || 0}%` }} /></div><p className="mt-2 text-xs text-gray-400">{course.progress || 0}% concluído</p></div>
|
<div className="p-5"><p className="text-xs text-orange-400 font-semibold uppercase tracking-wider">{course.category}</p><h2 className="mt-2 font-bold text-white">{course.title}</h2><div className="mt-4 h-1.5 overflow-hidden rounded-full bg-white/10"><div className="h-full rounded-full bg-orange-500" style={{ width: `${course.progress || 0}%` }} /></div><p className="mt-2 text-xs text-gray-400">{course.progress || 0}% concluído</p></div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
5
server/migrations/008_course_cover_position.sql
Normal file
5
server/migrations/008_course_cover_position.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
alter table courses
|
||||||
|
add column cover_image_position_x real not null default 50
|
||||||
|
check (cover_image_position_x >= 0 and cover_image_position_x <= 100),
|
||||||
|
add column cover_image_position_y real not null default 50
|
||||||
|
check (cover_image_position_y >= 0 and cover_image_position_y <= 100);
|
||||||
@@ -22,6 +22,8 @@ export const courseSelect = (publicOnly = false, includeUnreadyMedia = false) =>
|
|||||||
select value #>> '{}' from platform_settings where key = 'media.defaultCoverImageUrl'
|
select value #>> '{}' from platform_settings where key = 'media.defaultCoverImageUrl'
|
||||||
)) as "coverImageUrl",
|
)) as "coverImageUrl",
|
||||||
c.cover_image_zoom as "coverImageZoom",
|
c.cover_image_zoom as "coverImageZoom",
|
||||||
|
c.cover_image_position_x as "coverImagePositionX",
|
||||||
|
c.cover_image_position_y as "coverImagePositionY",
|
||||||
c.status,
|
c.status,
|
||||||
c.published_at as "publishedAt",
|
c.published_at as "publishedAt",
|
||||||
jsonb_build_object(
|
jsonb_build_object(
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ const courseSchema = z.object({
|
|||||||
category: z.string().trim().min(1).max(120),
|
category: z.string().trim().min(1).max(120),
|
||||||
coverImageUrl: coverImageUrlSchema.nullable().optional(),
|
coverImageUrl: coverImageUrlSchema.nullable().optional(),
|
||||||
coverImageZoom: z.number().min(1).max(2.5).default(1),
|
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'),
|
status: z.enum(['draft', 'published']).default('draft'),
|
||||||
lessons: z.array(lessonSchema).min(1),
|
lessons: z.array(lessonSchema).min(1),
|
||||||
assets: z.array(assetSchema).default([]),
|
assets: z.array(assetSchema).default([]),
|
||||||
@@ -214,10 +216,10 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
await client.query('begin');
|
await client.query('begin');
|
||||||
const slug = await uniqueSlug(client, input.slug, input.title);
|
const slug = await uniqueSlug(client, input.slug, input.title);
|
||||||
const course = await client.query<{ id: string }>(
|
const course = await client.query<{ id: string }>(
|
||||||
`insert into courses (slug, title, description, category, cover_image_url, cover_image_zoom, status, instructor_id, published_at)
|
`insert into courses (slug, title, description, category, cover_image_url, cover_image_zoom, cover_image_position_x, cover_image_position_y, status, instructor_id, published_at)
|
||||||
values ($1, $2, $3, $4, $5, $6, $7::course_status, $8, case when $7::course_status = 'published'::course_status then now() else null end)
|
values ($1, $2, $3, $4, $5, $6, $7, $8, $9::course_status, $10, case when $9::course_status = 'published'::course_status then now() else null end)
|
||||||
returning id`,
|
returning id`,
|
||||||
[slug, input.title, input.description, input.category, input.coverImageUrl ?? null, input.coverImageZoom, input.status, request.user.id],
|
[slug, input.title, input.description, input.category, input.coverImageUrl ?? null, input.coverImageZoom, input.coverImagePositionX, input.coverImagePositionY, input.status, request.user.id],
|
||||||
);
|
);
|
||||||
await replaceCourseContents(client, course.rows[0].id, input);
|
await replaceCourseContents(client, course.rows[0].id, input);
|
||||||
await client.query('commit');
|
await client.query('commit');
|
||||||
@@ -246,10 +248,10 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
const slug = await uniqueSlug(client, input.slug, input.title, courseId);
|
const slug = await uniqueSlug(client, input.slug, input.title, courseId);
|
||||||
await client.query(
|
await client.query(
|
||||||
`update courses
|
`update courses
|
||||||
set slug = $2, title = $3, description = $4, category = $5, cover_image_url = $6, cover_image_zoom = $7,
|
set slug = $2, title = $3, description = $4, category = $5, cover_image_url = $6, cover_image_zoom = $7, cover_image_position_x = $8, cover_image_position_y = $9,
|
||||||
status = $8::course_status, published_at = case when $8::course_status = 'published'::course_status then coalesce(published_at, now()) else null end
|
status = $10::course_status, published_at = case when $10::course_status = 'published'::course_status then coalesce(published_at, now()) else null end
|
||||||
where id = $1`,
|
where id = $1`,
|
||||||
[courseId, slug, input.title, input.description, input.category, input.coverImageUrl ?? null, input.coverImageZoom, input.status],
|
[courseId, slug, input.title, input.description, input.category, input.coverImageUrl ?? null, input.coverImageZoom, input.coverImagePositionX, input.coverImagePositionY, input.status],
|
||||||
);
|
);
|
||||||
await replaceCourseContents(client, courseId, input);
|
await replaceCourseContents(client, courseId, input);
|
||||||
await client.query('commit');
|
await client.query('commit');
|
||||||
@@ -281,8 +283,8 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
await client.query('begin');
|
await client.query('begin');
|
||||||
const original = await client.query<{ title: string; description: string; category: string; cover_image_url: string | null; cover_image_zoom: number; instructor_id: string }>(
|
const original = await client.query<{ title: string; description: string; category: string; cover_image_url: string | null; cover_image_zoom: number; cover_image_position_x: number; cover_image_position_y: number; instructor_id: string }>(
|
||||||
`select title, description, category, cover_image_url, cover_image_zoom, instructor_id from courses where id = $1`, [courseId],
|
`select title, description, category, cover_image_url, cover_image_zoom, cover_image_position_x, cover_image_position_y, instructor_id from courses where id = $1`, [courseId],
|
||||||
);
|
);
|
||||||
if (!original.rows[0]) {
|
if (!original.rows[0]) {
|
||||||
await client.query('rollback');
|
await client.query('rollback');
|
||||||
@@ -292,9 +294,9 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
const title = `${source.title} (cópia)`;
|
const title = `${source.title} (cópia)`;
|
||||||
const slug = await uniqueSlug(client, undefined, title);
|
const slug = await uniqueSlug(client, undefined, title);
|
||||||
const copiedCourse = await client.query<{ id: string }>(
|
const copiedCourse = await client.query<{ id: string }>(
|
||||||
`insert into courses (slug, title, description, category, cover_image_url, cover_image_zoom, status, instructor_id)
|
`insert into courses (slug, title, description, category, cover_image_url, cover_image_zoom, cover_image_position_x, cover_image_position_y, status, instructor_id)
|
||||||
values ($1, $2, $3, $4, $5, $6, 'draft', $7) returning id`,
|
values ($1, $2, $3, $4, $5, $6, $7, $8, 'draft', $9) returning id`,
|
||||||
[slug, title, source.description, source.category, source.cover_image_url, source.cover_image_zoom, source.instructor_id],
|
[slug, title, source.description, source.category, source.cover_image_url, source.cover_image_zoom, source.cover_image_position_x, source.cover_image_position_y, source.instructor_id],
|
||||||
);
|
);
|
||||||
const newCourseId = copiedCourse.rows[0].id;
|
const newCourseId = copiedCourse.rows[0].id;
|
||||||
await client.query(
|
await client.query(
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ type ApiCourse = {
|
|||||||
category: string;
|
category: string;
|
||||||
coverImageUrl: string | null;
|
coverImageUrl: string | null;
|
||||||
coverImageZoom: number;
|
coverImageZoom: number;
|
||||||
|
coverImagePositionX: number;
|
||||||
|
coverImagePositionY: number;
|
||||||
status: 'draft' | 'published' | 'archived';
|
status: 'draft' | 'published' | 'archived';
|
||||||
progress?: number;
|
progress?: number;
|
||||||
lastActivity?: string | null;
|
lastActivity?: string | null;
|
||||||
@@ -69,6 +71,8 @@ const toCourse = (course: ApiCourse): Course => ({
|
|||||||
category: course.category,
|
category: course.category,
|
||||||
thumbnail: course.coverImageUrl || '/course-placeholder.svg',
|
thumbnail: course.coverImageUrl || '/course-placeholder.svg',
|
||||||
coverImageZoom: course.coverImageZoom || 1,
|
coverImageZoom: course.coverImageZoom || 1,
|
||||||
|
coverImagePositionX: course.coverImagePositionX ?? 50,
|
||||||
|
coverImagePositionY: course.coverImagePositionY ?? 50,
|
||||||
instructor: course.instructor.name,
|
instructor: course.instructor.name,
|
||||||
status: course.status === 'draft' ? 'draft' : 'published',
|
status: course.status === 'draft' ? 'draft' : 'published',
|
||||||
progress: course.progress,
|
progress: course.progress,
|
||||||
@@ -127,6 +131,8 @@ const toCoursePayload = (course: Course) => ({
|
|||||||
category: course.category,
|
category: course.category,
|
||||||
coverImageUrl: course.thumbnail && /^https?:\/\//.test(course.thumbnail) ? course.thumbnail : null,
|
coverImageUrl: course.thumbnail && /^https?:\/\//.test(course.thumbnail) ? course.thumbnail : null,
|
||||||
coverImageZoom: course.coverImageZoom || 1,
|
coverImageZoom: course.coverImageZoom || 1,
|
||||||
|
coverImagePositionX: course.coverImagePositionX ?? 50,
|
||||||
|
coverImagePositionY: course.coverImagePositionY ?? 50,
|
||||||
status: course.status || 'published',
|
status: course.status || 'published',
|
||||||
assets: (course.attachments || []).filter((attachment) => attachment.url && attachment.url !== '#').map(toAssetPayload),
|
assets: (course.attachments || []).filter((attachment) => attachment.url && attachment.url !== '#').map(toAssetPayload),
|
||||||
lessons: course.lessons.map((lesson) => ({
|
lessons: course.lessons.map((lesson) => ({
|
||||||
|
|||||||
2
types.ts
2
types.ts
@@ -43,6 +43,8 @@ export interface Course {
|
|||||||
category: string;
|
category: string;
|
||||||
thumbnail: string;
|
thumbnail: string;
|
||||||
coverImageZoom?: number;
|
coverImageZoom?: number;
|
||||||
|
coverImagePositionX?: number;
|
||||||
|
coverImagePositionY?: number;
|
||||||
lessons: Lesson[];
|
lessons: Lesson[];
|
||||||
progress?: number; // 0 to 100
|
progress?: number; // 0 to 100
|
||||||
views?: number;
|
views?: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user