|
|
@@ -0,0 +1,243 @@
|
|
|
+"use client";
|
|
|
+
|
|
|
+import { useEffect, useState } from "react";
|
|
|
+import { useTranslations } from "next-intl";
|
|
|
+import { Link } from "@/i18n/navigation";
|
|
|
+import { fetchCourseFiles, type CourseFile } from "@/data/courses";
|
|
|
+
|
|
|
+type Props = {
|
|
|
+ goodsId: string;
|
|
|
+ allHref?: string;
|
|
|
+ allLabel?: string;
|
|
|
+};
|
|
|
+
|
|
|
+export function CourseFilesListClient({ goodsId, allHref, allLabel }: Props) {
|
|
|
+ const pageSize = 10;
|
|
|
+ const [page, setPage] = useState(1);
|
|
|
+ const [files, setFiles] = useState<CourseFile[] | null>(null);
|
|
|
+ const [total, setTotal] = useState(0);
|
|
|
+ const [activeFile, setActiveFile] = useState<CourseFile | null>(null);
|
|
|
+ const [isPreviewVisible, setIsPreviewVisible] = useState(false);
|
|
|
+ const t = useTranslations("courses");
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ let cancelled = false;
|
|
|
+ void fetchCourseFiles(goodsId, { current: page, row: pageSize })
|
|
|
+ .then((result) => {
|
|
|
+ if (cancelled) return;
|
|
|
+ setFiles(result.list);
|
|
|
+ setTotal(result.page.total);
|
|
|
+ })
|
|
|
+ .catch(() => {
|
|
|
+ if (cancelled) return;
|
|
|
+ setFiles([]);
|
|
|
+ setTotal(0);
|
|
|
+ });
|
|
|
+
|
|
|
+ return () => {
|
|
|
+ cancelled = true;
|
|
|
+ };
|
|
|
+ }, [goodsId, page]);
|
|
|
+
|
|
|
+ const loading = files === null;
|
|
|
+ const canPrev = page > 1 && !loading;
|
|
|
+ const canNext = !loading && page * pageSize < total;
|
|
|
+ const previewDurationMs = 220;
|
|
|
+
|
|
|
+ function openPreview(file: CourseFile) {
|
|
|
+ setActiveFile(file);
|
|
|
+ requestAnimationFrame(() => setIsPreviewVisible(true));
|
|
|
+ }
|
|
|
+
|
|
|
+ function closePreview() {
|
|
|
+ setIsPreviewVisible(false);
|
|
|
+ window.setTimeout(() => {
|
|
|
+ setActiveFile(null);
|
|
|
+ }, previewDurationMs);
|
|
|
+ }
|
|
|
+
|
|
|
+ async function handleDownload(url: string, title: string) {
|
|
|
+ const fallbackOpen = () => {
|
|
|
+ window.open(url, "_blank", "noopener,noreferrer");
|
|
|
+ };
|
|
|
+ try {
|
|
|
+ const response = await fetch(url, { mode: "cors" });
|
|
|
+ if (!response.ok) {
|
|
|
+ fallbackOpen();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const blob = await response.blob();
|
|
|
+ const objectUrl = URL.createObjectURL(blob);
|
|
|
+ const fromPath = url.split("/").pop()?.split("?")[0]?.trim() ?? "";
|
|
|
+ const safeTitle = title.trim() || "file";
|
|
|
+ const defaultName = fromPath || `${safeTitle}.pdf`;
|
|
|
+ const anchor = document.createElement("a");
|
|
|
+ anchor.href = objectUrl;
|
|
|
+ anchor.download = decodeURIComponent(defaultName);
|
|
|
+ document.body.appendChild(anchor);
|
|
|
+ anchor.click();
|
|
|
+ anchor.remove();
|
|
|
+ URL.revokeObjectURL(objectUrl);
|
|
|
+ } catch {
|
|
|
+ fallbackOpen();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="mt-10">
|
|
|
+ <div className="flex items-center justify-between gap-3">
|
|
|
+ <h2 className="font-serif text-2xl font-bold text-[var(--navy)]">
|
|
|
+ {t("fileListTitle")}
|
|
|
+ </h2>
|
|
|
+ {allHref ? (
|
|
|
+ <Link
|
|
|
+ href={allHref}
|
|
|
+ className="inline-flex rounded-full border-2 border-slate-200 px-4 py-2 text-sm font-semibold text-slate-800 transition hover:border-blue-200 hover:bg-slate-50"
|
|
|
+ >
|
|
|
+ {allLabel ?? t("filterAll")}
|
|
|
+ </Link>
|
|
|
+ ) : null}
|
|
|
+ </div>
|
|
|
+ {loading ? (
|
|
|
+ <p className="mt-4 text-sm text-slate-500">{t("fileListLoading")}</p>
|
|
|
+ ) : files.length > 0 ? (
|
|
|
+ <ul className="mt-6 grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
|
|
+ {files.map((file) => (
|
|
|
+ <li
|
|
|
+ key={`${file.id}-${file.downloadUrl}`}
|
|
|
+ className="shadow-card flex w-full min-h-0 flex-col overflow-hidden rounded-xl border border-slate-200/90 bg-white"
|
|
|
+ >
|
|
|
+ <div className="relative flex h-24 w-full shrink-0 items-center justify-center overflow-hidden bg-slate-100">
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={() => openPreview(file)}
|
|
|
+ className="absolute right-2 top-2 inline-flex h-6 w-6 items-center justify-center rounded-md border border-slate-300/90 bg-white/90 text-slate-600 transition hover:bg-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
|
|
+ aria-label={t("filePreview")}
|
|
|
+ title={t("filePreview")}
|
|
|
+ >
|
|
|
+ <svg aria-hidden="true" viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none">
|
|
|
+ <path
|
|
|
+ d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6z"
|
|
|
+ stroke="currentColor"
|
|
|
+ strokeWidth="1.8"
|
|
|
+ strokeLinecap="round"
|
|
|
+ strokeLinejoin="round"
|
|
|
+ />
|
|
|
+ <circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="1.8" />
|
|
|
+ </svg>
|
|
|
+ </button>
|
|
|
+ <svg
|
|
|
+ aria-hidden="true"
|
|
|
+ viewBox="0 0 64 64"
|
|
|
+ className="h-12 w-12 text-amber-400 drop-shadow-sm"
|
|
|
+ fill="currentColor"
|
|
|
+ >
|
|
|
+ <path d="M8 18a6 6 0 0 1 6-6h14c2.6 0 4.9 1.4 6.1 3.6L36 18h14a6 6 0 0 1 6 6v22a6 6 0 0 1-6 6H14a6 6 0 0 1-6-6V18z" />
|
|
|
+ <path
|
|
|
+ d="M8 26a6 6 0 0 1 6-6h36a6 6 0 0 1 6 6v4H8v-4z"
|
|
|
+ className="text-amber-300"
|
|
|
+ />
|
|
|
+ </svg>
|
|
|
+ </div>
|
|
|
+ <div className="flex min-h-0 min-w-0 flex-1 flex-col items-center border-t border-slate-100 px-3 pb-3 pt-2 text-center">
|
|
|
+ <h3 className="font-serif text-base font-bold leading-snug tracking-tight text-[var(--navy)] break-words">
|
|
|
+ {file.title}
|
|
|
+ </h3>
|
|
|
+ <p className="mt-2 line-clamp-2 text-xs leading-relaxed text-slate-500 break-words">
|
|
|
+ {file.introduction || t("fileNoIntro")}
|
|
|
+ </p>
|
|
|
+ <div className="mt-3">
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={() => void handleDownload(file.downloadUrl, file.title)}
|
|
|
+ className="inline-flex h-8 min-w-[5.5rem] items-center justify-center rounded-full bg-gradient-to-r from-blue-600 to-blue-700 px-3 text-xs font-semibold text-white shadow-md shadow-blue-600/20"
|
|
|
+ >
|
|
|
+ {t("fileDownload")}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </li>
|
|
|
+ ))}
|
|
|
+ </ul>
|
|
|
+ ) : (
|
|
|
+ <p className="mt-4 rounded-xl border border-dashed border-slate-300 bg-slate-50 px-4 py-3 text-sm text-slate-500">
|
|
|
+ {t("fileListEmpty")}
|
|
|
+ </p>
|
|
|
+ )}
|
|
|
+ <div className="mt-4 flex items-center justify-end gap-3">
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={() => {
|
|
|
+ if (!canPrev) return;
|
|
|
+ setFiles(null);
|
|
|
+ setPage((p) => Math.max(1, p - 1));
|
|
|
+ }}
|
|
|
+ disabled={!canPrev}
|
|
|
+ className="rounded-full border border-slate-300 px-3 py-1.5 text-sm text-slate-700 disabled:cursor-not-allowed disabled:opacity-50"
|
|
|
+ >
|
|
|
+ {t("videoPrevPage")}
|
|
|
+ </button>
|
|
|
+ <span className="text-sm text-slate-500">
|
|
|
+ {t("videoCurrentPage", { page })}
|
|
|
+ </span>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={() => {
|
|
|
+ if (!canNext) return;
|
|
|
+ setFiles(null);
|
|
|
+ setPage((p) => p + 1);
|
|
|
+ }}
|
|
|
+ disabled={!canNext}
|
|
|
+ className="rounded-full border border-slate-300 px-3 py-1.5 text-sm text-slate-700 disabled:cursor-not-allowed disabled:opacity-50"
|
|
|
+ >
|
|
|
+ {t("videoNextPage")}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ {activeFile ? (
|
|
|
+ <div
|
|
|
+ className={`fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-[2px] transition-opacity duration-200 ${
|
|
|
+ isPreviewVisible ? "bg-slate-950/70 opacity-100" : "bg-slate-950/0 opacity-0"
|
|
|
+ }`}
|
|
|
+ onClick={closePreview}
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ className={`w-full max-w-5xl overflow-hidden rounded-2xl border border-slate-200/20 bg-white shadow-2xl transition duration-200 ${
|
|
|
+ isPreviewVisible
|
|
|
+ ? "translate-y-0 scale-100 opacity-100"
|
|
|
+ : "translate-y-2 scale-95 opacity-0"
|
|
|
+ }`}
|
|
|
+ onClick={(e) => e.stopPropagation()}
|
|
|
+ >
|
|
|
+ <div className="flex items-center justify-between gap-3 border-b border-slate-200 bg-slate-50 px-4 py-3">
|
|
|
+ <p className="truncate text-sm font-semibold text-slate-800">
|
|
|
+ {t("filePreviewing")}: {activeFile.title}
|
|
|
+ </p>
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ <a
|
|
|
+ href={activeFile.downloadUrl}
|
|
|
+ target="_blank"
|
|
|
+ rel="noopener noreferrer"
|
|
|
+ className="inline-flex rounded-full border border-slate-300 px-3 py-1 text-xs font-medium text-slate-700 hover:bg-slate-100"
|
|
|
+ >
|
|
|
+ {t("fileOpenNewWindow")}
|
|
|
+ </a>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={closePreview}
|
|
|
+ className="rounded-full border border-slate-300 px-3 py-1 text-xs font-medium text-slate-700 hover:bg-slate-100"
|
|
|
+ >
|
|
|
+ {t("fileClosePreview")}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <iframe
|
|
|
+ title={activeFile.title}
|
|
|
+ src={activeFile.downloadUrl}
|
|
|
+ className="h-[65vh] w-full bg-white"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ) : null}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|