diff --git a/components/CourseCard.tsx b/components/CourseCard.tsx index 3732eee..23f4742 100644 --- a/components/CourseCard.tsx +++ b/components/CourseCard.tsx @@ -44,7 +44,10 @@ export const CourseCard: React.FC = ({ draggable={false} 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" - style={{ transform: `scale(${course.coverImageZoom || 1})` }} + style={{ + transform: `scale(${course.coverImageZoom || 1})`, + objectPosition: `${course.coverImagePositionX ?? 50}% ${course.coverImagePositionY ?? 50}%`, + }} /> {/* Badges on Thumbnail */} diff --git a/components/Hero.tsx b/components/Hero.tsx index 2231781..58d7d9f 100644 --- a/components/Hero.tsx +++ b/components/Hero.tsx @@ -24,7 +24,10 @@ export const Hero: React.FC = ({ onPlay }) => { src={featuredCourse.thumbnail} alt={featuredCourse.title} 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 */}
diff --git a/components/SearchModal.tsx b/components/SearchModal.tsx index c9687c8..5e37c42 100644 --- a/components/SearchModal.tsx +++ b/components/SearchModal.tsx @@ -136,7 +136,10 @@ export const SearchModal: React.FC = ({ isOpen, onClose, onPla src={course.thumbnail} 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]" - style={{ transform: `scale(${course.coverImageZoom || 1})` }} + style={{ + transform: `scale(${course.coverImageZoom || 1})`, + objectPosition: `${course.coverImagePositionX ?? 50}% ${course.coverImagePositionY ?? 50}%`, + }} />
diff --git a/pages/ManageCourses.tsx b/pages/ManageCourses.tsx index c894402..6d51956 100644 --- a/pages/ManageCourses.tsx +++ b/pages/ManageCourses.tsx @@ -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(''); const [coverImageUrl, setCoverImageUrl] = useState(''); 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(null); const localCoverPreviewRef = useRef(null); + const coverDragStartRef = useRef<{ pointerX: number; pointerY: number; positionX: number; positionY: number } | null>(null); const [categories, setCategories] = useState([]); const [coverConfigured, setCoverConfigured] = useState(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) => { + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + coverDragStartRef.current = { + pointerX: event.clientX, + pointerY: event.clientY, + positionX: coverImagePositionX, + positionY: coverImagePositionY, + }; + }; + + const moveCoverPosition = (event: React.PointerEvent) => { + 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) => { + 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}%`, + }} /> )} -
+ {isAdjustingCoverPosition && !coverPreviewError && ( +
event.stopPropagation()} + onPointerDown={startCoverPositioning} + onPointerMove={moveCoverPosition} + onPointerUp={endCoverPositioning} + onPointerCancel={endCoverPositioning} + > +
+ Arraste a imagem para ajustar o enquadramento +
+
+ )} +
Clique na imagem para alterar
+ + +
@@ -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}%`, + }} />
{course.category} diff --git a/pages/MyLearning.tsx b/pages/MyLearning.tsx index 19ca0d7..eed4ab0 100644 --- a/pages/MyLearning.tsx +++ b/pages/MyLearning.tsx @@ -28,7 +28,7 @@ export const MyLearning: React.FC<{ onPlay: (course: Course) => void }> = ({ onP
{courses.map((course) => ( ))} diff --git a/server/migrations/008_course_cover_position.sql b/server/migrations/008_course_cover_position.sql new file mode 100644 index 0000000..136943c --- /dev/null +++ b/server/migrations/008_course_cover_position.sql @@ -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); diff --git a/server/src/routes/courses.ts b/server/src/routes/courses.ts index 725ba34..6679242 100644 --- a/server/src/routes/courses.ts +++ b/server/src/routes/courses.ts @@ -22,6 +22,8 @@ export const courseSelect = (publicOnly = false, includeUnreadyMedia = false) => select value #>> '{}' from platform_settings where key = 'media.defaultCoverImageUrl' )) as "coverImageUrl", c.cover_image_zoom as "coverImageZoom", + c.cover_image_position_x as "coverImagePositionX", + c.cover_image_position_y as "coverImagePositionY", c.status, c.published_at as "publishedAt", jsonb_build_object( diff --git a/server/src/routes/manage-courses.ts b/server/src/routes/manage-courses.ts index 454b982..94a7f7d 100644 --- a/server/src/routes/manage-courses.ts +++ b/server/src/routes/manage-courses.ts @@ -46,6 +46,8 @@ const courseSchema = z.object({ category: z.string().trim().min(1).max(120), coverImageUrl: coverImageUrlSchema.nullable().optional(), 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'), lessons: z.array(lessonSchema).min(1), assets: z.array(assetSchema).default([]), @@ -214,10 +216,10 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => { 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, cover_image_zoom, 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) + `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, $8, $9::course_status, $10, case when $9::course_status = 'published'::course_status then now() else null end) 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 client.query('commit'); @@ -246,10 +248,10 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => { 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, cover_image_zoom = $7, - status = $8::course_status, published_at = case when $8::course_status = 'published'::course_status then coalesce(published_at, now()) else null end + 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 = $10::course_status, published_at = case when $10::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.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 client.query('commit'); @@ -281,8 +283,8 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => { const client = await pool.connect(); try { 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 }>( - `select title, description, category, cover_image_url, cover_image_zoom, instructor_id from courses where id = $1`, [courseId], + 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, cover_image_position_x, cover_image_position_y, instructor_id from courses where id = $1`, [courseId], ); if (!original.rows[0]) { await client.query('rollback'); @@ -292,9 +294,9 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => { const title = `${source.title} (cópia)`; const slug = await uniqueSlug(client, undefined, title); const copiedCourse = await client.query<{ id: string }>( - `insert into courses (slug, title, description, category, cover_image_url, cover_image_zoom, status, instructor_id) - values ($1, $2, $3, $4, $5, $6, 'draft', $7) returning id`, - [slug, title, source.description, source.category, source.cover_image_url, source.cover_image_zoom, source.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, $7, $8, 'draft', $9) returning 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; await client.query( diff --git a/services/db.ts b/services/db.ts index 3b9fc1f..b63044a 100644 --- a/services/db.ts +++ b/services/db.ts @@ -18,6 +18,8 @@ type ApiCourse = { category: string; coverImageUrl: string | null; coverImageZoom: number; + coverImagePositionX: number; + coverImagePositionY: number; status: 'draft' | 'published' | 'archived'; progress?: number; lastActivity?: string | null; @@ -69,6 +71,8 @@ const toCourse = (course: ApiCourse): Course => ({ 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, @@ -127,6 +131,8 @@ const toCoursePayload = (course: Course) => ({ 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) => ({ diff --git a/types.ts b/types.ts index d609ff1..1fcffa3 100644 --- a/types.ts +++ b/types.ts @@ -43,6 +43,8 @@ export interface Course { category: string; thumbnail: string; coverImageZoom?: number; + coverImagePositionX?: number; + coverImagePositionY?: number; lessons: Lesson[]; progress?: number; // 0 to 100 views?: number;