feat: add course cover positioning
All checks were successful
CI / Validate frontend and API (push) Successful in 37s
CI / Build and publish Docker images (push) Successful in 18s

This commit is contained in:
Cauê Faleiros
2026-09-04 14:42:04 -03:00
parent ad860074fd
commit 7a01be2b59
10 changed files with 120 additions and 18 deletions

View File

@@ -31,6 +31,8 @@ import {
ChevronDown,
ChevronLeft,
ChevronRight,
Move,
RotateCcw,
} from 'lucide-react';
import { Course, Comment, Attachment, Lesson, AttachmentType, LessonMedia } from '../types';
import { useToast } from '../context/ToastContext';
@@ -52,9 +54,13 @@ const CourseModal: React.FC<{
const [thumbnailPreview, setThumbnailPreview] = useState<string>('');
const [coverImageUrl, setCoverImageUrl] = useState<string>('');
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 coverFileRef = useRef<HTMLInputElement>(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 [coverConfigured, setCoverConfigured] = useState<boolean | null>(null);
const [coverUploadMessage, setCoverUploadMessage] = useState('');
@@ -98,6 +104,8 @@ const CourseModal: React.FC<{
setThumbnailPreview(initialData.thumbnail);
setCoverImageUrl(initialData.thumbnail);
setCoverImageZoom(initialData.coverImageZoom || 1);
setCoverImagePositionX(initialData.coverImagePositionX ?? 50);
setCoverImagePositionY(initialData.coverImagePositionY ?? 50);
setTips(initialData.tips || []);
setAttachments(initialData.attachments || []);
setLessons(initialData.lessons || []);
@@ -109,6 +117,8 @@ const CourseModal: React.FC<{
setThumbnailPreview('');
setCoverImageUrl('');
setCoverImageZoom(1);
setCoverImagePositionX(50);
setCoverImagePositionY(50);
setTips([]);
setAttachments([]);
setLessons([]);
@@ -118,6 +128,7 @@ const CourseModal: React.FC<{
localCoverPreviewRef.current = null;
}
setCoverPreviewError(false);
setIsAdjustingCoverPosition(false);
setNewLessonMedia(null);
setBunnyUploadState('idle');
setBunnyUploadMessage('');
@@ -199,6 +210,9 @@ const CourseModal: React.FC<{
localCoverPreviewRef.current = localPreviewUrl;
setThumbnailPreview(localPreviewUrl);
setCoverImageUrl(response.data.coverImageUrl);
setCoverImageZoom(1);
setCoverImagePositionX(50);
setCoverImagePositionY(50);
setCoverPreviewError(false);
setCoverUploadMessage('Imagem de capa enviada com sucesso.');
} 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 lesson = lessons.find((item) => item.id === lessonId);
const media = lesson?.media?.[0];
@@ -377,6 +417,8 @@ const CourseModal: React.FC<{
status,
thumbnail: coverImageUrl || '/course-placeholder.svg',
coverImageZoom,
coverImagePositionX,
coverImagePositionY,
duration: durationStr,
instructor: initialData?.instructor || '',
views: initialData?.views || 0,
@@ -448,10 +490,27 @@ const CourseModal: React.FC<{
alt="Prévia da imagem de capa"
onError={() => setCoverPreviewError(true)}
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>
<div className="flex items-center rounded-lg border border-white/10 bg-black/70 p-1 backdrop-blur-sm">
<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)))); }}
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>
<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>
</>
@@ -985,7 +1058,10 @@ export const ManageCourses: React.FC = () => {
src={course.thumbnail}
alt={course.title}
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">
{course.category}

View File

@@ -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">
{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">
<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>
</button>
))}