diff --git a/src/app/api/export/route.ts b/src/app/api/export/route.ts index c6b52e8..ca7b403 100644 --- a/src/app/api/export/route.ts +++ b/src/app/api/export/route.ts @@ -145,11 +145,8 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig): ); } - if (seoCanonicalRaw) { - seoHeadParts.push( - ` `, - ); - } + // canonical URL 은 Export HTML 에서는 meta property="og:url" 로만 사용하고, + // 별도의 태그는 생성하지 않는다. if (seoNoIndex) { seoHeadParts.push( diff --git a/src/app/api/forms/submit/route.ts b/src/app/api/forms/submit/route.ts index 3ec80f9..95a9310 100644 --- a/src/app/api/forms/submit/route.ts +++ b/src/app/api/forms/submit/route.ts @@ -5,6 +5,22 @@ import { encryptJson } from "@/features/auth/authCrypto"; const prisma = new PrismaClient() as any; +function resolveProjectSlug(formData: FormData, config: FormBlockProps | null): string | null { + const projectSlugRaw = formData.get("__projectSlug"); + let projectSlug: string | null = null; + + if (typeof projectSlugRaw === "string" && projectSlugRaw.trim() !== "") { + projectSlug = projectSlugRaw.trim(); + } else if (config?.extraParams && typeof config.extraParams.projectSlug === "string") { + const v = config.extraParams.projectSlug.trim(); + if (v !== "") { + projectSlug = v; + } + } + + return projectSlug; +} + function buildSensitiveFieldNameSet(config: FormBlockProps | null): Set { const result = new Set(); const fields: FormFieldConfig[] = Array.isArray(config?.fields) ? (config!.fields as FormFieldConfig[]) : []; @@ -55,17 +71,7 @@ function buildSensitiveFieldNameSet(config: FormBlockProps | null): Set } async function persistFormSubmission(formData: FormData, config: FormBlockProps | null) { - const projectSlugRaw = formData.get("__projectSlug"); - let projectSlug: string | null = null; - - if (typeof projectSlugRaw === "string" && projectSlugRaw.trim() !== "") { - projectSlug = projectSlugRaw.trim(); - } else if (config?.extraParams && typeof config.extraParams.projectSlug === "string") { - const v = config.extraParams.projectSlug.trim(); - if (v !== "") { - projectSlug = v; - } - } + const projectSlug = resolveProjectSlug(formData, config); const sensitiveNames = buildSensitiveFieldNameSet(config); @@ -167,6 +173,44 @@ export async function POST(req: Request) { const successMessage = config?.successMessage; const errorMessage = config?.errorMessage; + const projectSlug = resolveProjectSlug(formData, config); + let projectStatus: string | null = null; + + if (projectSlug) { + try { + const project = await prisma.project.findUnique({ where: { slug: projectSlug } }); + if (project && typeof (project as any).status === "string") { + projectStatus = (project as any).status as string; + } + } catch { + // 상태 조회 실패는 제출 자체를 막지 않는다. (projectSlug 기반 저장은 계속 허용) + } + } + + const isProjectClosed = (status: string | null): boolean => { + if (!status) return false; + if (status === "ARCHIVED") return true; + if (status === "DRAFT") return true; + return false; + }; + + if (isProjectClosed(projectStatus)) { + const message = + errorMessage ?? + (projectStatus === "DRAFT" + ? "아직 공개되지 않은 프로젝트입니다." + : "이 프로젝트는 현재 폼 제출이 중단된 상태입니다."); + + return NextResponse.json( + { + ok: false, + error: "project_closed", + message, + }, + { status: 403 }, + ); + } + if (submitTarget === "internal") { await persistFormSubmission(formData, config); console.log("[forms/submit][internal]", { name, email, message }); diff --git a/src/app/api/projects/[slug]/route.ts b/src/app/api/projects/[slug]/route.ts index b3e0479..fb6d411 100644 --- a/src/app/api/projects/[slug]/route.ts +++ b/src/app/api/projects/[slug]/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; -import { PrismaClient } from "@prisma/client"; +import { PrismaClient, ProjectStatus } from "@prisma/client"; import { verifyAccessToken } from "@/features/auth/authCrypto"; +import { cachePublicProject, parseProjectContentJson } from "@/features/projects/publicCache"; const prisma = new PrismaClient(); @@ -98,3 +99,70 @@ export async function DELETE( ); } } + +export async function PATCH( + request: Request, + context: { params: { slug: string } | Promise<{ slug: string }> }, +) { + const authUser = await getAuthUserFromRequest(request); + if (!authUser) { + return NextResponse.json({ message: "프로젝트를 수정하려면 로그인이 필요합니다." }, { status: 401 }); + } + + const { slug } = await context.params; + + let body: any = null; + try { + body = await request.json(); + } catch { + return NextResponse.json({ message: "잘못된 요청 본문입니다." }, { status: 400 }); + } + + const nextStatus = body?.status as ProjectStatus | undefined; + const allowedStatuses: ProjectStatus[] = ["DRAFT", "PUBLISHED", "ARCHIVED"]; + + if (!nextStatus || !allowedStatuses.includes(nextStatus)) { + return NextResponse.json({ message: "유효하지 않은 프로젝트 상태입니다." }, { status: 400 }); + } + + try { + const project = await prisma.project.findUnique({ + where: { slug }, + }); + + if (!project || (project.userId && project.userId !== authUser.id)) { + return NextResponse.json({ message: "프로젝트를 찾을 수 없습니다." }, { status: 404 }); + } + + const updated = await prisma.project.update({ + where: { slug }, + data: { status: nextStatus }, + }); + + // 퍼블릭 캐시 스냅샷도 함께 갱신해 /p/[slug] 가 최신 상태를 사용할 수 있도록 한다. + try { + const { blocks, projectConfig } = parseProjectContentJson((updated as any).contentJson); + + cachePublicProject({ + slug: updated.slug, + title: updated.title, + status: (updated as any).status ?? nextStatus, + blocks, + projectConfig, + }); + } catch { + // 캐시 갱신 실패는 퍼블릭 렌더링에만 영향을 주므로 무시한다. + } + + return NextResponse.json(updated, { status: 200 }); + } catch (error: any) { + if (error?.code === "P2025") { + return NextResponse.json({ message: "프로젝트를 찾을 수 없습니다." }, { status: 404 }); + } + + return NextResponse.json( + { message: "프로젝트 수정 중 오류가 발생했습니다.", error: error?.message }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/projects/route.ts b/src/app/api/projects/route.ts index d1a00dd..828a674 100644 --- a/src/app/api/projects/route.ts +++ b/src/app/api/projects/route.ts @@ -1,8 +1,7 @@ import { NextResponse } from "next/server"; import { PrismaClient } from "@prisma/client"; import { verifyAccessToken } from "@/features/auth/authCrypto"; -import type { Block } from "@/features/editor/state/editorStore"; -import { cachePublicProject } from "@/features/projects/publicCache"; +import { cachePublicProject, parseProjectContentJson } from "@/features/projects/publicCache"; const prisma = new PrismaClient(); @@ -62,7 +61,80 @@ export async function GET(request: Request) { }, }); - return NextResponse.json(projects, { status: 200 }); + if (projects.length === 0) { + return NextResponse.json( + projects.map((p) => ({ + ...p, + todaySubmissions: 0, + totalSubmissions: 0, + })), + { status: 200 }, + ); + } + + const projectIds = projects.map((p) => p.id); + + const submissions = await prisma.formSubmission.findMany({ + where: { + userId: authUser.id, + projectId: { in: projectIds }, + }, + select: { + id: true, + projectId: true, + createdAt: true, + }, + }); + + const now = new Date(); + const startOfToday = new Date(now); + startOfToday.setHours(0, 0, 0, 0); + + const statsByProjectId = new Map< + string, + { + todaySubmissions: number; + totalSubmissions: number; + } + >(); + + for (const project of projects) { + statsByProjectId.set(project.id, { todaySubmissions: 0, totalSubmissions: 0 }); + } + + for (const submission of submissions) { + const projectId = submission.projectId; + if (!projectId) continue; + + const stat = statsByProjectId.get(projectId); + if (!stat) continue; + + stat.totalSubmissions += 1; + + const createdAt = + submission.createdAt instanceof Date + ? submission.createdAt + : new Date((submission.createdAt as unknown as string) ?? ""); + + if (createdAt >= startOfToday) { + stat.todaySubmissions += 1; + } + } + + const result = projects.map((project) => { + const stat = statsByProjectId.get(project.id) ?? { + todaySubmissions: 0, + totalSubmissions: 0, + }; + + return { + ...project, + todaySubmissions: stat.todaySubmissions, + totalSubmissions: stat.totalSubmissions, + }; + }); + + return NextResponse.json(result, { status: 200 }); } catch (error: any) { return NextResponse.json( { message: "프로젝트 목록을 가져오는 중 오류가 발생했습니다.", error: error?.message }, @@ -112,17 +184,15 @@ export async function POST(request: Request) { // 퍼블릭 슬러그 페이지(/p/[slug])가 DB 조회 없이도 최신 스냅샷을 바로 렌더링할 수 있도록 // 메모리 기반 캐시에 프로젝트 스냅샷을 저장해 둔다. - const rawContent = project.contentJson; - let contentBlocks: Block[] = []; - if (Array.isArray(rawContent)) { - contentBlocks = rawContent as unknown as Block[]; - } + const { blocks, projectConfig } = parseProjectContentJson((project as any).contentJson); try { cachePublicProject({ slug: project.slug, title: project.title, - contentJson: contentBlocks, + status: (project as any).status ?? "DRAFT", + blocks, + projectConfig, }); } catch { // 캐시 저장 실패는 퍼블릭 렌더링에만 영향을 주므로, API 응답 자체는 정상적으로 계속 진행한다. diff --git a/src/app/editor/page.tsx b/src/app/editor/page.tsx index f3d515a..73818f1 100644 --- a/src/app/editor/page.tsx +++ b/src/app/editor/page.tsx @@ -282,9 +282,27 @@ function EditorPageInner() { } const data = await res.json(); - if (data && Array.isArray(data.contentJson)) { - replaceBlocks(data.contentJson as any); - if (data.title && typeof data.title === "string") { + const rawContent = (data as any)?.contentJson; + let nextBlocks: Block[] | null = null; + let nextProjectConfig: ProjectConfig | null = null; + + if (Array.isArray(rawContent)) { + nextBlocks = rawContent as Block[]; + } else if (rawContent && typeof rawContent === "object" && Array.isArray(rawContent.blocks)) { + nextBlocks = rawContent.blocks as Block[]; + if (rawContent.projectConfig && typeof rawContent.projectConfig === "object") { + nextProjectConfig = rawContent.projectConfig as ProjectConfig; + } + } + + if (nextBlocks) { + replaceBlocks(nextBlocks as any); + if (nextProjectConfig) { + updateProjectConfig({ + ...(nextProjectConfig as ProjectConfig), + slug, + }); + } else if (data.title && typeof data.title === "string") { updateProjectConfig({ title: data.title, slug }); } else { updateProjectConfig({ slug }); @@ -483,9 +501,13 @@ function EditorPageInner() { // 1) 로컬스토리지에 현재 상태 저장 (슬러그 기준) if (typeof window !== "undefined") { const localKey = `pb:project:${slug}`; + const snapshot = { + blocks, + projectConfig, + }; const payload = { title, - contentJson: blocks, + contentJson: snapshot, }; window.localStorage.setItem(localKey, JSON.stringify(payload)); @@ -505,6 +527,10 @@ function EditorPageInner() { } // 2) 서버에도 저장 (백업/공유용) + const snapshotForServer = { + blocks, + projectConfig, + }; const response = await fetch("/api/projects", { method: "POST", headers: { @@ -513,7 +539,7 @@ function EditorPageInner() { body: JSON.stringify({ title, slug, - contentJson: blocks, + contentJson: snapshotForServer, }), }); @@ -559,10 +585,33 @@ function EditorPageInner() { const raw = window.localStorage.getItem(localKey); if (raw) { try { - const parsed = JSON.parse(raw); - if (parsed && Array.isArray(parsed.contentJson)) { - replaceBlocks(parsed.contentJson as any); - if (parsed.title && typeof parsed.title === "string") { + const parsed = JSON.parse(raw) as { + title?: string; + contentJson?: any; + }; + + const rawContent = (parsed as any)?.contentJson; + let nextBlocks: Block[] | null = null; + let nextProjectConfig: ProjectConfig | null = null; + + if (Array.isArray(rawContent)) { + nextBlocks = rawContent as Block[]; + } else if (rawContent && typeof rawContent === "object" && Array.isArray(rawContent.blocks)) { + nextBlocks = rawContent.blocks as Block[]; + if (rawContent.projectConfig && typeof rawContent.projectConfig === "object") { + nextProjectConfig = rawContent.projectConfig as ProjectConfig; + } + } + + if (nextBlocks) { + replaceBlocks(nextBlocks as any); + + if (nextProjectConfig) { + updateProjectConfig({ + ...(nextProjectConfig as ProjectConfig), + slug, + }); + } else if (parsed.title && typeof parsed.title === "string") { updateProjectConfig({ title: parsed.title, slug }); } else { updateProjectConfig({ slug }); @@ -588,9 +637,27 @@ function EditorPageInner() { } const data = await response.json(); - if (data && Array.isArray(data.contentJson)) { - replaceBlocks(data.contentJson as any); - if (data.title && typeof data.title === "string") { + const rawContent = (data as any)?.contentJson; + let nextBlocks: Block[] | null = null; + let nextProjectConfig: ProjectConfig | null = null; + + if (Array.isArray(rawContent)) { + nextBlocks = rawContent as Block[]; + } else if (rawContent && typeof rawContent === "object" && Array.isArray(rawContent.blocks)) { + nextBlocks = rawContent.blocks as Block[]; + if (rawContent.projectConfig && typeof rawContent.projectConfig === "object") { + nextProjectConfig = rawContent.projectConfig as ProjectConfig; + } + } + + if (nextBlocks) { + replaceBlocks(nextBlocks as any); + if (nextProjectConfig) { + updateProjectConfig({ + ...(nextProjectConfig as ProjectConfig), + slug, + }); + } else if (data.title && typeof data.title === "string") { updateProjectConfig({ title: data.title, slug }); } else { updateProjectConfig({ slug }); diff --git a/src/app/p/[slug]/PublicProjectPageClient.tsx b/src/app/p/[slug]/PublicProjectPageClient.tsx index bc2bdd7..3243275 100644 --- a/src/app/p/[slug]/PublicProjectPageClient.tsx +++ b/src/app/p/[slug]/PublicProjectPageClient.tsx @@ -5,9 +5,10 @@ import { getPublicPageRendererMessages } from "@/features/i18n/messages/publicPa interface PublicProjectPageClientProps { html: string; + canSubmit?: boolean; } -export default function PublicProjectPageClient({ html }: PublicProjectPageClientProps) { +export default function PublicProjectPageClient({ html, canSubmit = true }: PublicProjectPageClientProps) { const m = getPublicPageRendererMessages(null); const [toastMessage, setToastMessage] = useState(null); @@ -52,6 +53,12 @@ export default function PublicProjectPageClient({ html }: PublicProjectPageClien } } + if (!canSubmit) { + const errorMsg = (config && config.errorMessage) || m.submitErrorDefault; + showToast(errorMsg); + return; + } + const action = form.getAttribute("action") || "/api/forms/submit"; fetch(action, { method: "POST", body: formData }) diff --git a/src/app/p/[slug]/page.tsx b/src/app/p/[slug]/page.tsx index b89df23..708c4c7 100644 --- a/src/app/p/[slug]/page.tsx +++ b/src/app/p/[slug]/page.tsx @@ -1,9 +1,10 @@ import { notFound } from "next/navigation"; +import type { Metadata } from "next"; import { PrismaClient } from "@prisma/client"; import type { Block, ProjectConfig } from "@/features/editor/state/editorStore"; import { buildStaticHtml } from "@/features/export/buildStaticHtml"; -import { getCachedPublicProject } from "@/features/projects/publicCache"; +import { getCachedPublicProject, parseProjectContentJson } from "@/features/projects/publicCache"; import PublicProjectPageClient from "./PublicProjectPageClient"; const prisma = new PrismaClient(); @@ -14,6 +15,113 @@ interface PageParams { params: Promise<{ slug: string }>; } +async function resolveSlugParam(rawParams: { slug: string } | Promise<{ slug: string }>): Promise { + const resolved = await rawParams; + const slugRaw = resolved?.slug ?? ""; + return slugRaw.toString().trim(); +} + +export async function generateMetadata( + props: + | { params: { slug: string } } + | { + params: Promise<{ slug: string }>; + }, +): Promise { + const slug = await resolveSlugParam((props as any).params); + if (!slug) { + return {}; + } + + const cached = getCachedPublicProject(slug); + + let status: string | null = null; + let projectConfigFromContent: ProjectConfig | null = null; + let fallbackTitle: string | null = null; + + if (cached) { + status = (cached as any).status ?? null; + projectConfigFromContent = (cached as any).projectConfig ?? null; + fallbackTitle = cached.title ?? null; + } else { + const project = await prisma.project.findUnique({ + where: { slug }, + select: { + title: true, + status: true, + contentJson: true, + }, + }); + + if (!project) { + return {}; + } + + status = (project as any).status ?? null; + const { projectConfig } = parseProjectContentJson((project as any).contentJson); + projectConfigFromContent = projectConfig; + fallbackTitle = project.title ?? null; + } + + if (status === "DRAFT") { + // DRAFT 프로젝트는 404 처리되므로 별도의 메타데이터를 노출하지 않는다. + return {}; + } + + const baseConfig: ProjectConfig = { + ...(projectConfigFromContent ?? {}), + title: (projectConfigFromContent?.title ?? fallbackTitle ?? slug) as any, + slug, + } as ProjectConfig; + + const baseTitleRaw = (baseConfig.title ?? "").trim() || "Page Builder"; + const seoTitleRaw = (baseConfig as any).seoTitle + ? String((baseConfig as any).seoTitle).trim() + : ""; + const pageTitle = seoTitleRaw || baseTitleRaw; + + const seoDescriptionRaw = (baseConfig as any).seoDescription + ? String((baseConfig as any).seoDescription).trim() + : ""; + const seoCanonicalRaw = (baseConfig as any).seoCanonicalUrl + ? String((baseConfig as any).seoCanonicalUrl).trim() + : ""; + const seoOgImageRaw = (baseConfig as any).seoOgImageUrl + ? String((baseConfig as any).seoOgImageUrl).trim() + : ""; + const seoNoIndex = Boolean((baseConfig as any).seoNoIndex); + + const description = seoDescriptionRaw || undefined; + const ogUrl = seoCanonicalRaw || undefined; + const ogImage = seoOgImageRaw || undefined; + + const metadata: Metadata = { + title: pageTitle, + description, + openGraph: { + title: pageTitle, + description, + type: "website", + url: ogUrl, + images: ogImage ? [ogImage] : undefined, + }, + twitter: { + card: "summary_large_image", + title: pageTitle, + description, + images: ogImage ? [ogImage] : undefined, + }, + robots: seoNoIndex + ? { + index: false, + follow: false, + } + : undefined, + }; + + return metadata; +} + // /p/[slug] 공개 페이지 // - slug 에 해당하는 Project 의 contentJson 을 불러와 buildStaticHtml 로 정적 HTML 을 생성한다. // - Export ZIP 에 포함되는 index.html 과 동일한 app-root DOM 구조를 사용해 렌더링한다. @@ -33,16 +141,21 @@ export default async function PublicProjectPage({ params }: PageParams) { let blocks: Block[]; let title: string; + let status: string | null = null; + let projectConfigFromContent: ProjectConfig | null = null; if (cached) { - blocks = cached.contentJson ?? []; + blocks = cached.blocks ?? []; title = cached.title ?? ""; + status = (cached as any).status ?? null; + projectConfigFromContent = (cached as any).projectConfig ?? null; } else { const project = await prisma.project.findUnique({ where: { slug }, select: { title: true, slug: true, + status: true, contentJson: true, }, }); @@ -51,24 +164,40 @@ export default async function PublicProjectPage({ params }: PageParams) { notFound(); } - const rawContent = project.contentJson; - let contentBlocks: Block[] = []; - if (Array.isArray(rawContent)) { - contentBlocks = rawContent as unknown as Block[]; - } - blocks = contentBlocks; + const { blocks: parsedBlocks, projectConfig } = parseProjectContentJson( + (project as any).contentJson, + ); + blocks = parsedBlocks; title = project.title; + status = (project as any).status ?? null; + projectConfigFromContent = projectConfig; + } + + if (status === "DRAFT") { + notFound(); } // Export index.html 과 동일한 규칙으로 ProjectConfig 를 구성한다. - const projectConfig: ProjectConfig = { + const baseConfig: ProjectConfig = { + ...(projectConfigFromContent ?? {}), title, slug, - canvasPreset: "full", - canvasBgColorHex: "#020617", - bodyBgColorHex: "#020617", } as ProjectConfig; + if (!baseConfig.canvasPreset) { + baseConfig.canvasPreset = "full" as ProjectConfig["canvasPreset"]; + } + if (!baseConfig.canvasBgColorHex) { + baseConfig.canvasBgColorHex = "#020617"; + } + if (!baseConfig.bodyBgColorHex) { + baseConfig.bodyBgColorHex = "#020617"; + } + + const projectConfig: ProjectConfig = baseConfig; + + const canSubmit = status === "ARCHIVED" ? false : true; + const fullHtml = buildStaticHtml(blocks, projectConfig); // buildStaticHtml 이 생성한 index.html 에서
...
블록만 추출해 렌더링한다. @@ -76,5 +205,24 @@ export default async function PublicProjectPage({ params }: PageParams) { const mainMatch = fullHtml.match(//); const mainHtml = mainMatch ? mainMatch[0] : ""; - return ; + return ( + <> + {(projectConfig as any).headHtml ? ( +
+ ) : null} + {(projectConfig as any).trackingScript ? ( +
...) 이므로 그대로 삽입한다. + dangerouslySetInnerHTML={{ + __html: String((projectConfig as any).trackingScript), + }} + /> + ) : null} + + + ); } diff --git a/src/app/projects/page.tsx b/src/app/projects/page.tsx index de3ab88..7a35574 100644 --- a/src/app/projects/page.tsx +++ b/src/app/projects/page.tsx @@ -15,6 +15,8 @@ import { FolderKanban, SunMoon, BarChart2, + HelpCircle, + ExternalLink, } from "lucide-react"; import { useAppLocale } from "@/features/i18n/LocaleProvider"; import { getDashboardMessages } from "@/features/i18n/messages/dashboard"; @@ -27,6 +29,8 @@ interface ProjectListItem { status: string; createdAt: string; updatedAt: string; + todaySubmissions?: number; + totalSubmissions?: number; } export default function ProjectsPage() { @@ -41,6 +45,7 @@ export default function ProjectsPage() { const [currentPage, setCurrentPage] = useState(1); const [isMenuOpen, setIsMenuOpen] = useState(false); const [emailVerified, setEmailVerified] = useState(null); + const [isStatusHelpOpen, setIsStatusHelpOpen] = useState(false); const pageSize = 10; useEffect(() => { @@ -117,6 +122,50 @@ export default function ProjectsPage() { setCurrentPage((prev) => Math.min(prev, totalPages)); }, [projects.length, pageSize]); + const handleStatusChange = async ( + slug: string, + nextStatus: "DRAFT" | "PUBLISHED" | "ARCHIVED", + ) => { + try { + const res = await fetch(`/api/projects/${encodeURIComponent(slug)}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ status: nextStatus }), + }); + + if (!res.ok) { + setStatus("error"); + setErrorMessage(p.errorFetchGeneric); + return; + } + + const updated = (await res.json().catch(() => null)) as + | (ProjectListItem & { status?: string }) + | null; + + setProjects((prev) => + prev.map((project) => { + if (project.slug !== slug) return project; + const updatedStatus = updated?.status ?? nextStatus; + const updatedAt = (updated as any)?.updatedAt ?? project.updatedAt; + return { + ...project, + status: updatedStatus, + updatedAt, + }; + }), + ); + + setStatus("idle"); + setErrorMessage(null); + } catch { + setStatus("error"); + setErrorMessage(p.errorFetchGeneric); + } + }; + const handleDelete = async (slug: string) => { if (typeof window !== "undefined") { const confirmed = window.confirm(p.confirmDeleteSingle); @@ -391,7 +440,30 @@ export default function ProjectsPage() { {p.tableHeaderTitle} {p.tableHeaderSlug} - {p.tableHeaderStatus} + + + {p.tableHeaderStatus} + + + {isStatusHelpOpen && ( +
+
+                          {p.tableHeaderStatusHelpTooltip}
+                        
+
+ )} + + {p.tableHeaderSubmissions} {p.tableHeaderCreatedAt} {p.tableHeaderUpdatedAt} {p.tableHeaderActions} @@ -424,7 +496,36 @@ export default function ProjectsPage() { {project.title} {project.slug} - {project.status} + + + + + +