feat: responsive media grid with lightbox, proper attachment list

- Media (images/videos) now render in a responsive grid with a
  full-screen lightbox on tap instead of a stacked full-width column.
- Multiple attached files (attachments[]) each get their own icon,
  title, and download button, replacing the single hidden file link.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 19:22:24 +03:30
parent 1ea325ad47
commit 826f61a0fe

View File

@@ -1,3 +1,4 @@
import { useState } from "react";
import { LearnProps } from "../../../../types/props"; import { LearnProps } from "../../../../types/props";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import useAxios from "../../../../hooks/useAxios"; import useAxios from "../../../../hooks/useAxios";
@@ -6,6 +7,12 @@ import { MdErrorOutline } from "react-icons/md";
import Button from "../../../../components/button"; import Button from "../../../../components/button";
import { saveAs } from "file-saver"; import { saveAs } from "file-saver";
import { Fragment } from "react/jsx-runtime"; import { Fragment } from "react/jsx-runtime";
import {
IoClose,
IoDocumentTextOutline,
IoDownloadOutline,
IoPlayCircleOutline,
} from "react-icons/io5";
const getMediaType = (url: string) => { const getMediaType = (url: string) => {
const extension = url.split(".").pop()?.toLowerCase(); const extension = url.split(".").pop()?.toLowerCase();
@@ -20,6 +27,9 @@ const getMediaType = (url: string) => {
return "unknown"; return "unknown";
}; };
type Attachment = { id: number | null; title: string; url: string };
type LightboxState = { url: string; type: "image" | "video" } | null;
const Learn = ({ activeTutorial }: LearnProps) => { const Learn = ({ activeTutorial }: LearnProps) => {
const axios = useAxios(); const axios = useAxios();
const { data, isLoading, isFetching, error } = useQuery({ const { data, isLoading, isFetching, error } = useQuery({
@@ -28,8 +38,41 @@ const Learn = ({ activeTutorial }: LearnProps) => {
return axios.get(`/home/category/learning/${activeTutorial}`); return axios.get(`/home/category/learning/${activeTutorial}`);
}, },
}); });
const [lightbox, setLightbox] = useState<LightboxState>(null);
return ( return (
<div className="w-full h-full border-4 flex flex-col justify-center items-center border-solid mt-4 p-4 rounded-md border-gray-800 border-spacing-1"> <div className="w-full h-full border-4 flex flex-col justify-center items-center border-solid mt-4 p-4 rounded-md border-gray-800 border-spacing-1">
{lightbox && (
<div
className="fixed inset-0 z-50 bg-black/90 flex flex-col justify-center items-center p-4"
onClick={() => setLightbox(null)}
>
<button
type="button"
className="absolute top-4 left-4 text-white text-3xl"
onClick={() => setLightbox(null)}
>
<IoClose />
</button>
{lightbox.type === "image" ? (
<img
src={lightbox.url}
alt="media"
className="max-w-full max-h-[85vh] rounded-lg"
onClick={(e) => e.stopPropagation()}
/>
) : (
<video
src={lightbox.url}
controls
autoPlay
className="max-w-full max-h-[85vh] rounded-lg"
onClick={(e) => e.stopPropagation()}
/>
)}
</div>
)}
{isLoading || isFetching ? ( {isLoading || isFetching ? (
<div className="w-full h-full p-5 flex justify-center items-center"> <div className="w-full h-full p-5 flex justify-center items-center">
<svg <svg
@@ -117,67 +160,92 @@ const Learn = ({ activeTutorial }: LearnProps) => {
id: string; id: string;
title: string; title: string;
medias: string[]; medias: string[];
file: string | null; attachments?: Attachment[];
url_file: string; url_file: string;
}) => ( }) => (
<div <div
key={i.id} key={i.id}
className="relative w-full h-full flex flex-col justify-center items-center" className="relative w-full h-full flex flex-col justify-center items-center mb-2"
> >
<span className="p-2 bg-gray-800 rounded-md mb-4"> <span className="p-2 bg-gray-800 rounded-md mb-4">
{i.title} {i.title}
</span> </span>
<div className="relative w-full h-full flex flex-col justify-center items-center">
{i.medias.map((media, index) => { {i.medias.length > 0 && (
const mediaType = getMediaType(media); <div className="w-full grid grid-cols-2 sm:grid-cols-3 gap-2 mb-4">
return ( {i.medias.map((media, index) => {
<div const mediaType = getMediaType(media);
key={index} return (
className="w-full md:w-1/2 mb-3 rounded-lg mx-auto h-full" <button
> key={index}
{mediaType === "image" ? ( type="button"
<img className="relative aspect-square rounded-lg overflow-hidden bg-gray-800 flex items-center justify-center"
src={media} onClick={() =>
alt="media" mediaType === "unknown"
className="w-full h-full object-cover rounded-lg" ? saveAs(media)
/> : setLightbox({
) : mediaType === "video" ? ( url: media,
<div className="w-full h-full flex flex-col gap-2"> type: mediaType as "image" | "video",
<video })
}
>
{mediaType === "image" ? (
<img
src={media} src={media}
controls alt="media"
className="w-full h-full object-cover rounded-lg" className="w-full h-full object-cover"
/> />
<Button ) : mediaType === "video" ? (
extra="!py-1 self-center" <>
onClick={() => saveAs(media)} <video
> src={media}
دانلود ویدیو className="w-full h-full object-cover"
</Button> muted
</div> />
) : ( <div className="absolute inset-0 flex items-center justify-center bg-black/30">
<div className="w-full h-full flex flex-col gap-2 justify-center items-center"> <IoPlayCircleOutline className="text-white w-10 h-10" />
<p>نوع فایل قابل نمایش نیست</p> </div>
<Button extra="!py-1" onClick={() => saveAs(media)}> </>
دانلود فایل ) : (
</Button> <div className="w-full h-full flex flex-col gap-1 items-center justify-center text-xs p-2">
</div> <IoDocumentTextOutline className="w-8 h-8" />
)} <span>دانلود</span>
</div> </div>
); )}
})} </button>
</div> );
{i.file && ( })}
<Button extra="!mb-4" onClick={() => saveAs(i.file as string)}> </div>
دانلود فایل آموزش
</Button>
)} )}
{i.attachments && i.attachments.length > 0 && (
<div className="w-full flex flex-col gap-2 mb-4">
{i.attachments.map((att) => (
<div
key={att.id ?? att.url}
className="w-full flex items-center justify-between gap-2 bg-gray-800 rounded-md p-2"
>
<div className="flex items-center gap-2 min-w-0">
<IoDocumentTextOutline className="shrink-0 w-5 h-5" />
<span className="truncate text-xs md:text-sm">
{att.title}
</span>
</div>
<Button
extra="!py-1 !px-2 shrink-0 flex items-center gap-1"
onClick={() => saveAs(att.url, att.title)}
>
<IoDownloadOutline />
</Button>
</div>
))}
</div>
)}
{i.url_file && ( {i.url_file && (
<> <Button extra="!mb-4" onClick={() => saveAs(i.url_file)}>
<Button extra="!mb-4" onClick={() => saveAs(i.url_file)}> دانلود برنامه
دانلود برنامه </Button>
</Button>
</>
)} )}
<hr className="border-2 w-full rounded-md my-5 border-gray-800" /> <hr className="border-2 w-full rounded-md my-5 border-gray-800" />
</div> </div>