542 lines
23 KiB
TypeScript
542 lines
23 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState } from "react";
|
||
import Link from "next/link";
|
||
import { useRouter } from "next/navigation";
|
||
import {
|
||
FilePlus2,
|
||
Trash2,
|
||
Eye,
|
||
Pencil,
|
||
ListChecks,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
LayoutDashboard,
|
||
FolderKanban,
|
||
SunMoon,
|
||
BarChart2,
|
||
} from "lucide-react";
|
||
import { useAppLocale } from "@/features/i18n/LocaleProvider";
|
||
import { getDashboardMessages } from "@/features/i18n/messages/dashboard";
|
||
import { getProjectsMessages } from "@/features/i18n/messages/projects";
|
||
|
||
interface ProjectListItem {
|
||
id: string;
|
||
title: string;
|
||
slug: string;
|
||
status: string;
|
||
createdAt: string;
|
||
updatedAt: string;
|
||
}
|
||
|
||
export default function ProjectsPage() {
|
||
const router = useRouter();
|
||
const locale = useAppLocale();
|
||
const t = getDashboardMessages(locale);
|
||
const p = getProjectsMessages(locale);
|
||
const [projects, setProjects] = useState<ProjectListItem[]>([]);
|
||
const [status, setStatus] = useState<"idle" | "loading" | "error">("idle");
|
||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||
const [selectedSlugs, setSelectedSlugs] = useState<string[]>([]);
|
||
const [currentPage, setCurrentPage] = useState(1);
|
||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||
const [emailVerified, setEmailVerified] = useState<boolean | null>(null);
|
||
const pageSize = 10;
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
|
||
const load = async () => {
|
||
try {
|
||
setStatus("loading");
|
||
setErrorMessage(null);
|
||
|
||
const res = await fetch("/api/projects");
|
||
if (!res.ok) {
|
||
if (!cancelled) {
|
||
setStatus("error");
|
||
|
||
if (res.status === 401) {
|
||
setErrorMessage(p.errorFetchUnauthorized);
|
||
router.push("/login");
|
||
} else {
|
||
setErrorMessage(p.errorFetchGeneric);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
const data = (await res.json()) as ProjectListItem[];
|
||
if (!cancelled) {
|
||
setProjects(Array.isArray(data) ? data : []);
|
||
setStatus("idle");
|
||
setErrorMessage(null);
|
||
}
|
||
} catch {
|
||
if (!cancelled) {
|
||
setStatus("error");
|
||
setErrorMessage(p.errorFetchGeneric);
|
||
}
|
||
}
|
||
};
|
||
|
||
void load();
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, []);
|
||
|
||
// 이메일 인증 여부를 확인해 퍼블릭 페이지 링크 노출 여부를 결정한다.
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
|
||
const loadMe = async () => {
|
||
try {
|
||
const res = await fetch("/api/auth/me");
|
||
if (cancelled || !res.ok) return;
|
||
|
||
const data = (await res.json().catch(() => null)) as any;
|
||
if (!cancelled && data) {
|
||
setEmailVerified(Boolean(data.emailVerified));
|
||
}
|
||
} catch {
|
||
// ignore – 인증 여부를 알 수 없으면 기존 동작(링크 노출)을 유지한다.
|
||
}
|
||
};
|
||
|
||
void loadMe();
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const totalPages = Math.max(1, Math.ceil(projects.length / pageSize));
|
||
setCurrentPage((prev) => Math.min(prev, totalPages));
|
||
}, [projects.length, pageSize]);
|
||
|
||
const handleDelete = async (slug: string) => {
|
||
if (typeof window !== "undefined") {
|
||
const confirmed = window.confirm(p.confirmDeleteSingle);
|
||
|
||
if (!confirmed) return;
|
||
}
|
||
|
||
try {
|
||
const res = await fetch(`/api/projects/${encodeURIComponent(slug)}`, {
|
||
method: "DELETE",
|
||
});
|
||
|
||
if (!res.ok) {
|
||
setStatus("error");
|
||
setErrorMessage(p.errorDeleteSingle);
|
||
return;
|
||
}
|
||
|
||
setProjects((prev) => prev.filter((p) => p.slug !== slug));
|
||
setSelectedSlugs((prev) => prev.filter((s) => s !== slug));
|
||
|
||
if (typeof window !== "undefined") {
|
||
window.localStorage.removeItem(`pb:project:${slug}`);
|
||
window.localStorage.removeItem(`pb:autosave:${slug}`);
|
||
}
|
||
|
||
setStatus("idle");
|
||
setErrorMessage(null);
|
||
} catch {
|
||
setStatus("error");
|
||
setErrorMessage(p.errorDeleteSingle);
|
||
}
|
||
};
|
||
|
||
const handleLogout = async () => {
|
||
try {
|
||
const res = await fetch("/api/auth/logout", {
|
||
method: "POST",
|
||
});
|
||
|
||
if (!res.ok) {
|
||
setStatus("error");
|
||
setErrorMessage("로그아웃 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
|
||
return;
|
||
}
|
||
|
||
router.push("/login");
|
||
} catch {
|
||
setStatus("error");
|
||
setErrorMessage("로그아웃 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
|
||
}
|
||
};
|
||
|
||
const totalCount = projects.length;
|
||
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||
const startIndex = (currentPage - 1) * pageSize;
|
||
const endIndex = startIndex + pageSize;
|
||
const pageProjects = projects.slice(startIndex, endIndex);
|
||
|
||
const handleBulkDelete = async () => {
|
||
const slugs = selectedSlugs;
|
||
if (slugs.length === 0) return;
|
||
|
||
if (typeof window !== "undefined") {
|
||
const confirmed = window.confirm(p.confirmDeleteBulk);
|
||
if (!confirmed) return;
|
||
}
|
||
|
||
try {
|
||
const results = await Promise.all(
|
||
slugs.map(async (slug) => {
|
||
const res = await fetch(`/api/projects/${encodeURIComponent(slug)}`, { method: "DELETE" });
|
||
return { slug, ok: res.ok };
|
||
}),
|
||
);
|
||
|
||
const okSlugs = results.filter((r) => r.ok).map((r) => r.slug);
|
||
|
||
if (okSlugs.length === 0) {
|
||
setStatus("error");
|
||
setErrorMessage(p.errorDeleteBulkAllFailed);
|
||
return;
|
||
}
|
||
|
||
setProjects((prev) => prev.filter((p) => !okSlugs.includes(p.slug)));
|
||
setSelectedSlugs((prev) => prev.filter((s) => !okSlugs.includes(s)));
|
||
|
||
if (typeof window !== "undefined") {
|
||
for (const slug of okSlugs) {
|
||
window.localStorage.removeItem(`pb:project:${slug}`);
|
||
window.localStorage.removeItem(`pb:autosave:${slug}`);
|
||
}
|
||
}
|
||
|
||
if (okSlugs.length !== slugs.length) {
|
||
setStatus("error");
|
||
setErrorMessage(p.errorDeleteBulkSomeFailed);
|
||
} else {
|
||
setStatus("idle");
|
||
setErrorMessage(null);
|
||
}
|
||
} catch {
|
||
setStatus("error");
|
||
setErrorMessage(p.errorDeleteBulkAllFailed);
|
||
}
|
||
};
|
||
|
||
const handleToggleTheme = () => {
|
||
if (typeof document === "undefined") {
|
||
return;
|
||
}
|
||
|
||
const root = document.documentElement;
|
||
if (!root) {
|
||
return;
|
||
}
|
||
|
||
root.classList.toggle("dark");
|
||
};
|
||
|
||
return (
|
||
<main className="min-h-screen flex flex-col bg-slate-100 text-slate-900 dark:bg-slate-950 dark:text-slate-50">
|
||
<header className="border-b border-slate-200 px-6 py-4 flex items-center justify-between bg-white/80 backdrop-blur dark:border-slate-800 dark:bg-slate-950/80">
|
||
<div>
|
||
<h1 className="text-2xl font-bold tracking-tight">{p.headerTitle}</h1>
|
||
<p className="text-sm text-slate-500 dark:text-slate-400">{p.headerDescription}</p>
|
||
</div>
|
||
<div className="flex items-center gap-3 text-sm">
|
||
<nav className="inline-flex items-center gap-1 rounded-full border border-slate-200 bg-white/80 px-1.5 py-1 shadow-sm dark:border-slate-700 dark:bg-slate-900/80">
|
||
<Link
|
||
href="/dashboard"
|
||
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium text-slate-700 hover:bg-slate-100 hover:text-sky-700 dark:text-slate-200 dark:hover:bg-slate-800 dark:hover:text-sky-200"
|
||
>
|
||
<LayoutDashboard className="w-4 h-4" aria-hidden="true" />
|
||
<span>{t.navDashboard}</span>
|
||
</Link>
|
||
<Link
|
||
href="/projects"
|
||
aria-current="page"
|
||
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-semibold bg-sky-600 text-white shadow-sm hover:bg-sky-700"
|
||
>
|
||
<FolderKanban className="w-4 h-4" aria-hidden="true" />
|
||
<span>{t.navProjects}</span>
|
||
</Link>
|
||
<Link
|
||
href="/projects/submissions"
|
||
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium text-emerald-700 hover:bg-emerald-50 hover:text-emerald-800 dark:text-emerald-200 dark:hover:bg-emerald-900/60 dark:hover:text-emerald-100"
|
||
>
|
||
<ListChecks className="w-4 h-4" aria-hidden="true" />
|
||
<span>{t.navSubmissions}</span>
|
||
</Link>
|
||
<Link
|
||
href="/reports"
|
||
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium text-slate-700 hover:bg-slate-100 hover:text-sky-700 dark:text-slate-200 dark:hover:bg-slate-800 dark:hover:text-sky-200"
|
||
>
|
||
<BarChart2 className="w-4 h-4" aria-hidden="true" />
|
||
<span>{t.navReports ?? "Reports"}</span>
|
||
</Link>
|
||
</nav>
|
||
<button
|
||
type="button"
|
||
className="inline-flex items-center gap-1 rounded-full border border-slate-300 bg-white/80 px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800"
|
||
onClick={handleToggleTheme}
|
||
>
|
||
<SunMoon className="w-4 h-4" aria-hidden="true" />
|
||
<span>{t.themeToggleLabel}</span>
|
||
</button>
|
||
<div className="relative">
|
||
<button
|
||
type="button"
|
||
className="inline-flex items-center gap-1 rounded-full border border-slate-300 bg-white/80 px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800"
|
||
onClick={() => {
|
||
setIsMenuOpen((prev) => !prev);
|
||
}}
|
||
>
|
||
{t.menuLabel}
|
||
</button>
|
||
{isMenuOpen && (
|
||
<div className="absolute right-0 mt-1 w-32 rounded border border-slate-200 bg-white shadow-lg z-20 dark:border-slate-700 dark:bg-slate-900/95">
|
||
<button
|
||
type="button"
|
||
className="w-full px-3 py-2 text-left text-xs text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800"
|
||
onClick={() => {
|
||
setIsMenuOpen(false);
|
||
router.push("/mypage");
|
||
}}
|
||
>
|
||
{t.myPageLabel}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="w-full px-3 py-2 text-left text-xs text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800"
|
||
onClick={() => {
|
||
setIsMenuOpen(false);
|
||
void handleLogout();
|
||
}}
|
||
>
|
||
{t.logoutLabel}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</header>
|
||
<section className="flex-1 px-6 py-4 overflow-auto bg-slate-50/60 dark:bg-transparent">
|
||
{status === "error" && (
|
||
<p className="text-xs text-red-500 mb-3 dark:text-red-300">
|
||
{errorMessage ?? p.errorFetchGeneric}
|
||
</p>
|
||
)}
|
||
{projects.length === 0 && status === "idle" && (
|
||
<p className="text-xs text-slate-500 dark:text-slate-400">아직 저장된 프로젝트가 없습니다. 에디터에서 프로젝트를 저장해 보세요.</p>
|
||
)}
|
||
{projects.length > 0 && (
|
||
<>
|
||
<div
|
||
data-testid="projects-toolbar"
|
||
className="flex items-center justify-between mb-2 text-[11px] text-slate-600 dark:text-slate-300"
|
||
>
|
||
<div className="inline-flex items-center gap-2">
|
||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full border bg-slate-100 border-slate-300 text-slate-700 dark:bg-slate-900/70 dark:border-slate-700 dark:text-slate-200">
|
||
<ListChecks className="w-3 h-3 text-slate-400 dark:text-slate-500" aria-hidden="true" />
|
||
<span>
|
||
{p.toolbarSelectedPrefix}
|
||
<span className="font-semibold">{selectedSlugs.length}</span>
|
||
{p.toolbarSelectedSuffix}
|
||
</span>
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
className="inline-flex items-center gap-1 rounded border px-2 py-1 text-xs font-medium border-red-300 text-red-700 bg-red-50 hover:bg-red-100 disabled:opacity-40 disabled:cursor-not-allowed dark:border-red-700 dark:text-red-200 dark:bg-red-900/40 dark:hover:bg-red-900/60"
|
||
disabled={selectedSlugs.length === 0}
|
||
onClick={() => {
|
||
void handleBulkDelete();
|
||
}}
|
||
>
|
||
<Trash2 className="w-3 h-3" aria-hidden="true" />
|
||
<span>{p.toolbarDeleteSelected}</span>
|
||
</button>
|
||
<Link
|
||
href="/editor?new=1"
|
||
className="inline-flex items-center gap-1 rounded border px-2 py-1 text-xs font-medium border-sky-500 bg-sky-600 text-white hover:bg-sky-700 shadow-sm dark:border-sky-700 dark:bg-sky-950 dark:text-sky-100 dark:hover:bg-sky-900"
|
||
>
|
||
<FilePlus2 className="w-3 h-3" aria-hidden="true" />
|
||
<span>{p.toolbarCreateNew}</span>
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
<table className="w-full text-xs text-left border-collapse mt-2">
|
||
<thead>
|
||
<tr className="border-b border-slate-200 text-slate-600 dark:border-slate-800 dark:text-slate-400">
|
||
<th className="py-2 pr-2 w-8">
|
||
<input
|
||
type="checkbox"
|
||
className="h-3 w-3 rounded border-slate-700 bg-slate-900 align-middle"
|
||
aria-label={p.tableHeaderSelectAllAria}
|
||
checked={
|
||
pageProjects.length > 0 && pageProjects.every((p) => selectedSlugs.includes(p.slug))
|
||
}
|
||
onChange={(e) => {
|
||
if (e.target.checked) {
|
||
const slugsOnPage = pageProjects.map((p) => p.slug);
|
||
setSelectedSlugs((prev) => Array.from(new Set([...prev, ...slugsOnPage])));
|
||
} else {
|
||
const slugsOnPage = pageProjects.map((p) => p.slug);
|
||
setSelectedSlugs((prev) => prev.filter((s) => !slugsOnPage.includes(s)));
|
||
}
|
||
}}
|
||
/>
|
||
</th>
|
||
<th className="py-2 pr-4">{p.tableHeaderTitle}</th>
|
||
<th className="py-2 pr-4">{p.tableHeaderSlug}</th>
|
||
<th className="py-2 pr-4">{p.tableHeaderStatus}</th>
|
||
<th className="py-2 pr-4">{p.tableHeaderCreatedAt}</th>
|
||
<th className="py-2 pr-4">{p.tableHeaderUpdatedAt}</th>
|
||
<th className="py-2 pr-4 text-right">{p.tableHeaderActions}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{pageProjects.map((project) => {
|
||
const checked = selectedSlugs.includes(project.slug);
|
||
return (
|
||
<tr
|
||
key={project.id}
|
||
className="border-b border-slate-200 hover:bg-slate-50 dark:border-slate-900 dark:hover:bg-slate-900/60"
|
||
>
|
||
<td className="py-2 pr-2 w-8">
|
||
<input
|
||
type="checkbox"
|
||
className="h-3 w-3 rounded border-slate-700 bg-slate-900 align-middle"
|
||
aria-label={`${project.title}${p.tableRowSelectAriaSuffix}`}
|
||
checked={checked}
|
||
onChange={(e) => {
|
||
if (e.target.checked) {
|
||
setSelectedSlugs((prev) =>
|
||
prev.includes(project.slug) ? prev : [...prev, project.slug],
|
||
);
|
||
} else {
|
||
setSelectedSlugs((prev) => prev.filter((s) => s !== project.slug));
|
||
}
|
||
}}
|
||
/>
|
||
</td>
|
||
<td className="py-2 pr-4 text-slate-900 dark:text-slate-100">{project.title}</td>
|
||
<td className="py-2 pr-4 text-slate-600 dark:text-slate-300 font-mono text-[11px]">{project.slug}</td>
|
||
<td className="py-2 pr-4 text-slate-600 dark:text-slate-300">{project.status}</td>
|
||
<td className="py-2 pr-4 text-slate-500 dark:text-slate-400 text-[11px]">
|
||
{new Date(project.createdAt).toLocaleString()}
|
||
</td>
|
||
<td className="py-2 pr-4 text-slate-500 text-[11px]">
|
||
{new Date(project.updatedAt).toLocaleString()}
|
||
</td>
|
||
<td className="py-2 pl-4 pr-0 text-right text-[11px]">
|
||
<div className="inline-flex items-center gap-2">
|
||
<Link
|
||
href={`/editor?slug=${encodeURIComponent(project.slug)}`}
|
||
className="inline-flex items-center gap-1 text-sky-600 hover:text-sky-700 underline-offset-2 hover:underline dark:text-sky-300 dark:hover:text-sky-200"
|
||
>
|
||
<Pencil className="w-3 h-3" aria-hidden="true" />
|
||
<span>{p.actionEdit}</span>
|
||
</Link>
|
||
<Link
|
||
href={`/preview?slug=${encodeURIComponent(project.slug)}`}
|
||
className="inline-flex items-center gap-1 text-slate-600 hover:text-slate-800 underline-offset-2 hover:underline dark:text-slate-300 dark:hover:text-slate-100"
|
||
>
|
||
<Eye className="w-3 h-3" aria-hidden="true" />
|
||
<span>{p.actionPreview}</span>
|
||
</Link>
|
||
{emailVerified !== false && (
|
||
<Link
|
||
href={`/p/${encodeURIComponent(project.slug)}`}
|
||
className="inline-flex items-center gap-1 text-sky-700 hover:text-sky-900 underline-offset-2 hover:underline dark:text-sky-300 dark:hover:text-sky-200"
|
||
>
|
||
<Eye className="w-3 h-3" aria-hidden="true" />
|
||
<span>{t.projectOpenPublicPageLink}</span>
|
||
</Link>
|
||
)}
|
||
<Link
|
||
href={`/projects/${encodeURIComponent(project.slug)}/submissions`}
|
||
className="inline-flex items-center gap-1 text-emerald-600 hover:text-emerald-700 underline-offset-2 hover:underline dark:text-emerald-300 dark:hover:text-emerald-100"
|
||
>
|
||
<ListChecks className="w-3 h-3" aria-hidden="true" />
|
||
<span>{p.actionViewSubmissions}</span>
|
||
</Link>
|
||
<button
|
||
type="button"
|
||
className="inline-flex items-center gap-1 text-red-600 hover:text-red-700 underline-offset-2 hover:underline dark:text-red-300 dark:hover:text-red-200"
|
||
onClick={() => {
|
||
void handleDelete(project.slug);
|
||
}}
|
||
>
|
||
<Trash2 className="w-3 h-3" aria-hidden="true" />
|
||
<span>{p.actionDelete}</span>
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
|
||
<div className="mt-3 flex items-center justify-between text-[11px] text-slate-600 dark:text-slate-300">
|
||
<div className="inline-flex items-center gap-2">
|
||
<span className="px-2 py-1 rounded-full bg-slate-100 border border-slate-300 text-slate-700 dark:bg-slate-900/70 dark:border-slate-700 dark:text-slate-200">
|
||
{p.pagerTotalPrefix}
|
||
{totalCount}
|
||
{p.pagerTotalCountSuffix}
|
||
<span className="font-semibold">
|
||
{totalCount === 0 ? 0 : startIndex + 1}–{Math.min(endIndex, totalCount)}
|
||
</span>
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center gap-1">
|
||
{Array.from({ length: totalPages }).map((_, idx) => {
|
||
const page = idx + 1;
|
||
const isActive = page === currentPage;
|
||
return (
|
||
<button
|
||
key={page}
|
||
type="button"
|
||
onClick={() => setCurrentPage(page)}
|
||
className={`w-6 h-6 rounded-full text-[10px] flex items-center justify-center border transition-colors ${
|
||
isActive
|
||
? "bg-sky-600 text-white border-sky-500 shadow-sm"
|
||
: "bg-white text-slate-700 border-slate-300 hover:bg-slate-100 dark:bg-slate-900 dark:text-slate-300 dark:border-slate-700 dark:hover:bg-slate-800"
|
||
}`}
|
||
>
|
||
{page}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
<div className="flex items-center gap-1">
|
||
<button
|
||
type="button"
|
||
disabled={currentPage === 1}
|
||
className="inline-flex items-center gap-1 px-2 py-1 rounded border border-slate-300 text-slate-700 bg-white disabled:opacity-40 disabled:cursor-not-allowed hover:bg-slate-100 dark:border-slate-700 dark:text-slate-300 dark:bg-slate-900 dark:hover:bg-slate-800"
|
||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||
>
|
||
<ChevronLeft className="w-3 h-3" aria-hidden="true" />
|
||
<span>{p.pagerPrevLabel}</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={currentPage === totalPages}
|
||
className="inline-flex items-center gap-1 px-2 py-1 rounded border border-slate-300 text-slate-700 bg-white disabled:opacity-40 disabled:cursor-not-allowed hover:bg-slate-100 dark:border-slate-700 dark:text-slate-300 dark:bg-slate-900 dark:hover:bg-slate-800"
|
||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||
>
|
||
<span>{p.pagerNextLabel}</span>
|
||
<ChevronRight className="w-3 h-3" aria-hidden="true" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</section>
|
||
</main>
|
||
);
|
||
}
|