From be30303bc89247747fcbfbd8e380a0fa7bdbf27c Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Wed, 18 Mar 2026 14:04:10 +0800 Subject: [PATCH] refactor(public-pages): unify download blogs and auth styling --- .../EmailVerificationContent.tsx | 310 ++++++------ src/app/(auth)/email-verification/page.tsx | 18 +- src/app/(auth)/layout.tsx | 10 +- src/app/(auth)/login/LoginContent.tsx | 17 +- src/app/(auth)/login/LoginForm.tsx | 348 +++++++------ src/app/(auth)/login/page.tsx | 24 +- src/app/(auth)/register/RegisterContent.tsx | 45 +- src/app/(auth)/register/page.tsx | 24 +- src/app/blogs/[...slug]/page.tsx | 180 ++++--- src/app/blogs/page.tsx | 47 +- src/app/download/[...segments]/page.tsx | 144 +++--- src/app/download/page.tsx | 83 ++-- src/components/BrandCTA.tsx | 67 ++- src/components/auth/AuthLayout.tsx | 213 +++++--- src/components/blog/BlogList.tsx | 460 ++++++++++-------- src/components/download/Breadcrumbs.tsx | 31 +- src/components/download/CardGrid.tsx | 150 +++--- src/components/download/CopyButton.tsx | 18 +- src/components/download/DownloadBrowser.tsx | 117 +++-- .../download/DownloadListingContent.tsx | 184 ++++--- src/components/download/DownloadNotFound.tsx | 14 +- src/components/download/DownloadSummary.tsx | 87 ++-- src/components/download/FileTable.tsx | 175 ++++--- 23 files changed, 1589 insertions(+), 1177 deletions(-) diff --git a/src/app/(auth)/email-verification/EmailVerificationContent.tsx b/src/app/(auth)/email-verification/EmailVerificationContent.tsx index ec027f1..86df3d4 100644 --- a/src/app/(auth)/email-verification/EmailVerificationContent.tsx +++ b/src/app/(auth)/email-verification/EmailVerificationContent.tsx @@ -1,4 +1,4 @@ -'use client' +"use client"; import { ChangeEvent, @@ -8,169 +8,186 @@ import { useMemo, useRef, useState, -} from 'react' -import { useRouter, useSearchParams } from 'next/navigation' +} from "react"; +import { useRouter, useSearchParams } from "next/navigation"; -import { AuthLayout } from '@components/auth/AuthLayout' -import { useLanguage } from '@i18n/LanguageProvider' -import { translations } from '@i18n/translations' +import { + AUTH_INPUT_CLASS, + AUTH_PRIMARY_BUTTON_CLASS, + AUTH_SECONDARY_BUTTON_CLASS, + AuthLayout, +} from "@components/auth/AuthLayout"; +import { useLanguage } from "@i18n/LanguageProvider"; +import { translations } from "@i18n/translations"; -const VERIFICATION_CODE_LENGTH = 6 -const RESEND_COOLDOWN_SECONDS = 60 +const VERIFICATION_CODE_LENGTH = 6; +const RESEND_COOLDOWN_SECONDS = 60; -const EMAIL_QUERY_KEYS = ['email', 'address', 'identifier', 'account'] as const +const EMAIL_QUERY_KEYS = ["email", "address", "identifier", "account"] as const; -type AlertState = { type: 'error' | 'success' | 'info'; message: string } +type AlertState = { type: "error" | "success" | "info"; message: string }; export default function EmailVerificationContent() { - const { language } = useLanguage() - const t = translations[language].auth.emailVerification - const router = useRouter() - const searchParams = useSearchParams() - const redirectTimeoutRef = useRef(null) + const { language } = useLanguage(); + const t = translations[language].auth.emailVerification; + const router = useRouter(); + const searchParams = useSearchParams(); + const redirectTimeoutRef = useRef(null); const email = useMemo(() => { for (const key of EMAIL_QUERY_KEYS) { - const value = searchParams.get(key) - if (typeof value === 'string' && value.trim().length > 0) { - return value.trim().toLowerCase() + const value = searchParams.get(key); + if (typeof value === "string" && value.trim().length > 0) { + return value.trim().toLowerCase(); } } - return '' - }, [searchParams]) + return ""; + }, [searchParams]); - const statusParam = searchParams.get('status') - const errorParam = searchParams.get('error') + const statusParam = searchParams.get("status"); + const errorParam = searchParams.get("error"); - const descriptionEmail = email || t.emailFallback || '' + const descriptionEmail = email || t.emailFallback || ""; const description = useMemo(() => { - if (!t.description.includes('{{email}}')) { - return t.description + if (!t.description.includes("{{email}}")) { + return t.description; } - return t.description.replace('{{email}}', descriptionEmail) - }, [descriptionEmail, t.description]) + return t.description.replace("{{email}}", descriptionEmail); + }, [descriptionEmail, t.description]); const initialAlert = useMemo(() => { - if (statusParam === 'sent') { - return { type: 'info', message: t.alerts.verificationSent } + if (statusParam === "sent") { + return { type: "info", message: t.alerts.verificationSent }; } - if (statusParam === 'resent') { + if (statusParam === "resent") { return { - type: 'success', + type: "success", message: t.alerts.verificationResent ?? t.alerts.verificationSent, - } + }; } if (errorParam) { const normalized = errorParam .trim() .toLowerCase() - .replace(/[^a-z0-9]+/g, '_') - .replace(/^_+|_+$/g, '') + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); const errorMap: Record = { missing_verification: t.alerts.codeRequired, verification_failed: t.alerts.verificationFailed, invalid_code: t.alerts.verificationFailed, invalid_email: t.alerts.missingEmail, code_required: t.alerts.codeRequired, - } - const message = errorMap[normalized] ?? t.alerts.genericError - return { type: normalized === 'already_verified' ? 'success' : 'error', message } + }; + const message = errorMap[normalized] ?? t.alerts.genericError; + return { + type: normalized === "already_verified" ? "success" : "error", + message, + }; } if (!email) { - return { type: 'info', message: t.alerts.missingEmail } + return { type: "info", message: t.alerts.missingEmail }; } - return null - }, [email, errorParam, statusParam, t.alerts]) + return null; + }, [email, errorParam, statusParam, t.alerts]); - const [alert, setAlert] = useState(initialAlert) - const [code, setCode] = useState('') - const [isSubmitting, setIsSubmitting] = useState(false) - const [isResending, setIsResending] = useState(false) - const [resendCooldown, setResendCooldown] = useState(0) + const [alert, setAlert] = useState(initialAlert); + const [code, setCode] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isResending, setIsResending] = useState(false); + const [resendCooldown, setResendCooldown] = useState(0); useEffect(() => { - setAlert(initialAlert) - }, [initialAlert]) + setAlert(initialAlert); + }, [initialAlert]); useEffect(() => { if (resendCooldown <= 0) { - return undefined + return undefined; } const timeoutId = window.setTimeout(() => { - setResendCooldown(previous => Math.max(previous - 1, 0)) - }, 1000) + setResendCooldown((previous) => Math.max(previous - 1, 0)); + }, 1000); return () => { - window.clearTimeout(timeoutId) - } - }, [resendCooldown]) + window.clearTimeout(timeoutId); + }; + }, [resendCooldown]); useEffect(() => { return () => { if (redirectTimeoutRef.current !== null) { - window.clearTimeout(redirectTimeoutRef.current) + window.clearTimeout(redirectTimeoutRef.current); } - } - }, []) + }; + }, []); - const handleCodeChange = useCallback((event: ChangeEvent) => { - const digitsOnly = event.target.value.replace(/\D/g, '').slice(0, VERIFICATION_CODE_LENGTH) - setCode(digitsOnly) - }, []) + const handleCodeChange = useCallback( + (event: ChangeEvent) => { + const digitsOnly = event.target.value + .replace(/\D/g, "") + .slice(0, VERIFICATION_CODE_LENGTH); + setCode(digitsOnly); + }, + [], + ); - const hasEmail = email.length > 0 + const hasEmail = email.length > 0; const isSubmitDisabled = - isSubmitting || !hasEmail || code.length !== VERIFICATION_CODE_LENGTH - const isResendDisabled = isResending || resendCooldown > 0 || !hasEmail + isSubmitting || !hasEmail || code.length !== VERIFICATION_CODE_LENGTH; + const isResendDisabled = isResending || resendCooldown > 0 || !hasEmail; const handleSubmit = useCallback( async (event: FormEvent) => { - event.preventDefault() + event.preventDefault(); if (isSubmitting) { - return + return; } if (!hasEmail) { - setAlert({ type: 'error', message: t.alerts.missingEmail }) - return + setAlert({ type: "error", message: t.alerts.missingEmail }); + return; } if (code.length !== VERIFICATION_CODE_LENGTH) { - setAlert({ type: 'error', message: t.alerts.codeRequired }) - return + setAlert({ type: "error", message: t.alerts.codeRequired }); + return; } - setIsSubmitting(true) - setAlert(null) + setIsSubmitting(true); + setAlert(null); try { - const response = await fetch('/api/auth/verify-email', { - method: 'POST', + const response = await fetch("/api/auth/verify-email", { + method: "POST", headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, body: JSON.stringify({ email, code }), - }) + }); const payload = (await response.json().catch(() => ({}))) as { - success?: boolean - error?: string | null - } + success?: boolean; + error?: string | null; + }; if (!response.ok || payload?.success !== true) { - const errorCode = typeof payload?.error === 'string' ? payload.error : 'verification_failed' + const errorCode = + typeof payload?.error === "string" + ? payload.error + : "verification_failed"; const normalized = errorCode .trim() .toLowerCase() - .replace(/[^a-z0-9]+/g, '_') - .replace(/^_+|_+$/g, '') + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); - if (normalized === 'already_verified') { - const message = t.alerts.verificationReady ?? t.alerts.verificationSent - setAlert({ type: 'success', message }) + if (normalized === "already_verified") { + const message = + t.alerts.verificationReady ?? t.alerts.verificationSent; + setAlert({ type: "success", message }); redirectTimeoutRef.current = window.setTimeout(() => { - router.push('/login?registered=1') - }, 1200) - return + router.push("/login?registered=1"); + }, 1200); + return; } const errorMap: Record = { @@ -179,93 +196,101 @@ export default function EmailVerificationContent() { verification_failed: t.alerts.verificationFailed, invalid_email: t.alerts.missingEmail, code_expired: t.alerts.verificationFailed, - } - const message = errorMap[normalized] ?? t.alerts.genericError - setAlert({ type: 'error', message }) - return + }; + const message = errorMap[normalized] ?? t.alerts.genericError; + setAlert({ type: "error", message }); + return; } - const successMessage = t.alerts.verificationReady ?? t.alerts.verificationSent - setAlert({ type: 'success', message: successMessage }) - setCode('') + const successMessage = + t.alerts.verificationReady ?? t.alerts.verificationSent; + setAlert({ type: "success", message: successMessage }); + setCode(""); redirectTimeoutRef.current = window.setTimeout(() => { - router.push('/login?registered=1') - }, 1200) + router.push("/login?registered=1"); + }, 1200); } catch (error) { - console.error('Email verification request failed', error) - setAlert({ type: 'error', message: t.alerts.genericError }) + console.error("Email verification request failed", error); + setAlert({ type: "error", message: t.alerts.genericError }); } finally { - setIsSubmitting(false) + setIsSubmitting(false); } - }, [code, email, hasEmail, isSubmitting, router, t.alerts]) + }, + [code, email, hasEmail, isSubmitting, router, t.alerts], + ); const handleResend = useCallback(async () => { if (isResending || !hasEmail) { if (!hasEmail) { - setAlert({ type: 'error', message: t.alerts.missingEmail }) + setAlert({ type: "error", message: t.alerts.missingEmail }); } - return + return; } - setIsResending(true) + setIsResending(true); try { - const response = await fetch('/api/auth/verify-email/send', { - method: 'POST', + const response = await fetch("/api/auth/verify-email/send", { + method: "POST", headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, body: JSON.stringify({ email }), - }) + }); const payload = (await response.json().catch(() => ({}))) as { - success?: boolean - error?: string | null - } + success?: boolean; + error?: string | null; + }; if (!response.ok || payload?.success !== true) { - const errorCode = typeof payload?.error === 'string' ? payload.error : 'verification_failed' + const errorCode = + typeof payload?.error === "string" + ? payload.error + : "verification_failed"; const normalized = errorCode .trim() .toLowerCase() - .replace(/[^a-z0-9]+/g, '_') - .replace(/^_+|_+$/g, '') + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); - if (normalized === 'already_verified') { - const message = t.alerts.verificationReady ?? t.alerts.verificationSent - setAlert({ type: 'success', message }) + if (normalized === "already_verified") { + const message = + t.alerts.verificationReady ?? t.alerts.verificationSent; + setAlert({ type: "success", message }); redirectTimeoutRef.current = window.setTimeout(() => { - router.push('/login?registered=1') - }, 1200) - return + router.push("/login?registered=1"); + }, 1200); + return; } const errorMap: Record = { invalid_email: t.alerts.missingEmail, verification_failed: t.alerts.verificationFailed, rate_limited: t.alerts.genericError, - } - const message = errorMap[normalized] ?? t.alerts.genericError - setAlert({ type: 'error', message }) - return + }; + const message = errorMap[normalized] ?? t.alerts.genericError; + setAlert({ type: "error", message }); + return; } - const successMessage = t.alerts.verificationResent ?? t.alerts.verificationSent - setAlert({ type: 'success', message: successMessage }) - setResendCooldown(RESEND_COOLDOWN_SECONDS) + const successMessage = + t.alerts.verificationResent ?? t.alerts.verificationSent; + setAlert({ type: "success", message: successMessage }); + setResendCooldown(RESEND_COOLDOWN_SECONDS); } catch (error) { - console.error('Email verification resend failed', error) - setAlert({ type: 'error', message: t.alerts.genericError }) + console.error("Email verification resend failed", error); + setAlert({ type: "error", message: t.alerts.genericError }); } finally { - setIsResending(false) + setIsResending(false); } - }, [email, hasEmail, isResending, router, t.alerts]) + }, [email, hasEmail, isResending, router, t.alerts]); const resendLabel = isResending - ? t.resend.resending ?? t.resend.label + ? (t.resend.resending ?? t.resend.label) : resendCooldown > 0 ? `${t.resend.label} (${resendCooldown}s)` - : t.resend.label + : t.resend.label; return (
-
@@ -415,7 +416,7 @@ export default function LoginContent({ {t.form.remember} @@ -423,7 +424,7 @@ export default function LoginContent({ type="submit" disabled={isSubmitting} aria-busy={isSubmitting} - className="w-full rounded-2xl bg-gradient-to-r from-sky-500 to-blue-500 px-4 py-2.5 text-sm font-semibold text-white shadow-lg shadow-sky-500/20 transition hover:from-sky-500 hover:to-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500 disabled:cursor-not-allowed disabled:opacity-70" + className={`w-full ${AUTH_PRIMARY_BUTTON_CLASS}`} > {isSubmitting ? (t.form.submitting ?? t.form.submit) : t.form.submit} diff --git a/src/app/(auth)/login/LoginForm.tsx b/src/app/(auth)/login/LoginForm.tsx index 5f5aa23..d1c198f 100644 --- a/src/app/(auth)/login/LoginForm.tsx +++ b/src/app/(auth)/login/LoginForm.tsx @@ -1,132 +1,144 @@ -'use client' +"use client"; -import { FormEvent, useEffect, useState } from 'react' -import Link from 'next/link' -import { useRouter } from 'next/navigation' +import { FormEvent, useEffect, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; -import { useLanguage } from '@i18n/LanguageProvider' -import { translations } from '@i18n/translations' -import { useUserStore } from '@lib/userStore' +import { + AUTH_CHECKBOX_CLASS, + AUTH_HINT_PANEL_CLASS, + AUTH_INPUT_CLASS, + AUTH_PRIMARY_BUTTON_CLASS, + AUTH_SECONDARY_BUTTON_CLASS, + AUTH_TEXT_LINK_CLASS, +} from "@components/auth/AuthLayout"; +import { useLanguage } from "@i18n/LanguageProvider"; +import { translations } from "@i18n/translations"; +import { useUserStore } from "@lib/userStore"; export function LoginForm() { - const router = useRouter() - const { language } = useLanguage() - const pageCopy = translations[language].login - const authCopy = translations[language].auth.login - const navCopy = translations[language].nav.account - const user = useUserStore((state) => state.user) - const login = useUserStore((state) => state.login) - const userEmail = user?.email ?? '' - const [identifier, setIdentifier] = useState(() => userEmail) - const [password, setPassword] = useState('') - const [totpCode, setTotpCode] = useState('') - const [remember, setRemember] = useState(false) - const [error, setError] = useState(null) - const [isSubmitting, setIsSubmitting] = useState(false) - const [mfaRequirement, setMfaRequirement] = useState<'optional' | 'required'>(() => - user?.mfaEnabled ? 'required' : 'optional', - ) + const router = useRouter(); + const { language } = useLanguage(); + const pageCopy = translations[language].login; + const authCopy = translations[language].auth.login; + const navCopy = translations[language].nav.account; + const user = useUserStore((state) => state.user); + const login = useUserStore((state) => state.login); + const userEmail = user?.email ?? ""; + const [identifier, setIdentifier] = useState(() => userEmail); + const [password, setPassword] = useState(""); + const [totpCode, setTotpCode] = useState(""); + const [remember, setRemember] = useState(false); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [mfaRequirement, setMfaRequirement] = useState<"optional" | "required">( + () => (user?.mfaEnabled ? "required" : "optional"), + ); useEffect(() => { if (userEmail && identifier.trim().length === 0) { - setIdentifier(userEmail) + setIdentifier(userEmail); } - }, [identifier, userEmail]) + }, [identifier, userEmail]); useEffect(() => { - setTotpCode('') - }, [identifier]) + setTotpCode(""); + }, [identifier]); useEffect(() => { - if (mfaRequirement !== 'required' && totpCode !== '') { - setTotpCode('') + if (mfaRequirement !== "required" && totpCode !== "") { + setTotpCode(""); } - }, [mfaRequirement, totpCode]) + }, [mfaRequirement, totpCode]); useEffect(() => { - let isActive = true - const trimmedIdentifier = identifier.trim() + let isActive = true; + const trimmedIdentifier = identifier.trim(); if (!trimmedIdentifier) { if (isActive) { - setMfaRequirement('optional') + setMfaRequirement("optional"); } return () => { - isActive = false - } + isActive = false; + }; } - const normalizedIdentifier = trimmedIdentifier.toLowerCase() + const normalizedIdentifier = trimmedIdentifier.toLowerCase(); - const controller = new AbortController() - const signal = controller.signal + const controller = new AbortController(); + const signal = controller.signal; const timeoutId = window.setTimeout(async () => { try { const response = await fetch( `/api/auth/mfa/status?identifier=${encodeURIComponent(normalizedIdentifier)}`, { - method: 'GET', - cache: 'no-store', + method: "GET", + cache: "no-store", signal, }, - ) + ); if (!isActive || signal.aborted) { - return + return; } if (!response.ok) { - setMfaRequirement('optional') - return + setMfaRequirement("optional"); + return; } const payload = (await response.json().catch(() => ({}))) as { - mfa?: { totpEnabled?: boolean } - } + mfa?: { totpEnabled?: boolean }; + }; - const requiresMfa = Boolean(payload?.mfa?.totpEnabled) - setMfaRequirement(requiresMfa ? 'required' : 'optional') + const requiresMfa = Boolean(payload?.mfa?.totpEnabled); + setMfaRequirement(requiresMfa ? "required" : "optional"); } catch (lookupError) { - if ((lookupError as Error)?.name === 'AbortError' || signal.aborted) { - return + if ((lookupError as Error)?.name === "AbortError" || signal.aborted) { + return; } - setMfaRequirement('optional') + setMfaRequirement("optional"); } - }, 300) + }, 300); return () => { - isActive = false - controller.abort() - window.clearTimeout(timeoutId) - } - }, [identifier]) + isActive = false; + controller.abort(); + window.clearTimeout(timeoutId); + }; + }, [identifier]); useEffect(() => { if (user?.mfaEnabled) { - setMfaRequirement('required') + setMfaRequirement("required"); } - }, [user?.mfaEnabled]) + }, [user?.mfaEnabled]); const handleSubmit = async (event: FormEvent) => { - event.preventDefault() + event.preventDefault(); - const trimmedIdentifier = identifier.trim() + const trimmedIdentifier = identifier.trim(); if (!trimmedIdentifier) { - setError(pageCopy.missingUsername) - return + setError(pageCopy.missingUsername); + return; } if (!password) { - setError(pageCopy.missingPassword) - return + setError(pageCopy.missingPassword); + return; } - const requiresTotp = mfaRequirement === 'required' - const sanitizedTotp = totpCode.replace(/\D/g, '') + const requiresTotp = mfaRequirement === "required"; + const sanitizedTotp = totpCode.replace(/\D/g, ""); if (requiresTotp) { if (!sanitizedTotp) { - setError(pageCopy.missingTotp ?? authCopy.alerts.mfa?.missing ?? authCopy.alerts.missingCredentials) - return + setError( + pageCopy.missingTotp ?? + authCopy.alerts.mfa?.missing ?? + authCopy.alerts.missingCredentials, + ); + return; } if (sanitizedTotp.length !== 6) { @@ -135,8 +147,8 @@ export function LoginForm() { authCopy.alerts.mfa?.invalid ?? pageCopy.missingTotp ?? authCopy.alerts.missingCredentials, - ) - return + ); + return; } } else if (sanitizedTotp && sanitizedTotp.length !== 6) { setError( @@ -144,18 +156,18 @@ export function LoginForm() { authCopy.alerts.mfa?.invalid ?? pageCopy.missingTotp ?? authCopy.alerts.missingCredentials, - ) - return + ); + return; } - setError(null) - setIsSubmitting(true) + setError(null); + setIsSubmitting(true); try { - const response = await fetch('/api/auth/login', { - method: 'POST', + const response = await fetch("/api/auth/login", { + method: "POST", headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', + "Content-Type": "application/json", + Accept: "application/json", }, body: JSON.stringify({ email: trimmedIdentifier, @@ -163,108 +175,114 @@ export function LoginForm() { totp: sanitizedTotp.length === 6 ? sanitizedTotp : undefined, remember, }), - credentials: 'include', - }) + credentials: "include", + }); const payload = (await response.json().catch(() => ({}))) as { - success?: boolean - error?: string | null - needMfa?: boolean - } + success?: boolean; + error?: string | null; + needMfa?: boolean; + }; if (payload.needMfa) { - setMfaRequirement('required') - router.replace('/panel/account?setupMfa=1') - router.refresh() - return + setMfaRequirement("required"); + router.replace("/panel/account?setupMfa=1"); + router.refresh(); + return; } - const isSuccessful = response.ok && (payload.success ?? true) + const isSuccessful = response.ok && (payload.success ?? true); if (!isSuccessful) { - const messageKey = payload.error ?? 'generic_error' + const messageKey = payload.error ?? "generic_error"; if ( - messageKey === 'mfa_code_required' || - messageKey === 'invalid_mfa_code' || - messageKey === 'mfa_required' || - messageKey === 'mfa_setup_required' || - messageKey === 'mfa_challenge_failed' + messageKey === "mfa_code_required" || + messageKey === "invalid_mfa_code" || + messageKey === "mfa_required" || + messageKey === "mfa_setup_required" || + messageKey === "mfa_challenge_failed" ) { - setMfaRequirement('required') + setMfaRequirement("required"); } switch (messageKey) { - case 'missing_credentials': - setError(authCopy.alerts.missingCredentials) - break - case 'invalid_credentials': - setError(pageCopy.invalidCredentials) - break - case 'user_not_found': - setError(pageCopy.userNotFound) - break - case 'mfa_code_required': - setError(authCopy.alerts.mfa?.missing ?? pageCopy.missingTotp ?? authCopy.alerts.missingCredentials) - break - case 'invalid_mfa_code': - setError(authCopy.alerts.mfa?.invalid ?? pageCopy.genericError) - break - case 'mfa_challenge_failed': - setError(authCopy.alerts.mfa?.challengeFailed ?? pageCopy.genericError) - break - case 'account_service_unreachable': - setError(pageCopy.serviceUnavailable ?? pageCopy.genericError) - break + case "missing_credentials": + setError(authCopy.alerts.missingCredentials); + break; + case "invalid_credentials": + setError(pageCopy.invalidCredentials); + break; + case "user_not_found": + setError(pageCopy.userNotFound); + break; + case "mfa_code_required": + setError( + authCopy.alerts.mfa?.missing ?? + pageCopy.missingTotp ?? + authCopy.alerts.missingCredentials, + ); + break; + case "invalid_mfa_code": + setError(authCopy.alerts.mfa?.invalid ?? pageCopy.genericError); + break; + case "mfa_challenge_failed": + setError( + authCopy.alerts.mfa?.challengeFailed ?? pageCopy.genericError, + ); + break; + case "account_service_unreachable": + setError(pageCopy.serviceUnavailable ?? pageCopy.genericError); + break; default: - setError(pageCopy.genericError) - break + setError(pageCopy.genericError); + break; } - return + return; } - await login() - router.replace('/') - router.refresh() + await login(); + router.replace("/"); + router.refresh(); } catch (submitError) { - console.warn('Login failed', submitError) - setError(pageCopy.genericError) + console.warn("Login failed", submitError); + setError(pageCopy.genericError); } finally { - setIsSubmitting(false) + setIsSubmitting(false); } - } + }; const handleGoHome = () => { - router.replace('/') - router.refresh() - } + router.replace("/"); + router.refresh(); + }; const handleLogout = () => { - router.push('/logout') - } + router.push("/logout"); + }; - const requiresTotpInput = mfaRequirement === 'required' + const requiresTotpInput = mfaRequirement === "required"; const mfaModeLabel = requiresTotpInput ? authCopy.form.mfa.passwordAndTotp - : authCopy.form.mfa.passwordOnly + : authCopy.form.mfa.passwordOnly; return ( <> {user ? ( -
+

- {pageCopy.success.replace('{username}', user.username)} + {pageCopy.success.replace("{username}", user.username)}

@@ -273,9 +291,17 @@ export function LoginForm() { ) : null} {!user ? ( -
+
-
-

{authCopy.form.mfa.mode}

-
- {mfaModeLabel} -
+

+ {authCopy.form.mfa.mode} +

+
{mfaModeLabel}
-
@@ -312,12 +341,15 @@ export function LoginForm() { value={password} onChange={(event) => setPassword(event.target.value)} placeholder={authCopy.form.passwordPlaceholder} - className="w-full rounded-2xl border border-slate-200 bg-white/90 px-4 py-2.5 text-slate-900 shadow-sm transition focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200" + className={AUTH_INPUT_CLASS} />
{requiresTotpInput ? (
-
) : null} @@ -340,7 +374,7 @@ export function LoginForm() { setRemember(event.target.checked)} /> @@ -352,7 +386,7 @@ export function LoginForm() { @@ -360,5 +394,5 @@ export function LoginForm() {
) : null} - ) + ); } diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 200abae..fb9acd5 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -1,21 +1,21 @@ -export const dynamic = 'error' +export const dynamic = "error"; -import { Suspense } from 'react' -import { notFound } from 'next/navigation' -import { isFeatureEnabled } from '@lib/featureToggles' -import { getAccountServiceBaseUrl } from '@server/serviceConfig' -import { LoginForm } from './LoginForm' -import LoginContent from './LoginContent' +import { Suspense } from "react"; +import { notFound } from "next/navigation"; +import { isFeatureEnabled } from "@lib/featureToggles"; +import { getAccountServiceBaseUrl } from "@server/serviceConfig"; +import { LoginForm } from "./LoginForm"; +import LoginContent from "./LoginContent"; function LoginPageFallback() { - return
+ return
; } export default function LoginPage() { - if (!isFeatureEnabled('globalNavigation', '/login')) { - notFound() + if (!isFeatureEnabled("globalNavigation", "/login")) { + notFound(); } - const accountServiceBaseUrl = getAccountServiceBaseUrl() + const accountServiceBaseUrl = getAccountServiceBaseUrl(); // 统一返回:容器包裹表单,兼容两边改动 return ( }> @@ -23,5 +23,5 @@ export default function LoginPage() { - ) + ); } diff --git a/src/app/(auth)/register/RegisterContent.tsx b/src/app/(auth)/register/RegisterContent.tsx index d1ff783..a73ab12 100644 --- a/src/app/(auth)/register/RegisterContent.tsx +++ b/src/app/(auth)/register/RegisterContent.tsx @@ -17,6 +17,12 @@ import { import { useRouter, useSearchParams } from "next/navigation"; import { + AUTH_CHECKBOX_CLASS, + AUTH_CODE_INPUT_CLASS, + AUTH_HINT_PANEL_CLASS, + AUTH_INPUT_CLASS, + AUTH_PRIMARY_BUTTON_CLASS, + AUTH_TEXT_LINK_CLASS, AuthLayout, AuthLayoutSocialButton, } from "@components/auth/AuthLayout"; @@ -332,8 +338,8 @@ export default function RegisterContent({ setIsSubmitting(true); showStatus( t.form.validation?.submitting ?? - t.form.submitting ?? - "Submitting registration request…", + t.form.submitting ?? + "Submitting registration request…", ); try { @@ -400,9 +406,9 @@ export default function RegisterContent({ setIsSubmitting(true); showStatus( t.form.validation?.completing ?? - t.form.completing ?? - t.form.completeSubmit ?? - t.form.submit, + t.form.completing ?? + t.form.completeSubmit ?? + t.form.submit, ); try { @@ -563,9 +569,7 @@ export default function RegisterContent({ // Render Helpers const aboveForm = t.uuidNote ? ( -
- {t.uuidNote} -
+
{t.uuidNote}
) : null; const submitLabel = useMemo(() => { @@ -624,7 +628,7 @@ export default function RegisterContent({ placeholder={ t.form.namePlaceholder || "4-16 chars, starts with letter" } - className="w-full rounded-2xl border border-slate-200 bg-white/90 px-4 py-2.5 text-slate-900 shadow-sm transition focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200" + className={AUTH_INPUT_CLASS} required value={formValues.username} onChange={handleInputChange("username")} @@ -644,7 +648,7 @@ export default function RegisterContent({ type="email" autoComplete="email" placeholder={t.form.emailPlaceholder} - className="w-full rounded-2xl border border-slate-200 bg-white/90 px-4 py-2.5 text-slate-900 shadow-sm transition focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200" + className={AUTH_INPUT_CLASS} required value={formValues.email} onChange={handleInputChange("email")} @@ -665,7 +669,7 @@ export default function RegisterContent({ type="password" autoComplete="new-password" placeholder={t.form.passwordPlaceholder} - className="w-full rounded-2xl border border-slate-200 bg-white/90 px-4 py-2.5 text-slate-900 shadow-sm transition focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200" + className={AUTH_INPUT_CLASS} required value={formValues.password} onChange={handleInputChange("password")} @@ -684,7 +688,7 @@ export default function RegisterContent({ type="password" autoComplete="new-password" placeholder={t.form.confirmPasswordPlaceholder} - className="w-full rounded-2xl border border-slate-200 bg-white/90 px-4 py-2.5 text-slate-900 shadow-sm transition focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200" + className={AUTH_INPUT_CLASS} required value={formValues.confirmPassword} onChange={handleInputChange("confirmPassword")} @@ -697,16 +701,13 @@ export default function RegisterContent({ type="checkbox" name="agreement" required - className="mt-1 h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500" + className={AUTH_CHECKBOX_CLASS} checked={formValues.agreement} onChange={handleAgreementChange} /> {t.form.agreement}{" "} - + {t.form.terms} @@ -716,7 +717,7 @@ export default function RegisterContent({ {currentStep === 1 && (
-
+
我们已向你的邮箱 {formValues.email}{" "} 发送一封验证邮件。
@@ -738,7 +739,7 @@ export default function RegisterContent({ inputMode="numeric" autoComplete="one-time-code" maxLength={1} - className="h-12 w-full rounded-xl border border-slate-200 bg-white/90 text-center text-lg font-semibold text-slate-900 shadow-sm transition focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200" + className={AUTH_CODE_INPUT_CLASS} value={digit} onChange={(e) => handleCodeChange(index, e.target.value)} onKeyDown={(e) => handleCodeKeyDown(index, e)} @@ -752,7 +753,7 @@ export default function RegisterContent({ @@ -761,7 +762,7 @@ export default function RegisterContent({ type="button" onClick={handleResend} disabled={isResending || resendCooldown > 0} - className="text-sm font-medium text-sky-600 transition hover:text-sky-500 disabled:cursor-not-allowed disabled:opacity-50" + className={`${AUTH_TEXT_LINK_CLASS} text-sm disabled:cursor-not-allowed disabled:opacity-50`} style={{ zIndex: 10, position: "relative" }} > {resendLabel} @@ -775,7 +776,7 @@ export default function RegisterContent({ disabled={ isSubmitting || (currentStep === 1 && codeDigits.some((d) => !d)) } - className="w-full rounded-2xl bg-gradient-to-r from-sky-500 to-blue-500 px-4 py-2.5 text-sm font-semibold text-white shadow-lg shadow-sky-500/20 transition hover:from-sky-500 hover:to-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500 disabled:cursor-not-allowed disabled:opacity-70" + className={`w-full ${AUTH_PRIMARY_BUTTON_CLASS}`} > {submitLabel} diff --git a/src/app/(auth)/register/page.tsx b/src/app/(auth)/register/page.tsx index 5c8aea3..9121e95 100644 --- a/src/app/(auth)/register/page.tsx +++ b/src/app/(auth)/register/page.tsx @@ -1,29 +1,29 @@ -export const dynamic = 'force-dynamic' +export const dynamic = "force-dynamic"; -export const revalidate = 0 +export const revalidate = 0; -import { Suspense } from 'react' -import { notFound } from 'next/navigation' +import { Suspense } from "react"; +import { notFound } from "next/navigation"; -import { isFeatureEnabled } from '@lib/featureToggles' -import { getAccountServiceBaseUrl } from '@server/serviceConfig' +import { isFeatureEnabled } from "@lib/featureToggles"; +import { getAccountServiceBaseUrl } from "@server/serviceConfig"; -import RegisterContent from './RegisterContent' +import RegisterContent from "./RegisterContent"; function RegisterPageFallback() { - return
+ return
; } export default function RegisterPage() { - if (!isFeatureEnabled('globalNavigation', '/register')) { - notFound() + if (!isFeatureEnabled("globalNavigation", "/register")) { + notFound(); } - const accountServiceBaseUrl = getAccountServiceBaseUrl() + const accountServiceBaseUrl = getAccountServiceBaseUrl(); return ( }> - ) + ); } diff --git a/src/app/blogs/[...slug]/page.tsx b/src/app/blogs/[...slug]/page.tsx index 0081c9e..6bd645d 100644 --- a/src/app/blogs/[...slug]/page.tsx +++ b/src/app/blogs/[...slug]/page.tsx @@ -1,110 +1,134 @@ -export const dynamic = 'force-dynamic' +export const dynamic = "force-dynamic"; -import Link from 'next/link' -import { notFound } from 'next/navigation' -import type { Metadata } from 'next' +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; -import { getBlogPostBySlug } from '@lib/blogContent' -import { renderMarkdownContent } from '@server/render-markdown' -import BrandCTA from '@components/BrandCTA' +import BrandCTA from "@components/BrandCTA"; +import { PublicPageShell } from "@/components/public/PublicPageShell"; +import { getBlogPostBySlug } from "@lib/blogContent"; +import { renderMarkdownContent } from "@server/render-markdown"; type PageProps = { - params: { slug: string | string[] } -} + params: Promise<{ slug: string | string[] }>; +}; -function formatDate(dateStr: string, language: 'zh' | 'en'): string { - const date = new Date(dateStr) +function formatDate(dateStr: string, language: "zh" | "en"): string { + const date = new Date(dateStr); - if (language === 'zh') { - return date.toLocaleDateString('zh-CN', { - year: 'numeric', - month: 'long', - day: 'numeric', - }) + if (language === "zh") { + return date.toLocaleDateString("zh-CN", { + year: "numeric", + month: "long", + day: "numeric", + }); } - return date.toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - }) + return date.toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); } -export async function generateMetadata({ params }: PageProps): Promise { - const slugParam = await params - const slugPath = Array.isArray(slugParam.slug) ? slugParam.slug.join('/') : slugParam.slug - const post = await getBlogPostBySlug(slugPath) +export async function generateMetadata({ + params, +}: PageProps): Promise { + const slugParam = await params; + const slugPath = Array.isArray(slugParam.slug) + ? slugParam.slug.join("/") + : slugParam.slug; + const post = await getBlogPostBySlug(slugPath); if (!post) { - return { title: 'Blog Post | Cloud-Neutral' } + return { title: "Blog Post | Cloud-Neutral" }; } return { title: `${post.title} | Cloud-Neutral Blog`, description: post.excerpt, - } + }; } export default async function BlogPostPage({ params }: PageProps) { - const slugParam = await params - const slugPath = Array.isArray(slugParam.slug) ? slugParam.slug.join('/') : slugParam.slug - const post = await getBlogPostBySlug(slugPath) + const slugParam = await params; + const slugPath = Array.isArray(slugParam.slug) + ? slugParam.slug.join("/") + : slugParam.slug; + const post = await getBlogPostBySlug(slugPath); if (!post) { - notFound() + notFound(); } - const html = renderMarkdownContent(post.content) - const language: 'zh' | 'en' = /[\u4e00-\u9fff]/.test(`${post.title} ${post.content}`) ? 'zh' : 'en' + const html = renderMarkdownContent(post.content); + const language: "zh" | "en" = /[\u4e00-\u9fff]/.test( + `${post.title} ${post.content}`, + ) + ? "zh" + : "en"; + const isChinese = language === "zh"; return ( -
-
- - ← {post.date ? 'Back to Blog' : '返回博客'} - - -
-

{post.title}

- - {post.author &&

{post.date ? 'By' : '作者'} {post.author}

} - - {post.date && ( - - )} - - {post.tags && post.tags.length > 0 && ( -
- {post.tags.map((tag) => ( - - {tag} - - ))} -
- )} -
- -
- -
- -
- -
+ +
+
- ← Back to Blog + {isChinese ? "← 返回博客" : "← Back to Blog"} -
+ +
+

+ {post.category?.label ?? "Blog"} +

+

+ {post.title} +

+

+ {post.excerpt} +

+
+ +
+ {post.author ? ( + + {isChinese ? "作者" : "By"} {post.author} + + ) : null} + {post.date ? ( + + ) : null} + {post.tags?.map((tag) => ( + + {tag} + + ))} +
+ + +
+
+
+ +
-
- ) + + ); } diff --git a/src/app/blogs/page.tsx b/src/app/blogs/page.tsx index c9cb199..e4feb7c 100644 --- a/src/app/blogs/page.tsx +++ b/src/app/blogs/page.tsx @@ -1,25 +1,38 @@ -export const dynamic = 'error' -export const revalidate = false +export const dynamic = "error"; +export const revalidate = false; -import type { Metadata } from 'next' -import { Suspense } from 'react' +import type { Metadata } from "next"; +import { Suspense } from "react"; -import BlogList from '@components/blog/BlogList' -import type { BlogCategory, BlogPostSummary } from '@lib/blogContent' -import { getBlogCategories, getBlogPosts } from '@lib/blogContent' +import BlogList from "@components/blog/BlogList"; +import { PublicPageShell } from "@/components/public/PublicPageShell"; +import type { BlogCategory, BlogPostSummary } from "@lib/blogContent"; +import { getBlogCategories, getBlogPosts } from "@lib/blogContent"; export const metadata: Metadata = { - title: 'Blog | Cloud-Neutral', - description: 'Latest updates, releases, and insights from the Cloud-Neutral community.', -} + title: "Blog | Cloud-Neutral", + description: + "Latest updates, releases, and insights from the Cloud-Neutral community.", +}; export default async function BlogPage() { - const posts = await getBlogPosts() - const categories: BlogCategory[] = await getBlogCategories() - const postsWithoutContent: BlogPostSummary[] = posts.map(({ content: _content, ...post }) => post) + const posts = await getBlogPosts(); + const categories: BlogCategory[] = await getBlogCategories(); + const postsWithoutContent: BlogPostSummary[] = posts.map( + ({ content: _content, ...post }) => post, + ); + return ( - Loading blog content...
}> - - - ) + + + Loading blog content... +
+ } + > + + + + ); } diff --git a/src/app/download/[...segments]/page.tsx b/src/app/download/[...segments]/page.tsx index 990bbb7..7ae55c1 100644 --- a/src/app/download/[...segments]/page.tsx +++ b/src/app/download/[...segments]/page.tsx @@ -1,136 +1,144 @@ -export const dynamic = 'error' +export const dynamic = "error"; -import DownloadListingContent from '../../../components/download/DownloadListingContent' -import DownloadNotFound from '../../../components/download/DownloadNotFound' +import DownloadListingContent from "@/components/download/DownloadListingContent"; +import DownloadNotFound from "@/components/download/DownloadNotFound"; +import { PublicPageShell } from "@/components/public/PublicPageShell"; import { buildSectionsForListing, countFiles, findListing, formatSegmentLabel, -} from '../../../lib/download-data' -import { getDownloadListings, getDownloadListingsForBuildTime } from '../../../lib/download/dl-index-data-artifacts' -import type { DirListing } from '@lib/download/types' +} from "@/lib/download-data"; +import { + getDownloadListings, + getDownloadListingsForBuildTime, +} from "@/lib/download/dl-index-data-artifacts"; +import type { DirListing } from "@lib/download/types"; async function getAllListings(): Promise { - return getDownloadListings() + return getDownloadListings(); } -// 构建时获取:优先使用本地数据,保证构建成功 async function getAllListingsForBuildTime(): Promise { - return getDownloadListingsForBuildTime() + return getDownloadListingsForBuildTime(); } -function collectDownloadParams(listings: DirListing[]): { segments: string[] }[] { - const params: { segments: string[] }[] = [] - const root = findListing(listings, []) +function collectDownloadParams( + listings: DirListing[], +): { segments: string[] }[] { + const params: { segments: string[] }[] = []; + const root = findListing(listings, []); if (!root) { - return params + return params; } - const stack: { segments: string[]; listing: DirListing }[] = [] - stack.push({ segments: [], listing: root }) + const stack: { segments: string[]; listing: DirListing }[] = []; + stack.push({ segments: [], listing: root }); while (stack.length > 0) { - const current = stack.pop() + const current = stack.pop(); if (!current) { - continue + continue; } for (const entry of current.listing.entries) { - if (entry.type !== 'dir') { - continue + if (entry.type !== "dir") { + continue; } - const segment = entry.name.replace(/\/+$/g, '').trim() + const segment = entry.name.replace(/\/+$/g, "").trim(); if (!segment) { - continue + continue; } - const nextSegments = [...current.segments, segment] - params.push({ segments: nextSegments }) + const nextSegments = [...current.segments, segment]; + params.push({ segments: nextSegments }); - const child = findListing(listings, nextSegments) + const child = findListing(listings, nextSegments); if (child) { - stack.push({ segments: nextSegments, listing: child }) + stack.push({ segments: nextSegments, listing: child }); } } } - return params + return params; } export async function generateStaticParams() { - // 构建时优先使用本地 fallback 数据,避免外部API调用 - const allListings = await getAllListingsForBuildTime() - return collectDownloadParams(allListings) + const allListings = await getAllListingsForBuildTime(); + return collectDownloadParams(allListings); } -export const dynamicParams = false +export const dynamicParams = false; function getLatestModified(listing: DirListing): string | undefined { - let latest: string | undefined + let latest: string | undefined; for (const entry of listing.entries) { if (entry.lastModified && (!latest || entry.lastModified > latest)) { - latest = entry.lastModified + latest = entry.lastModified; } } - return latest + return latest; } export default async function DownloadListing({ params, }: { - params: Promise<{ segments: string[] }> + params: Promise<{ segments: string[] }>; }) { - const { segments: rawSegments } = await params + const { segments: rawSegments } = await params; const segments = rawSegments - .map((segment) => segment.trim().replace(/\/+$/g, '')) - .filter((segment) => segment.length > 0) + .map((segment) => segment.trim().replace(/\/+$/g, "")) + .filter((segment) => segment.length > 0); - const allListings = await getAllListings() + const allListings = await getAllListings(); if (segments.length === 0) { return ( -
+ -
- ) + + ); } - const listing = findListing(allListings, segments) + const listing = findListing(allListings, segments); if (!listing) { return ( -
+ -
- ) + + ); } - const subdirectorySections = buildSectionsForListing(listing, allListings, segments) - const fileEntries = listing.entries.filter((entry: DirListing['entries'][number]) => entry.type === 'file') - const fileListing: DirListing = { path: listing.path, entries: fileEntries } + const subdirectorySections = buildSectionsForListing( + listing, + allListings, + segments, + ); + const fileEntries = listing.entries.filter( + (entry: DirListing["entries"][number]) => entry.type === "file", + ); + const fileListing: DirListing = { path: listing.path, entries: fileEntries }; - const totalFiles = countFiles(listing, allListings) - const latestModified = getLatestModified(listing) - const displayTitle = formatSegmentLabel(segments[segments.length - 1] ?? '') - const relativePath = segments.join('/') - const remotePath = `https://dl.svc.plus/${listing.path}` + const totalFiles = countFiles(listing, allListings); + const latestModified = getLatestModified(listing); + const displayTitle = formatSegmentLabel(segments[segments.length - 1] ?? ""); + const relativePath = segments.join("/"); + const remotePath = `https://dl.svc.plus/${listing.path}`; return ( -
-
- -
-
- ) + + + + ); } diff --git a/src/app/download/page.tsx b/src/app/download/page.tsx index 69d817f..4f72cd3 100644 --- a/src/app/download/page.tsx +++ b/src/app/download/page.tsx @@ -1,50 +1,59 @@ -export const dynamic = 'force-dynamic' +export const dynamic = "force-dynamic"; -import { notFound } from 'next/navigation' +import { notFound } from "next/navigation"; -import DownloadBrowser from '../../components/download/DownloadBrowser' -import DownloadSummary from '../../components/download/DownloadSummary' -import { buildDownloadSections, countFiles, findListing } from '../../lib/download-data' -import { getDownloadListings } from '../../lib/download/dl-index-data-artifacts' -import { getOfflinePackageSections, getOfflinePackageFileCount } from '../../lib/download/dl-index-data-offline-package' -import type { DirEntry } from '../../lib/download/types' -import { isFeatureEnabled } from '@lib/featureToggles' +import DownloadBrowser from "@/components/download/DownloadBrowser"; +import DownloadSummary from "@/components/download/DownloadSummary"; +import { PublicPageShell } from "@/components/public/PublicPageShell"; +import { + buildDownloadSections, + countFiles, + findListing, +} from "@/lib/download-data"; +import { getDownloadListings } from "@/lib/download/dl-index-data-artifacts"; +import { + getOfflinePackageFileCount, + getOfflinePackageSections, +} from "@/lib/download/dl-index-data-offline-package"; +import type { DirEntry } from "@/lib/download/types"; +import { isFeatureEnabled } from "@lib/featureToggles"; export default async function DownloadHome() { - if (!isFeatureEnabled('appModules', '/download')) { - notFound() + if (!isFeatureEnabled("appModules", "/download")) { + notFound(); } - // Get data from multiple sources - const allListings = await getDownloadListings() - const offlinePackageSections = await getOfflinePackageSections() + const allListings = await getDownloadListings(); + const offlinePackageSections = await getOfflinePackageSections(); - // Merge sections - offline-package takes priority - const sectionsMap = buildDownloadSections(allListings) - const mergedSectionsMap = { ...sectionsMap, ...offlinePackageSections } + const sectionsMap = buildDownloadSections(allListings); + const mergedSectionsMap = { ...sectionsMap, ...offlinePackageSections }; - const rootListing = findListing(allListings, []) - const topLevelDirectories = rootListing?.entries.filter((entry: DirEntry) => entry.type === 'dir') ?? [] + const rootListing = findListing(allListings, []); + const topLevelDirectories = + rootListing?.entries.filter((entry: DirEntry) => entry.type === "dir") ?? + []; - // Get file count from offline-package if available - const offlinePackageFileCount = await getOfflinePackageFileCount() + const offlinePackageFileCount = await getOfflinePackageFileCount(); - const totalCollections = Object.values(mergedSectionsMap).reduce((total, sections) => total + sections.length, 0) - const totalFiles = topLevelDirectories.reduce((total: number, entry: DirEntry) => { - const listing = findListing(allListings, [entry.name]) - return total + (listing ? countFiles(listing, allListings) : 0) - }, 0) + offlinePackageFileCount + const totalCollections = Object.values(mergedSectionsMap).reduce( + (total, sections) => total + sections.length, + 0, + ); + const totalFiles = + topLevelDirectories.reduce((total: number, entry: DirEntry) => { + const listing = findListing(allListings, [entry.name]); + return total + (listing ? countFiles(listing, allListings) : 0); + }, 0) + offlinePackageFileCount; return ( -
-
- - -
-
- ) + + + + + ); } diff --git a/src/components/BrandCTA.tsx b/src/components/BrandCTA.tsx index 4c34bc9..7b4fac0 100644 --- a/src/components/BrandCTA.tsx +++ b/src/components/BrandCTA.tsx @@ -1,46 +1,63 @@ -import Image from 'next/image' +import Image from "next/image"; type BrandCTAProps = { - lang?: 'zh' | 'en' - variant?: 'compact' | 'default' -} + lang?: "zh" | "en"; + variant?: "compact" | "default"; +}; const COPY = { zh: { - main: '云原生实践 · 架构思考', - secondary: '获取更多信息,可通过右侧官方渠道', + main: "云原生实践 · 架构思考", + secondary: "获取更多信息,可通过右侧官方渠道", }, en: { - main: 'Cloud-native practice · Architecture thinking', - secondary: 'For more information, see the official channels on the right', + main: "Cloud-native practice · Architecture thinking", + secondary: "For more information, see the official channels on the right", }, -} +}; -export default function BrandCTA({ lang = 'en', variant = 'default' }: BrandCTAProps) { - const content = COPY[lang] - const isCompact = variant === 'compact' - const imageSize = isCompact ? 160 : 180 +export default function BrandCTA({ + lang = "en", + variant = "default", +}: BrandCTAProps) { + const content = COPY[lang]; + const isCompact = variant === "compact"; + const imageSize = isCompact ? 132 : 168; return ( -
+
-

{content.main}

- {!isCompact && ( - <> - -
+
{lang
- ) + ); } diff --git a/src/components/auth/AuthLayout.tsx b/src/components/auth/AuthLayout.tsx index 9442caa..e48b457 100644 --- a/src/components/auth/AuthLayout.tsx +++ b/src/components/auth/AuthLayout.tsx @@ -1,75 +1,110 @@ -'use client' +"use client"; -import clsx from 'clsx' -import Link from 'next/link' -import type { MouseEvent, ReactNode } from 'react' +import clsx from "clsx"; +import Link from "next/link"; +import type { MouseEvent, ReactNode } from "react"; type SwitchAction = { - text: string - linkLabel: string - href: string -} + text: string; + linkLabel: string; + href: string; +}; export type AuthLayoutSocialButton = { - label: string - href: string - icon: ReactNode - disabled?: boolean - onClick?: (event: MouseEvent) => void -} + label: string; + href: string; + icon: ReactNode; + disabled?: boolean; + onClick?: (event: MouseEvent) => void; +}; -type AlertType = 'error' | 'success' | 'info' +type AlertType = "error" | "success" | "info"; type AuthLayoutProps = { - mode: 'login' | 'register' - badge?: string - title: string - description?: string - alert?: { type: AlertType; message: string } | null - socialHeading?: string - socialButtons?: AuthLayoutSocialButton[] - aboveForm?: ReactNode - children: ReactNode - footnote?: ReactNode - bottomNote?: string - switchAction: SwitchAction -} + mode: "login" | "register"; + badge?: string; + title: string; + description?: string; + alert?: { type: AlertType; message: string } | null; + socialHeading?: string; + socialButtons?: AuthLayoutSocialButton[]; + aboveForm?: ReactNode; + children: ReactNode; + footnote?: ReactNode; + bottomNote?: string; + switchAction: SwitchAction; +}; -function AuthLayoutTab({ href, active, children }: { href: string; active: boolean; children: ReactNode }) { +export const AUTH_INPUT_CLASS = + "w-full rounded-[1.25rem] border border-slate-900/10 bg-[#fcfbf8] px-4 py-3 text-slate-900 shadow-[0_1px_2px_rgba(15,23,42,0.04)] transition focus:border-slate-900/15 focus:outline-none focus:ring-2 focus:ring-primary/15 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400"; + +export const AUTH_HINT_PANEL_CLASS = + "rounded-[1.25rem] border border-slate-900/10 bg-[#fcfbf8] px-4 py-3 text-sm leading-6 text-slate-600"; + +export const AUTH_PRIMARY_BUTTON_CLASS = + "inline-flex items-center justify-center rounded-[1.25rem] bg-slate-950 px-4 py-3 text-sm font-semibold text-white transition hover:bg-primary focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-70"; + +export const AUTH_SECONDARY_BUTTON_CLASS = + "inline-flex items-center justify-center rounded-[1.25rem] border border-slate-900/10 bg-white px-4 py-3 text-sm font-semibold text-slate-800 transition hover:border-slate-900/15 hover:bg-[#fcfbf8] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-300 disabled:cursor-not-allowed disabled:opacity-60"; + +export const AUTH_TEXT_LINK_CLASS = + "font-semibold text-primary transition hover:text-primary-hover"; + +export const AUTH_CHECKBOX_CLASS = + "h-4 w-4 rounded border-slate-300 text-primary focus:ring-primary/30"; + +export const AUTH_CODE_INPUT_CLASS = + "h-12 w-full rounded-[1rem] border border-slate-900/10 bg-[#fcfbf8] text-center text-lg font-semibold text-slate-900 shadow-[0_1px_2px_rgba(15,23,42,0.04)] transition focus:border-slate-900/15 focus:outline-none focus:ring-2 focus:ring-primary/15"; + +function AuthLayoutTab({ + href, + active, + children, +}: { + href: string; + active: boolean; + children: ReactNode; +}) { return ( {children} - ) + ); } -function AuthSocialButton({ label, href, icon, disabled, onClick }: AuthLayoutSocialButton) { +function AuthSocialButton({ + label, + href, + icon, + disabled, + onClick, +}: AuthLayoutSocialButton) { const handleClick = (event: MouseEvent) => { if (disabled) { - event.preventDefault() - event.stopPropagation() + event.preventDefault(); + event.stopPropagation(); } - onClick?.(event) - } + onClick?.(event); + }; return ( - ) + ); } export function AuthLayout({ @@ -94,51 +129,75 @@ export function AuthLayout({ bottomNote, switchAction, }: AuthLayoutProps) { + const modeLabel = mode === "login" ? "Account access" : "Create account"; + return ( -
+
+
+
-
-
- - Svc.Plus +
+
+ +

+ Cloud-Neutral Toolkit +

+

+ Svc.Plus +

-

Cloud-Neutral · 自由中立

+ + {modeLabel} +
-
-
- + +
+
+ Sign In - + Sign Up
+
{badge ? ( - + {badge} ) : null} +
-

{title}

- {description ?

{description}

: null} +

+ {title} +

+ {description ? ( +

+ {description} +

+ ) : null}
+ {alert ? (
) : null} + {aboveForm} +
{children}
+ {socialButtons.length > 0 ? (
- {socialHeading ?? 'Or continue with'} + {socialHeading ?? "Or continue with"}
- {socialButtons.map(button => ( + {socialButtons.map((button) => ( ))}
) : null} +

- {switchAction.text}{' '} - + {switchAction.text}{" "} + {switchAction.linkLabel}

- {footnote ?
{footnote}
: null} + + {footnote ? ( +
+ {footnote} +
+ ) : null}
- {bottomNote ?

{bottomNote}

: null} + + {bottomNote ? ( +

+ {bottomNote} +

+ ) : null}
- ) + ); } diff --git a/src/components/blog/BlogList.tsx b/src/components/blog/BlogList.tsx index cacbcd9..01ceb64 100644 --- a/src/components/blog/BlogList.tsx +++ b/src/components/blog/BlogList.tsx @@ -1,250 +1,298 @@ -'use client' +"use client"; -import { useMemo } from 'react' -import Link from 'next/link' -import { useSearchParams } from 'next/navigation' +import { useMemo } from "react"; +import Link from "next/link"; +import { ArrowRight } from "lucide-react"; +import { useSearchParams } from "next/navigation"; -import BrandCTA from '@components/BrandCTA' -import SearchComponent from '@components/search' -import type { BlogCategory, BlogPostSummary } from '@lib/blogContent' +import BrandCTA from "@components/BrandCTA"; +import { PublicPageIntro } from "@/components/public/PublicPageShell"; +import SearchComponent from "@components/search"; +import { useLanguage } from "@i18n/LanguageProvider"; +import type { BlogCategory, BlogPostSummary } from "@lib/blogContent"; -function formatDate(dateStr: string | undefined, language: 'zh' | 'en'): string { - if (!dateStr) return '' +function formatDate( + dateStr: string | undefined, + language: "zh" | "en", +): string { + if (!dateStr) return ""; - const date = new Date(dateStr) + const date = new Date(dateStr); - if (language === 'zh') { - return date.toLocaleDateString('zh-CN', { - year: 'numeric', - month: 'long', - day: 'numeric', - }) + if (language === "zh") { + return date.toLocaleDateString("zh-CN", { + year: "numeric", + month: "long", + day: "numeric", + }); } - return date.toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - }) + return date.toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); } interface BlogListProps { - posts: BlogPostSummary[] - categories: BlogCategory[] + posts: BlogPostSummary[]; + categories: BlogCategory[]; } function buildCategoryCounts(posts: BlogPostSummary[]) { return posts.reduce>((acc, post) => { - const categoryKey = post.category?.key - if (!categoryKey) return acc - acc[categoryKey] = (acc[categoryKey] || 0) + 1 - return acc - }, {}) -} - -function detectLanguage(posts: BlogPostSummary[]): 'zh' | 'en' { - for (const post of posts) { - if (/[\u4e00-\u9fff]/.test(`${post.title} ${post.excerpt}`)) { - return 'zh' - } - } - return 'en' + const categoryKey = post.category?.key; + if (!categoryKey) return acc; + acc[categoryKey] = (acc[categoryKey] || 0) + 1; + return acc; + }, {}); } export default function BlogList({ posts, categories }: BlogListProps) { - const searchParams = useSearchParams() - const selectedCategory = searchParams.get('category') - const page = searchParams.get('page') + const { language } = useLanguage(); + const isChinese = language === "zh"; + const searchParams = useSearchParams(); + const selectedCategory = searchParams.get("category"); + const page = searchParams.get("page"); const categoryTabs = useMemo(() => { const categoriesFromPosts = posts .map((post) => post.category) - .filter((category): category is NonNullable => Boolean(category)) - .map((category) => ({ key: category.key, label: category.label ?? category.key })) + .filter( + (category): category is NonNullable => + Boolean(category), + ) + .map((category) => ({ + key: category.key, + label: category.label ?? category.key, + })); return [...categories, ...categoriesFromPosts].filter( - (category, index, self) => self.findIndex((item) => item.key === category.key) === index, - ) - }, [categories, posts]) + (category, index, self) => + self.findIndex((item) => item.key === category.key) === index, + ); + }, [categories, posts]); - const categoryCounts = useMemo(() => buildCategoryCounts(posts), [posts]) + const categoryCounts = useMemo(() => buildCategoryCounts(posts), [posts]); const filteredPosts = useMemo(() => { - if (!selectedCategory) return posts - return posts.filter((post) => post.category?.key === selectedCategory) - }, [posts, selectedCategory]) + if (!selectedCategory) return posts; + return posts.filter((post) => post.category?.key === selectedCategory); + }, [posts, selectedCategory]); - const postsPerPage = 10 + const postsPerPage = 10; const currentPage = useMemo(() => { - const parsed = Number(page || '1') - if (!Number.isFinite(parsed) || parsed < 1) return 1 - const totalPages = Math.max(1, Math.ceil(filteredPosts.length / postsPerPage)) - return Math.min(parsed, totalPages) - }, [page, filteredPosts.length]) - const totalPages = Math.max(1, Math.ceil(filteredPosts.length / postsPerPage)) - const startIndex = (currentPage - 1) * postsPerPage - const endIndex = startIndex + postsPerPage - const paginatedPosts = filteredPosts.slice(startIndex, endIndex) - const language = useMemo(() => detectLanguage(filteredPosts), [filteredPosts]) + const parsed = Number(page || "1"); + if (!Number.isFinite(parsed) || parsed < 1) return 1; + const totalPages = Math.max( + 1, + Math.ceil(filteredPosts.length / postsPerPage), + ); + return Math.min(parsed, totalPages); + }, [page, filteredPosts.length]); + const totalPages = Math.max( + 1, + Math.ceil(filteredPosts.length / postsPerPage), + ); + const startIndex = (currentPage - 1) * postsPerPage; + const endIndex = startIndex + postsPerPage; + const paginatedPosts = filteredPosts.slice(startIndex, endIndex); return ( -
-
-
- - SVC.plus - / - blog - -
- +
+
+
+ + +
+

+ {isChinese ? "搜索文章" : "Search notes"} +

+
+ +
-
+
-
-
-
-

Blog

-

- Latest updates, releases, and insights from the Cloud-Neutral community. -

-
- -
- {categoryTabs.map((tab) => { - const isActive = tab.key === selectedCategory - const labelWithCount = categoryCounts[tab.key] - - return ( - - {tab.label} - {labelWithCount ? ( - - {labelWithCount} - - ) : null} - - ) - })} - +
+ + {isChinese ? "全部" : "All"} + - 全部 - + + {categoryTabs.map((tab) => { + const isActive = tab.key === selectedCategory; + const labelWithCount = categoryCounts[tab.key]; + return ( + - {posts.length} - - -
- - {filteredPosts.length === 0 ? ( -
-

暂无博客文章

-
- ) : ( - <> -
- {paginatedPosts.map((post) => ( -
{tab.label} + {labelWithCount ? ( + -
- Blog - {post.date && } -
-

{post.title}

- {post.author &&

By {post.author}

} -

{post.excerpt}

-
- {post.tags && post.tags.length > 0 && ( -
- {post.tags.map((tag) => ( - - {tag} - - ))} -
- )} - - Read more → - -
-
- ))} + {labelWithCount} + + ) : null} + + ); + })} +
+ + + {filteredPosts.length === 0 ? ( +
+ {isChinese ? "暂无博客文章" : "No posts found."} +
+ ) : ( +
+ {paginatedPosts.map((post) => ( +
+
+ + {post.category?.label ?? "Blog"} + + {post.date ? ( + + ) : null}
- {totalPages > 1 && ( -
+ ))} +
+ )} - - Next - - - )} + {totalPages > 1 ? ( +
-
+ + {isChinese ? "下一页" : "Next"} + + + ) : null} + +
- ) + ); } diff --git a/src/components/download/Breadcrumbs.tsx b/src/components/download/Breadcrumbs.tsx index b6a3e73..973dea4 100644 --- a/src/components/download/Breadcrumbs.tsx +++ b/src/components/download/Breadcrumbs.tsx @@ -1,23 +1,24 @@ -import Link from 'next/link' +import Link from "next/link"; export interface Crumb { - label: string - href: string + label: string; + href: string; } export default function Breadcrumbs({ items }: { items: Crumb[] }) { return ( -