i18n 적용
CI / test (push) Failing after 11m12s
CI / e2e (push) Has been cancelled
CI / pr_and_merge (push) Has been cancelled

This commit is contained in:
2025-12-10 15:56:51 +09:00
parent 73e9bc6a1c
commit f71207aeb5
127 changed files with 7346 additions and 2079 deletions
+47 -45
View File
@@ -15,6 +15,9 @@ import {
FolderKanban,
SunMoon,
} 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;
@@ -27,6 +30,9 @@ interface ProjectListItem {
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);
@@ -49,12 +55,10 @@ export default function ProjectsPage() {
setStatus("error");
if (res.status === 401) {
setErrorMessage("프로젝트 목록을 불러오는 중 인증 오류가 발생했습니다. 다시 로그인해 주세요.");
setErrorMessage(p.errorFetchUnauthorized);
router.push("/login");
} else {
setErrorMessage(
"프로젝트 목록을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.",
);
setErrorMessage(p.errorFetchGeneric);
}
}
return;
@@ -69,7 +73,7 @@ export default function ProjectsPage() {
} catch {
if (!cancelled) {
setStatus("error");
setErrorMessage("프로젝트 목록을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
setErrorMessage(p.errorFetchGeneric);
}
}
};
@@ -88,9 +92,7 @@ export default function ProjectsPage() {
const handleDelete = async (slug: string) => {
if (typeof window !== "undefined") {
const confirmed = window.confirm(
"이 프로젝트를 삭제할까요? 삭제 후에는 되돌릴 수 없습니다. (로컬 저장 및 자동저장 데이터도 함께 삭제됩니다.)",
);
const confirmed = window.confirm(p.confirmDeleteSingle);
if (!confirmed) return;
}
@@ -102,7 +104,7 @@ export default function ProjectsPage() {
if (!res.ok) {
setStatus("error");
setErrorMessage("프로젝트 삭제 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
setErrorMessage(p.errorDeleteSingle);
return;
}
@@ -118,7 +120,7 @@ export default function ProjectsPage() {
setErrorMessage(null);
} catch {
setStatus("error");
setErrorMessage("프로젝트 삭제 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
setErrorMessage(p.errorDeleteSingle);
}
};
@@ -152,9 +154,7 @@ export default function ProjectsPage() {
if (slugs.length === 0) return;
if (typeof window !== "undefined") {
const confirmed = window.confirm(
"선택한 프로젝트를 모두 삭제할까요? 삭제 후에는 되돌릴 수 없습니다. (로컬 저장 및 자동저장 데이터도 함께 삭제됩니다.)",
);
const confirmed = window.confirm(p.confirmDeleteBulk);
if (!confirmed) return;
}
@@ -170,7 +170,7 @@ export default function ProjectsPage() {
if (okSlugs.length === 0) {
setStatus("error");
setErrorMessage("선택한 프로젝트를 삭제하는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
setErrorMessage(p.errorDeleteBulkAllFailed);
return;
}
@@ -186,16 +186,14 @@ export default function ProjectsPage() {
if (okSlugs.length !== slugs.length) {
setStatus("error");
setErrorMessage(
"일부 프로젝트 삭제에 실패했습니다. 페이지를 새로고침한 뒤 목록을 확인해 주세요.",
);
setErrorMessage(p.errorDeleteBulkSomeFailed);
} else {
setStatus("idle");
setErrorMessage(null);
}
} catch {
setStatus("error");
setErrorMessage("선택한 프로젝트를 삭제하는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
setErrorMessage(p.errorDeleteBulkAllFailed);
}
};
@@ -216,8 +214,8 @@ export default function ProjectsPage() {
<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"> </h1>
<p className="text-sm text-slate-500 dark:text-slate-400"> </p>
<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">
@@ -226,7 +224,7 @@ export default function ProjectsPage() {
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></span>
<span>{t.navDashboard}</span>
</Link>
<Link
href="/projects"
@@ -234,14 +232,14 @@ export default function ProjectsPage() {
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> </span>
<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> </span>
<span>{t.navSubmissions}</span>
</Link>
</nav>
<button
@@ -250,7 +248,7 @@ export default function ProjectsPage() {
onClick={handleToggleTheme}
>
<SunMoon className="w-4 h-4" aria-hidden="true" />
<span> </span>
<span>{t.themeToggleLabel}</span>
</button>
<div className="relative">
<button
@@ -260,7 +258,7 @@ export default function ProjectsPage() {
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">
@@ -272,7 +270,7 @@ export default function ProjectsPage() {
void handleLogout();
}}
>
{t.logoutLabel}
</button>
</div>
)}
@@ -282,7 +280,7 @@ export default function ProjectsPage() {
<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 ?? "프로젝트 목록을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."}
{errorMessage ?? p.errorFetchGeneric}
</p>
)}
{projects.length === 0 && status === "idle" && (
@@ -298,7 +296,9 @@ export default function ProjectsPage() {
<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>
: <span className="font-semibold">{selectedSlugs.length}</span>
{p.toolbarSelectedPrefix}
<span className="font-semibold">{selectedSlugs.length}</span>
{p.toolbarSelectedSuffix}
</span>
</span>
</div>
@@ -312,14 +312,14 @@ export default function ProjectsPage() {
}}
>
<Trash2 className="w-3 h-3" aria-hidden="true" />
<span> </span>
<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> </span>
<span>{p.toolbarCreateNew}</span>
</Link>
</div>
</div>
@@ -330,7 +330,7 @@ export default function ProjectsPage() {
<input
type="checkbox"
className="h-3 w-3 rounded border-slate-700 bg-slate-900 align-middle"
aria-label="전체 선택"
aria-label={p.tableHeaderSelectAllAria}
checked={
pageProjects.length > 0 && pageProjects.every((p) => selectedSlugs.includes(p.slug))
}
@@ -345,12 +345,12 @@ export default function ProjectsPage() {
}}
/>
</th>
<th className="py-2 pr-4"></th>
<th className="py-2 pr-4">(slug)</th>
<th className="py-2 pr-4"></th>
<th className="py-2 pr-4"></th>
<th className="py-2 pr-4"></th>
<th className="py-2 pr-4 text-right"></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>
@@ -365,7 +365,7 @@ export default function ProjectsPage() {
<input
type="checkbox"
className="h-3 w-3 rounded border-slate-700 bg-slate-900 align-middle"
aria-label={`${project.title} 선택`}
aria-label={`${project.title}${p.tableRowSelectAriaSuffix}`}
checked={checked}
onChange={(e) => {
if (e.target.checked) {
@@ -394,21 +394,21 @@ export default function ProjectsPage() {
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></span>
<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></span>
<span>{p.actionPreview}</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> </span>
<span>{p.actionViewSubmissions}</span>
</Link>
<button
type="button"
@@ -418,7 +418,7 @@ export default function ProjectsPage() {
}}
>
<Trash2 className="w-3 h-3" aria-hidden="true" />
<span></span>
<span>{p.actionDelete}</span>
</button>
</div>
</td>
@@ -431,7 +431,9 @@ export default function ProjectsPage() {
<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">
{totalCount} {" "}
{p.pagerTotalPrefix}
{totalCount}
{p.pagerTotalCountSuffix}
<span className="font-semibold">
{totalCount === 0 ? 0 : startIndex + 1}{Math.min(endIndex, totalCount)}
</span>
@@ -465,7 +467,7 @@ export default function ProjectsPage() {
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="w-3 h-3" aria-hidden="true" />
<span></span>
<span>{p.pagerPrevLabel}</span>
</button>
<button
type="button"
@@ -473,7 +475,7 @@ export default function ProjectsPage() {
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></span>
<span>{p.pagerNextLabel}</span>
<ChevronRight className="w-3 h-3" aria-hidden="true" />
</button>
</div>