refactor(public-pages): unify download blogs and auth styling

This commit is contained in:
Haitao Pan 2026-03-18 14:04:10 +08:00
parent 3aee5aa0bb
commit be30303bc8
23 changed files with 1589 additions and 1177 deletions

View File

@ -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<number | null>(null)
const { language } = useLanguage();
const t = translations[language].auth.emailVerification;
const router = useRouter();
const searchParams = useSearchParams();
const redirectTimeoutRef = useRef<number | null>(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<AlertState | null>(() => {
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<string, string> = {
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<AlertState | null>(initialAlert)
const [code, setCode] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [isResending, setIsResending] = useState(false)
const [resendCooldown, setResendCooldown] = useState(0)
const [alert, setAlert] = useState<AlertState | null>(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<HTMLInputElement>) => {
const digitsOnly = event.target.value.replace(/\D/g, '').slice(0, VERIFICATION_CODE_LENGTH)
setCode(digitsOnly)
}, [])
const handleCodeChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
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<HTMLFormElement>) => {
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<string, string> = {
@ -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<string, string> = {
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 (
<AuthLayout
@ -274,13 +299,20 @@ export default function EmailVerificationContent() {
title={t.title}
description={description}
alert={alert}
switchAction={{ text: t.switchAction.text, linkLabel: t.switchAction.link, href: '/login' }}
switchAction={{
text: t.switchAction.text,
linkLabel: t.switchAction.link,
href: "/login",
}}
footnote={t.footnote}
bottomNote={t.bottomNote}
>
<form className="space-y-5" onSubmit={handleSubmit} noValidate>
<div className="space-y-2">
<label htmlFor="verification-code" className="text-sm font-medium text-slate-600">
<label
htmlFor="verification-code"
className="text-sm font-medium text-slate-600"
>
{t.form.codeLabel}
</label>
<input
@ -290,7 +322,7 @@ export default function EmailVerificationContent() {
inputMode="numeric"
autoComplete="one-time-code"
placeholder={t.form.codePlaceholder}
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 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400"
className={AUTH_INPUT_CLASS}
value={code}
onChange={handleCodeChange}
disabled={isSubmitting || !hasEmail}
@ -304,20 +336,20 @@ export default function EmailVerificationContent() {
</div>
<button
type="submit"
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}`}
disabled={isSubmitDisabled}
>
{isSubmitting ? t.form.submitting ?? t.form.submit : t.form.submit}
{isSubmitting ? (t.form.submitting ?? t.form.submit) : t.form.submit}
</button>
</form>
<button
type="button"
onClick={handleResend}
className="inline-flex w-full items-center justify-center rounded-2xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-600 transition hover:border-slate-300 hover:bg-slate-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-300 disabled:cursor-not-allowed disabled:opacity-60"
className={`w-full ${AUTH_SECONDARY_BUTTON_CLASS}`}
disabled={isResendDisabled}
>
{resendLabel}
</button>
</AuthLayout>
)
);
}

View File

@ -1,24 +1,24 @@
export const dynamic = 'force-dynamic'
export const dynamic = "force-dynamic";
import { Suspense } from 'react'
import { notFound } from 'next/navigation'
import { Suspense } from "react";
import { notFound } from "next/navigation";
import { isFeatureEnabled } from '@lib/featureToggles'
import { isFeatureEnabled } from "@lib/featureToggles";
import EmailVerificationContent from './EmailVerificationContent'
import EmailVerificationContent from "./EmailVerificationContent";
function EmailVerificationPageFallback() {
return <div className="flex min-h-screen flex-col bg-slate-50" />
return <div className="flex min-h-screen flex-col bg-background" />;
}
export default function EmailVerificationPage() {
if (!isFeatureEnabled('globalNavigation', '/email-verification')) {
notFound()
if (!isFeatureEnabled("globalNavigation", "/email-verification")) {
notFound();
}
return (
<Suspense fallback={<EmailVerificationPageFallback />}>
<EmailVerificationContent />
</Suspense>
)
);
}

View File

@ -1,13 +1,11 @@
import type { ReactNode } from 'react'
import type { ReactNode } from "react";
import { AppShellBypass } from '@lib/appShellBypass'
import { AppShellBypass } from "@lib/appShellBypass";
export default function AuthPagesLayout({ children }: { children: ReactNode }) {
return (
<AppShellBypass>
<div className="flex min-h-screen flex-col bg-slate-50">
{children}
</div>
<div className="flex min-h-screen flex-col bg-background">{children}</div>
</AppShellBypass>
)
);
}

View File

@ -14,6 +14,10 @@ import { useRouter, useSearchParams } from "next/navigation";
import { Github } from "lucide-react";
import {
AUTH_CHECKBOX_CLASS,
AUTH_INPUT_CLASS,
AUTH_PRIMARY_BUTTON_CLASS,
AUTH_TEXT_LINK_CLASS,
AuthLayout,
AuthLayoutSocialButton,
} from "@components/auth/AuthLayout";
@ -382,7 +386,7 @@ export default function LoginContent({
type="text"
autoComplete="username"
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
/>
</div>
@ -394,10 +398,7 @@ export default function LoginContent({
>
{t.form.password}
</label>
<Link
href="#"
className="font-medium text-sky-600 hover:text-sky-500"
>
<Link href="#" className={AUTH_TEXT_LINK_CLASS}>
{t.forgotPassword}
</Link>
</div>
@ -407,7 +408,7 @@ export default function LoginContent({
type="password"
autoComplete="current-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
/>
</div>
@ -415,7 +416,7 @@ export default function LoginContent({
<input
type="checkbox"
name="remember"
className="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500"
className={AUTH_CHECKBOX_CLASS}
/>
{t.form.remember}
</label>
@ -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}
</button>

View File

@ -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<string | null>(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<string | null>(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<HTMLFormElement>) => {
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 ? (
<div className="space-y-4 rounded-2xl border border-sky-200 bg-sky-50/80 p-5 text-sm text-sky-700">
<div className={`space-y-4 ${AUTH_HINT_PANEL_CLASS}`}>
<p className="text-base font-semibold">
{pageCopy.success.replace('{username}', user.username)}
{pageCopy.success.replace("{username}", user.username)}
</p>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={handleGoHome}
className="inline-flex items-center justify-center rounded-2xl bg-gradient-to-r from-sky-500 to-blue-500 px-4 py-2 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"
className={AUTH_PRIMARY_BUTTON_CLASS}
>
{pageCopy.goHome}
</button>
<button
type="button"
onClick={handleLogout}
className="inline-flex items-center justify-center rounded-2xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-600 transition hover:border-slate-300 hover:bg-slate-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-300"
className={AUTH_SECONDARY_BUTTON_CLASS}
>
{navCopy.logout}
</button>
@ -273,9 +291,17 @@ export function LoginForm() {
) : null}
{!user ? (
<form method="post" onSubmit={handleSubmit} className="space-y-5" noValidate>
<form
method="post"
onSubmit={handleSubmit}
className="space-y-5"
noValidate
>
<div className="space-y-2">
<label htmlFor="login-identifier" className="text-sm font-medium text-slate-600">
<label
htmlFor="login-identifier"
className="text-sm font-medium text-slate-600"
>
{authCopy.form.email}
</label>
<input
@ -286,21 +312,24 @@ export function LoginForm() {
value={identifier}
onChange={(event) => setIdentifier(event.target.value)}
placeholder={authCopy.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}
/>
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-slate-600">{authCopy.form.mfa.mode}</p>
<div className="rounded-2xl border border-dashed border-sky-200 bg-sky-50/80 px-4 py-3 text-sm text-sky-700">
{mfaModeLabel}
</div>
<p className="text-sm font-medium text-slate-600">
{authCopy.form.mfa.mode}
</p>
<div className={AUTH_HINT_PANEL_CLASS}>{mfaModeLabel}</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<label htmlFor="login-password" className="font-medium text-slate-600">
<label
htmlFor="login-password"
className="font-medium text-slate-600"
>
{authCopy.form.password}
</label>
<Link href="#" className="font-medium text-sky-600 hover:text-sky-500">
<Link href="#" className={AUTH_TEXT_LINK_CLASS}>
{authCopy.forgotPassword}
</Link>
</div>
@ -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}
/>
</div>
{requiresTotpInput ? (
<div className="space-y-2">
<label htmlFor="login-totp" className="text-sm font-medium text-slate-600">
<label
htmlFor="login-totp"
className="text-sm font-medium text-slate-600"
>
{authCopy.form.mfa.codeLabel}
</label>
<input
@ -328,11 +360,13 @@ export function LoginForm() {
pattern="[0-9]*"
value={totpCode}
onChange={(event) => {
const digits = event.target.value.replace(/\D/g, '').slice(0, 6)
setTotpCode(digits)
const digits = event.target.value
.replace(/\D/g, "")
.slice(0, 6);
setTotpCode(digits);
}}
placeholder={authCopy.form.mfa.codePlaceholder}
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}
/>
</div>
) : null}
@ -340,7 +374,7 @@ export function LoginForm() {
<input
type="checkbox"
name="remember"
className="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500"
className={AUTH_CHECKBOX_CLASS}
checked={remember}
onChange={(event) => setRemember(event.target.checked)}
/>
@ -352,7 +386,7 @@ export function LoginForm() {
<button
type="submit"
disabled={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 ? `${authCopy.form.submit}` : authCopy.form.submit}
</button>
@ -360,5 +394,5 @@ export function LoginForm() {
</form>
) : null}
</>
)
);
}

View File

@ -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 <div className="flex min-h-screen flex-col bg-slate-50" />
return <div className="flex min-h-screen flex-col bg-background" />;
}
export default function LoginPage() {
if (!isFeatureEnabled('globalNavigation', '/login')) {
notFound()
if (!isFeatureEnabled("globalNavigation", "/login")) {
notFound();
}
const accountServiceBaseUrl = getAccountServiceBaseUrl()
const accountServiceBaseUrl = getAccountServiceBaseUrl();
// 统一返回:容器包裹表单,兼容两边改动
return (
<Suspense fallback={<LoginPageFallback />}>
@ -23,5 +23,5 @@ export default function LoginPage() {
<LoginForm />
</LoginContent>
</Suspense>
)
);
}

View File

@ -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 ? (
<div className="rounded-2xl border border-dashed border-sky-200 bg-sky-50/80 px-4 py-3 text-sm text-sky-700">
{t.uuidNote}
</div>
<div className={AUTH_HINT_PANEL_CLASS}>{t.uuidNote}</div>
) : 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}
/>
<span>
{t.form.agreement}{" "}
<Link
href="/docs"
className="font-semibold text-sky-600 hover:text-sky-500"
>
<Link href="/docs" className={AUTH_TEXT_LINK_CLASS}>
{t.form.terms}
</Link>
</span>
@ -716,7 +717,7 @@ export default function RegisterContent({
{currentStep === 1 && (
<div className="space-y-6">
<div className="rounded-2xl border border-dashed border-sky-200 bg-sky-50/80 px-4 py-3 text-sm text-sky-700">
<div className={AUTH_HINT_PANEL_CLASS}>
<strong>{formValues.email}</strong>{" "}
<br />
@ -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({
<button
type="button"
onClick={() => setCurrentStep(0)}
className="text-sm text-slate-500 hover:text-slate-700"
className="text-sm font-medium text-slate-500 transition hover:text-slate-800"
>
</button>
@ -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}
</button>

View File

@ -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 <div className="flex min-h-screen flex-col bg-slate-50" />
return <div className="flex min-h-screen flex-col bg-background" />;
}
export default function RegisterPage() {
if (!isFeatureEnabled('globalNavigation', '/register')) {
notFound()
if (!isFeatureEnabled("globalNavigation", "/register")) {
notFound();
}
const accountServiceBaseUrl = getAccountServiceBaseUrl()
const accountServiceBaseUrl = getAccountServiceBaseUrl();
return (
<Suspense fallback={<RegisterPageFallback />}>
<RegisterContent accountServiceBaseUrl={accountServiceBaseUrl} />
</Suspense>
)
);
}

View File

@ -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<Metadata> {
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<Metadata> {
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 (
<main className="flex min-h-screen flex-col bg-slate-50">
<div className="mx-auto w-full max-w-4xl px-4 py-16">
<Link
href="/blogs"
className="mb-8 inline-flex items-center text-sm font-semibold text-brand transition hover:text-brand-dark"
>
{post.date ? 'Back to Blog' : '返回博客'}
</Link>
<header className="mb-12">
<h1 className="mb-4 text-4xl font-bold text-slate-900 sm:text-5xl">{post.title}</h1>
{post.author && <p className="mb-2 text-sm text-slate-600">{post.date ? 'By' : '作者'} {post.author}</p>}
{post.date && (
<time className="text-sm text-slate-500">{formatDate(post.date, 'en')}</time>
)}
{post.tags && post.tags.length > 0 && (
<div className="mt-6 flex flex-wrap gap-2">
{post.tags.map((tag) => (
<span key={tag} className="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700">
{tag}
</span>
))}
</div>
)}
</header>
<article
className="prose prose-slate max-w-none text-[15px] prose-headings:scroll-mt-24 prose-a:text-brand prose-a:no-underline hover:prose-a:underline"
dangerouslySetInnerHTML={{ __html: html }}
/>
<div className="mt-12">
<BrandCTA lang={language} />
</div>
<footer className="mt-16 border-t border-slate-200 pt-8">
<PublicPageShell>
<div className="space-y-6">
<section className="rounded-[2.4rem] border border-slate-900/10 bg-[linear-gradient(180deg,#ffffff,#faf7f2)] p-6 shadow-[0_22px_50px_rgba(15,23,42,0.05)] sm:p-8 lg:p-10">
<Link
href="/blogs"
className="inline-flex items-center text-sm font-semibold text-brand transition hover:text-brand-dark"
className="inline-flex rounded-full border border-slate-900/10 bg-white px-3 py-1.5 text-sm font-semibold text-slate-700 transition hover:border-slate-900/15 hover:text-primary"
>
Back to Blog
{isChinese ? "← 返回博客" : "← Back to Blog"}
</Link>
</footer>
<div className="mt-6 space-y-4">
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.24em] text-text-subtle">
{post.category?.label ?? "Blog"}
</p>
<h1
className={
isChinese
? "text-[2.7rem] font-semibold leading-[0.9] tracking-[-0.08em] text-slate-900 sm:text-[3.4rem]"
: "editorial-display text-[2.9rem] leading-[0.92] tracking-[-0.06em] text-slate-900 sm:text-[3.6rem]"
}
>
{post.title}
</h1>
<p className="max-w-3xl text-[1rem] leading-8 text-slate-600 sm:text-[1.05rem]">
{post.excerpt}
</p>
</div>
<div className="mt-6 flex flex-wrap items-center gap-3">
{post.author ? (
<span className="rounded-full border border-slate-900/10 bg-white px-3 py-1 text-sm text-slate-600">
{isChinese ? "作者" : "By"} {post.author}
</span>
) : null}
{post.date ? (
<time className="rounded-full border border-slate-900/10 bg-white px-3 py-1 text-sm text-slate-600">
{formatDate(post.date, language)}
</time>
) : null}
{post.tags?.map((tag) => (
<span
key={tag}
className="rounded-full border border-slate-900/10 bg-[#f8f4ec] px-3 py-1 text-xs font-semibold text-slate-600"
>
{tag}
</span>
))}
</div>
</section>
<section className="rounded-[2rem] border border-slate-900/10 bg-white/92 p-6 shadow-[0_18px_40px_rgba(15,23,42,0.05)] lg:p-8">
<article
className="public-doc-prose"
dangerouslySetInnerHTML={{ __html: html }}
/>
</section>
<BrandCTA lang={language} />
</div>
</main>
)
</PublicPageShell>
);
}

View File

@ -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 (
<Suspense fallback={<div className="p-6 text-center">Loading blog content...</div>}>
<BlogList posts={postsWithoutContent} categories={categories} />
</Suspense>
)
<PublicPageShell>
<Suspense
fallback={
<div className="rounded-[2rem] border border-slate-900/10 bg-white/90 p-6 text-center text-sm text-slate-500">
Loading blog content...
</div>
}
>
<BlogList posts={postsWithoutContent} categories={categories} />
</Suspense>
</PublicPageShell>
);
}

View File

@ -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<DirListing[]> {
return getDownloadListings()
return getDownloadListings();
}
// 构建时获取:优先使用本地数据,保证构建成功
async function getAllListingsForBuildTime(): Promise<DirListing[]> {
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 (
<main className="px-4 py-10 md:px-8">
<PublicPageShell>
<DownloadNotFound />
</main>
)
</PublicPageShell>
);
}
const listing = findListing(allListings, segments)
const listing = findListing(allListings, segments);
if (!listing) {
return (
<main className="px-4 py-10 md:px-8">
<PublicPageShell>
<DownloadNotFound />
</main>
)
</PublicPageShell>
);
}
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 (
<main className="px-4 py-10 md:px-8">
<div className="mx-auto max-w-7xl">
<DownloadListingContent
segments={segments}
title={displayTitle}
subdirectorySections={subdirectorySections}
fileListing={fileListing}
totalFiles={totalFiles}
latestModified={latestModified}
relativePath={relativePath}
remotePath={remotePath}
/>
</div>
</main>
)
<PublicPageShell>
<DownloadListingContent
segments={segments}
title={displayTitle}
subdirectorySections={subdirectorySections}
fileListing={fileListing}
totalFiles={totalFiles}
latestModified={latestModified}
relativePath={relativePath}
remotePath={remotePath}
/>
</PublicPageShell>
);
}

View File

@ -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 (
<main className="px-4 py-10 md:px-8">
<div className="mx-auto max-w-7xl space-y-8">
<DownloadSummary
topLevelCount={topLevelDirectories.length}
totalCollections={totalCollections}
totalFiles={totalFiles}
/>
<DownloadBrowser sectionsMap={mergedSectionsMap} />
</div>
</main>
)
<PublicPageShell>
<DownloadSummary
topLevelCount={topLevelDirectories.length}
totalCollections={totalCollections}
totalFiles={totalFiles}
/>
<DownloadBrowser sectionsMap={mergedSectionsMap} />
</PublicPageShell>
);
}

View File

@ -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 (
<section className={`flex items-center border-t border-slate-200 ${isCompact ? 'pt-3' : 'pt-4'}`}>
<section
className={`flex flex-col gap-5 rounded-[1.75rem] border border-slate-900/10 bg-[#fcfbf8] ${
isCompact
? "p-4 sm:flex-row sm:items-center"
: "p-5 sm:flex-row sm:items-center"
}`}
>
<div className="flex-1 text-left">
<p className="text-sm font-medium text-slate-600">{content.main}</p>
{!isCompact && (
<>
<div className="h-3" aria-hidden="true" />
<p className="text-xs text-slate-500">{content.secondary}</p>
</>
)}
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.24em] text-text-subtle">
{lang === "zh" ? "官方渠道" : "Official channel"}
</p>
<p className="mt-3 text-base font-semibold text-slate-900">
{content.main}
</p>
{!isCompact ? (
<p className="mt-2 text-sm leading-6 text-slate-600">
{content.secondary}
</p>
) : null}
</div>
<div className="ml-6 flex justify-end">
<div className="flex justify-start sm:justify-end">
<Image
src="/icons/webchat.jpg"
alt={lang === 'zh' ? 'Cloud-Neutral 微信二维码' : 'Cloud-Neutral WeChat QR code'}
alt={
lang === "zh"
? "Cloud-Neutral 微信二维码"
: "Cloud-Neutral WeChat QR code"
}
width={imageSize}
height={imageSize}
className="h-auto w-auto"
className="h-auto w-auto rounded-[1.25rem] border border-slate-900/10"
/>
</div>
</section>
)
);
}

View File

@ -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<HTMLAnchorElement>) => void
}
label: string;
href: string;
icon: ReactNode;
disabled?: boolean;
onClick?: (event: MouseEvent<HTMLAnchorElement>) => 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 (
<Link
href={href}
className={clsx(
'flex items-center justify-center rounded-full px-4 py-2 text-sm font-semibold transition',
"flex items-center justify-center rounded-full px-4 py-2 text-sm font-semibold transition",
active
? 'bg-white text-slate-900 shadow-sm shadow-slate-900/5'
: 'text-slate-500 hover:text-slate-700 focus-visible:text-slate-700',
? "bg-white text-slate-900 shadow-sm shadow-slate-900/5"
: "text-slate-500 hover:text-slate-800",
)}
aria-current={active ? 'page' : undefined}
aria-current={active ? "page" : undefined}
>
{children}
</Link>
)
);
}
function AuthSocialButton({ label, href, icon, disabled, onClick }: AuthLayoutSocialButton) {
function AuthSocialButton({
label,
href,
icon,
disabled,
onClick,
}: AuthLayoutSocialButton) {
const handleClick = (event: MouseEvent<HTMLAnchorElement>) => {
if (disabled) {
event.preventDefault()
event.stopPropagation()
event.preventDefault();
event.stopPropagation();
}
onClick?.(event)
}
onClick?.(event);
};
return (
<a
href={href}
onClick={handleClick}
className={clsx(
'flex items-center justify-center gap-3 rounded-2xl px-4 py-2.5 text-sm font-medium transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2',
"inline-flex items-center justify-center gap-3 rounded-[1.25rem] px-4 py-3 text-sm font-semibold transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2",
disabled
? 'cursor-not-allowed bg-slate-100 text-slate-400 focus-visible:outline-slate-200'
: 'bg-slate-900 text-white shadow-lg shadow-slate-900/10 hover:bg-slate-800 focus-visible:outline-slate-900',
? "cursor-not-allowed border border-slate-200 bg-slate-100 text-slate-400 focus-visible:outline-slate-200"
: "border border-slate-900/10 bg-white text-slate-800 hover:border-slate-900/15 hover:bg-[#fcfbf8] focus-visible:outline-slate-300",
)}
aria-disabled={disabled}
tabIndex={disabled ? -1 : undefined}
@ -77,7 +112,7 @@ function AuthSocialButton({ label, href, icon, disabled, onClick }: AuthLayoutSo
{icon}
{label}
</a>
)
);
}
export function AuthLayout({
@ -94,51 +129,75 @@ export function AuthLayout({
bottomNote,
switchAction,
}: AuthLayoutProps) {
const modeLabel = mode === "login" ? "Account access" : "Create account";
return (
<div className="relative flex min-h-screen flex-col overflow-hidden bg-slate-50">
<div className="relative flex min-h-screen flex-col overflow-hidden bg-background text-text transition-colors duration-150">
<div
className="pointer-events-none absolute inset-x-0 -top-1/3 h-1/2 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-sky-100 via-transparent to-transparent"
aria-hidden
className="pointer-events-none absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.56),rgba(255,255,255,0))]"
/>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-[26rem] bg-[radial-gradient(circle_at_top_left,rgba(37,78,219,0.08),transparent_36%),radial-gradient(circle_at_top_right,rgba(245,211,170,0.32),transparent_38%)]"
/>
<main
className="relative flex flex-1 items-center justify-center px-4 py-12 sm:px-6 lg:px-8"
data-testid="auth-layout"
>
<div className="w-full max-w-md">
<div className="mb-8 text-center">
<Link href="/" className="text-3xl font-semibold tracking-tight text-slate-900">
Svc.Plus
<div className="w-full max-w-[32rem]">
<div className="mb-6 flex items-center justify-between gap-4">
<Link href="/" className="space-y-1">
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.24em] text-text-subtle">
Cloud-Neutral Toolkit
</p>
<p className="text-2xl font-semibold tracking-[-0.04em] text-slate-900">
Svc.Plus
</p>
</Link>
<p className="mt-1 text-sm text-slate-500">Cloud-Neutral · </p>
<span className="rounded-full border border-slate-900/10 bg-white/90 px-3 py-1 text-xs font-semibold text-slate-600">
{modeLabel}
</span>
</div>
<div className="overflow-hidden rounded-3xl border border-slate-200 bg-white/90 p-8 shadow-xl shadow-slate-900/5 backdrop-blur">
<div className="grid grid-cols-2 gap-2 rounded-full bg-slate-100 p-1">
<AuthLayoutTab href="/login" active={mode === 'login'}>
<div className="overflow-hidden rounded-[2.25rem] border border-slate-900/10 bg-white/94 p-6 shadow-[0_22px_50px_rgba(15,23,42,0.05)] backdrop-blur sm:p-8">
<div className="grid grid-cols-2 gap-2 rounded-full bg-[#f3efe8] p-1">
<AuthLayoutTab href="/login" active={mode === "login"}>
Sign In
</AuthLayoutTab>
<AuthLayoutTab href="/register" active={mode === 'register'}>
<AuthLayoutTab href="/register" active={mode === "register"}>
Sign Up
</AuthLayoutTab>
</div>
<div className="mt-6 space-y-6">
{badge ? (
<span className="inline-flex items-center rounded-full bg-sky-100 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-sky-700">
<span className="inline-flex items-center rounded-full border border-slate-900/10 bg-[#f8f4ec] px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] text-slate-600">
{badge}
</span>
) : null}
<div className="space-y-2">
<h1 className="text-2xl font-semibold text-slate-900 sm:text-3xl">{title}</h1>
{description ? <p className="text-sm text-slate-600">{description}</p> : null}
<h1 className="text-[2rem] font-semibold leading-[0.95] tracking-[-0.05em] text-slate-900 sm:text-[2.5rem]">
{title}
</h1>
{description ? (
<p className="text-sm leading-7 text-slate-600">
{description}
</p>
) : null}
</div>
{alert ? (
<div
className={clsx(
'rounded-2xl border px-4 py-3 text-sm font-medium',
alert.type === 'error'
? 'border-red-200 bg-red-50 text-red-700'
: alert.type === 'success'
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
: 'border-sky-200 bg-sky-50 text-sky-700',
"rounded-[1.25rem] border px-4 py-3 text-sm leading-6",
alert.type === "error"
? "border-danger/20 bg-danger-muted text-danger-foreground"
: alert.type === "success"
? "border-success/20 bg-success-muted text-success-foreground"
: "border-primary/15 bg-primary-muted text-accent-foreground",
)}
role="status"
aria-live="polite"
@ -146,34 +205,48 @@ export function AuthLayout({
{alert.message}
</div>
) : null}
{aboveForm}
<div className="space-y-5">{children}</div>
{socialButtons.length > 0 ? (
<div className="space-y-4">
<div className="flex items-center gap-4 text-xs uppercase tracking-[0.2em] text-slate-400">
<span className="h-px flex-1 bg-slate-200" aria-hidden />
{socialHeading ?? 'Or continue with'}
{socialHeading ?? "Or continue with"}
<span className="h-px flex-1 bg-slate-200" aria-hidden />
</div>
<div className="grid grid-cols-2 gap-3">
{socialButtons.map(button => (
{socialButtons.map((button) => (
<AuthSocialButton key={button.label} {...button} />
))}
</div>
</div>
) : null}
<p className="text-sm text-slate-600">
{switchAction.text}{' '}
<Link href={switchAction.href} className="font-semibold text-sky-600 hover:text-sky-500">
{switchAction.text}{" "}
<Link href={switchAction.href} className={AUTH_TEXT_LINK_CLASS}>
{switchAction.linkLabel}
</Link>
</p>
{footnote ? <div className="text-xs text-slate-400">{footnote}</div> : null}
{footnote ? (
<div className="text-xs leading-6 text-text-subtle">
{footnote}
</div>
) : null}
</div>
</div>
{bottomNote ? <p className="mt-6 text-center text-xs text-slate-500">{bottomNote}</p> : null}
{bottomNote ? (
<p className="mt-6 text-center text-xs text-text-subtle">
{bottomNote}
</p>
) : null}
</div>
</main>
</div>
)
);
}

View File

@ -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<Record<string, number>>((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<BlogPostSummary['category']> => Boolean(category))
.map((category) => ({ key: category.key, label: category.label ?? category.key }))
.filter(
(category): category is NonNullable<BlogPostSummary["category"]> =>
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 (
<div className="bg-white text-slate-900">
<header className="sticky top-0 z-30 border-b border-slate-200 bg-white/80 backdrop-blur">
<div className="mx-auto flex h-16 max-w-6xl items-center justify-between px-4 sm:px-6 lg:px-8">
<Link href="/" className="flex items-center gap-2 text-sm font-semibold">
<span className="text-slate-500">SVC.plus</span>
<span className="text-slate-400">/</span>
<span className="text-brand-dark">blog</span>
</Link>
<div className="flex items-center gap-3">
<SearchComponent className="relative w-full max-w-xs" />
<div className="space-y-8">
<section className="rounded-[2.4rem] border border-slate-900/10 bg-[linear-gradient(180deg,#ffffff,#faf7f2)] p-6 shadow-[0_22px_50px_rgba(15,23,42,0.05)] sm:p-8 lg:p-10">
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem] lg:items-end">
<PublicPageIntro
eyebrow={isChinese ? "博客与动态" : "Editorial notes"}
title={
isChinese ? "产品日志与架构随笔" : "Product Notes & Field Updates"
}
subtitle={
isChinese
? "把产品更新、发布日志和架构观察收进同一套公开页阅读语法。"
: "A calmer feed for releases, essays, and field notes across the Cloud-Neutral stack."
}
titleClassName={
isChinese
? "text-[2.7rem] tracking-[-0.08em] sm:text-[3.4rem]"
: "editorial-display text-[2.9rem] tracking-[-0.06em] sm:text-[3.6rem]"
}
/>
<div className="rounded-[1.75rem] border border-slate-900/10 bg-white/85 p-5">
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.24em] text-text-subtle">
{isChinese ? "搜索文章" : "Search notes"}
</p>
<div className="mt-4">
<SearchComponent
className="max-w-none"
inputClassName="w-full rounded-full border border-slate-900/10 bg-[#fcfbf8] py-3 pl-5 pr-12 text-sm text-slate-700 shadow-none focus:border-slate-900/15 focus:bg-white focus:ring-2 focus:ring-primary/15"
buttonClassName="absolute right-3 top-1/2 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full bg-slate-950 text-white transition hover:bg-primary"
/>
</div>
</div>
</div>
</header>
</section>
<main className="flex min-h-screen flex-col bg-slate-50">
<div className="mx-auto w-full max-w-6xl px-4 py-16">
<div className="mb-12">
<h1 className="text-4xl font-bold text-slate-900 mb-4">Blog</h1>
<p className="text-lg text-slate-600">
Latest updates, releases, and insights from the Cloud-Neutral community.
</p>
</div>
<div className="mb-10 flex flex-wrap items-center gap-3">
{categoryTabs.map((tab) => {
const isActive = tab.key === selectedCategory
const labelWithCount = categoryCounts[tab.key]
return (
<Link
key={tab.key}
href={`/blogs${isActive ? '' : `?category=${tab.key}`}`}
className={`flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-semibold transition ${isActive
? 'border-brand bg-brand text-white shadow-sm'
: 'border-slate-200 bg-white text-slate-700 hover:border-brand/60 hover:text-brand'
}`}
aria-current={isActive ? 'page' : undefined}
>
<span>{tab.label}</span>
{labelWithCount ? (
<span
className={`rounded-full px-2 py-0.5 text-xs font-bold ${isActive ? 'bg-white/20 text-white' : 'bg-slate-100 text-slate-700'
}`}
>
{labelWithCount}
</span>
) : null}
</Link>
)
})}
<Link
href="/blogs"
className={`flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-semibold transition ${!selectedCategory
? 'border-brand bg-brand text-white shadow-sm'
: 'border-slate-200 bg-white text-slate-700 hover:border-brand/60 hover:text-brand'
}`}
<section className="rounded-[2rem] border border-slate-900/10 bg-white/92 p-5 shadow-[0_18px_40px_rgba(15,23,42,0.05)]">
<div className="flex flex-wrap items-center gap-3">
<Link
href="/blogs"
className={`flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-semibold transition ${
!selectedCategory
? "border-slate-900/10 bg-slate-950 text-white"
: "border-slate-900/10 bg-white text-slate-700 hover:border-slate-900/15 hover:bg-[#fcfbf8]"
}`}
>
{isChinese ? "全部" : "All"}
<span
className={`rounded-full px-2 py-0.5 text-xs font-bold ${
!selectedCategory
? "bg-white/20 text-white"
: "bg-[#f8f4ec] text-slate-700"
}`}
>
<span
className={`rounded-full px-2 py-0.5 text-xs font-bold ${!selectedCategory ? 'bg-white/20 text-white' : 'bg-slate-100 text-slate-700'
}`}
{posts.length}
</span>
</Link>
{categoryTabs.map((tab) => {
const isActive = tab.key === selectedCategory;
const labelWithCount = categoryCounts[tab.key];
return (
<Link
key={tab.key}
href={`/blogs${isActive ? "" : `?category=${tab.key}`}`}
className={`flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-semibold transition ${
isActive
? "border-slate-900/10 bg-slate-950 text-white"
: "border-slate-900/10 bg-white text-slate-700 hover:border-slate-900/15 hover:bg-[#fcfbf8]"
}`}
aria-current={isActive ? "page" : undefined}
>
{posts.length}
</span>
</Link>
</div>
{filteredPosts.length === 0 ? (
<div className="text-center py-20">
<p className="text-slate-500"></p>
</div>
) : (
<>
<div className="grid gap-8">
{paginatedPosts.map((post) => (
<article
key={post.slug}
className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm transition hover:shadow-md"
<span>{tab.label}</span>
{labelWithCount ? (
<span
className={`rounded-full px-2 py-0.5 text-xs font-bold ${
isActive
? "bg-white/20 text-white"
: "bg-[#f8f4ec] text-slate-700"
}`}
>
<div className="mb-4 flex items-center justify-between">
<span className="text-sm font-semibold text-brand">Blog</span>
{post.date && <time className="text-sm text-slate-500">{formatDate(post.date, 'en')}</time>}
</div>
<h2 className="mb-4 text-2xl font-bold text-slate-900">{post.title}</h2>
{post.author && <p className="mb-4 text-sm text-slate-500">By {post.author}</p>}
<p className="mb-6 text-slate-600">{post.excerpt}</p>
<div className="flex items-center gap-4">
{post.tags && post.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{post.tags.map((tag) => (
<span
key={tag}
className="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700"
>
{tag}
</span>
))}
</div>
)}
<Link
href={`/blogs/${post.slug}`}
className="ml-auto text-sm font-semibold text-brand transition hover:text-brand-dark"
>
Read more
</Link>
</div>
</article>
))}
{labelWithCount}
</span>
) : null}
</Link>
);
})}
</div>
</section>
{filteredPosts.length === 0 ? (
<div className="rounded-[1.8rem] border border-dashed border-slate-900/12 bg-white/80 py-20 text-center text-sm text-slate-500">
{isChinese ? "暂无博客文章" : "No posts found."}
</div>
) : (
<div className="grid gap-4">
{paginatedPosts.map((post) => (
<article
key={post.slug}
className="rounded-[1.9rem] border border-slate-900/10 bg-white/92 p-6 shadow-[0_18px_40px_rgba(15,23,42,0.05)] transition duration-200 hover:-translate-y-[1px] hover:bg-white"
>
<div className="flex flex-wrap items-center justify-between gap-3">
<span className="rounded-full border border-slate-900/10 bg-[#f8f4ec] px-3 py-1 text-xs font-semibold text-slate-600">
{post.category?.label ?? "Blog"}
</span>
{post.date ? (
<time className="text-sm text-slate-500">
{formatDate(post.date, language)}
</time>
) : null}
</div>
{totalPages > 1 && (
<nav className="mt-12 flex items-center justify-center gap-2">
<Link
href={`/blogs?page=${Math.max(1, currentPage - 1)}${selectedCategory ? `&category=${selectedCategory}` : ''}`}
className={`px-4 py-2 text-sm font-semibold rounded-lg transition ${currentPage === 1
? 'cursor-not-allowed text-slate-400'
: 'text-brand hover:bg-slate-100'
}`}
aria-disabled={currentPage === 1}
>
Previous
</Link>
<div className="mt-5 space-y-3">
<h2 className="text-[1.65rem] font-semibold leading-[1.05] tracking-[-0.04em] text-slate-900">
{post.title}
</h2>
{post.author ? (
<p className="text-sm text-slate-500">
{isChinese ? "作者" : "By"} {post.author}
</p>
) : null}
<p className="max-w-3xl text-sm leading-7 text-slate-600">
{post.excerpt}
</p>
</div>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((pageNumber) => (
<Link
key={pageNumber}
href={`/blogs?page=${pageNumber}${selectedCategory ? `&category=${selectedCategory}` : ''}`}
className={`px-4 py-2 text-sm font-semibold rounded-lg transition ${pageNumber === currentPage
? 'bg-brand text-white'
: 'text-slate-700 hover:bg-slate-100'
}`}
>
{pageNumber}
</Link>
))}
<div className="mt-6 flex flex-wrap items-center gap-3">
{post.tags && post.tags.length > 0 ? (
<div className="flex flex-wrap gap-2">
{post.tags.map((tag) => (
<span
key={tag}
className="rounded-full border border-slate-900/10 bg-[#fcfbf8] px-3 py-1 text-xs font-medium text-slate-600"
>
{tag}
</span>
))}
</div>
) : null}
<Link
href={`/blogs/${post.slug}`}
className="ml-auto inline-flex items-center gap-2 text-sm font-semibold text-primary transition hover:text-primary-hover"
>
{isChinese ? "继续阅读" : "Read more"}
<ArrowRight className="h-4 w-4" aria-hidden />
</Link>
</div>
</article>
))}
</div>
)}
<Link
href={`/blogs?page=${Math.min(totalPages, currentPage + 1)}${selectedCategory ? `&category=${selectedCategory}` : ''
}`}
className={`px-4 py-2 text-sm font-semibold rounded-lg transition ${currentPage === totalPages
? 'cursor-not-allowed text-slate-400'
: 'text-brand hover:bg-slate-100'
}`}
aria-disabled={currentPage === totalPages}
>
Next
</Link>
</nav>
)}
{totalPages > 1 ? (
<nav className="flex flex-wrap items-center justify-center gap-2">
<Link
href={`/blogs?page=${Math.max(1, currentPage - 1)}${selectedCategory ? `&category=${selectedCategory}` : ""}`}
className={`rounded-full border px-4 py-2 text-sm font-semibold transition ${
currentPage === 1
? "pointer-events-none border-slate-900/8 bg-white text-slate-300"
: "border-slate-900/10 bg-white text-slate-700 hover:border-slate-900/15 hover:bg-[#fcfbf8]"
}`}
aria-disabled={currentPage === 1}
>
{isChinese ? "上一页" : "Previous"}
</Link>
</>
{Array.from({ length: totalPages }, (_, i) => i + 1).map(
(pageNumber) => (
<Link
key={pageNumber}
href={`/blogs?page=${pageNumber}${selectedCategory ? `&category=${selectedCategory}` : ""}`}
className={`rounded-full border px-4 py-2 text-sm font-semibold transition ${
pageNumber === currentPage
? "border-slate-900/10 bg-slate-950 text-white"
: "border-slate-900/10 bg-white text-slate-700 hover:border-slate-900/15 hover:bg-[#fcfbf8]"
}`}
>
{pageNumber}
</Link>
),
)}
<div className="mt-12">
<BrandCTA lang={language} variant="compact" />
</div>
</div>
</main>
<Link
href={`/blogs?page=${Math.min(totalPages, currentPage + 1)}${selectedCategory ? `&category=${selectedCategory}` : ""}`}
className={`rounded-full border px-4 py-2 text-sm font-semibold transition ${
currentPage === totalPages
? "pointer-events-none border-slate-900/8 bg-white text-slate-300"
: "border-slate-900/10 bg-white text-slate-700 hover:border-slate-900/15 hover:bg-[#fcfbf8]"
}`}
aria-disabled={currentPage === totalPages}
>
{isChinese ? "下一页" : "Next"}
</Link>
</nav>
) : null}
<BrandCTA lang={language} variant="compact" />
</div>
)
);
}

View File

@ -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 (
<nav className="text-sm mb-4">
<ol className="flex flex-wrap gap-1 items-center">
{items.map((item, idx) => (
<li key={idx} className="flex items-center gap-1">
{idx > 0 && <span>/</span>}
<Link href={item.href} className="text-blue-600 hover:underline">
{item.label}
</Link>
</li>
))}
</ol>
<nav className="mb-1 flex flex-wrap items-center gap-2 text-sm text-text-muted">
{items.map((item, index) => (
<div key={`${item.href}-${index}`} className="flex items-center gap-2">
{index > 0 ? <span className="text-slate-300">/</span> : null}
<Link
href={item.href}
className="rounded-full border border-slate-900/10 bg-white px-3 py-1.5 transition hover:border-slate-900/15 hover:text-primary"
>
{item.label}
</Link>
</div>
))}
</nav>
)
);
}

View File

@ -1,87 +1,115 @@
'use client'
"use client";
import Link from 'next/link'
import { useMemo, useState } from 'react'
import { useLanguage } from '@i18n/LanguageProvider'
import { translations } from '@i18n/translations'
import { formatDate } from '../../lib/format'
import { formatSegmentLabel } from '../../lib/download-data'
import Link from "next/link";
import { ArrowUpRight } from "lucide-react";
import { useMemo, useState } from "react";
import { useLanguage } from "@i18n/LanguageProvider";
import { translations } from "@i18n/translations";
import { formatSegmentLabel } from "@lib/download-data";
import { formatDate } from "@lib/format";
interface Section {
key: string
title: string
href: string
lastModified?: string
count?: number
root?: string
key: string;
title: string;
href: string;
lastModified?: string;
count?: number;
root?: string;
}
export default function CardGrid({ sections }: { sections: Section[] }) {
const { language } = useLanguage()
const locale = language === 'zh' ? 'zh-CN' : 'en-US'
const t = translations[language].download.cardGrid
const [search, setSearch] = useState('')
const [sort, setSort] = useState<'lastModified' | 'title'>('lastModified')
const { language } = useLanguage();
const locale = language === "zh" ? "zh-CN" : "en-US";
const t = translations[language].download.cardGrid;
const [search, setSearch] = useState("");
const [sort, setSort] = useState<"lastModified" | "title">("lastModified");
const filtered = useMemo(() => {
return sections
.filter((section) => section.title.toLowerCase().includes(search.toLowerCase()))
.sort((a, b) =>
sort === 'title'
? a.title.localeCompare(b.title, locale)
: new Date(b.lastModified || 0).getTime() - new Date(a.lastModified || 0).getTime(),
.filter((section) =>
section.title.toLowerCase().includes(search.toLowerCase()),
)
}, [sections, search, sort, locale])
.sort((a, b) =>
sort === "title"
? a.title.localeCompare(b.title, locale)
: new Date(b.lastModified || 0).getTime() -
new Date(a.lastModified || 0).getTime(),
);
}, [sections, search, sort, locale]);
return (
<div>
<div className="sticky top-20 z-10 mb-4 flex items-center gap-2 border-b bg-white pb-2">
<select className="rounded border p-2" value={sort} onChange={(event) => setSort(event.target.value as any)}>
<option value="lastModified">{t.sortUpdated}</option>
<option value="title">{t.sortName}</option>
</select>
<div className="ml-auto">
<input
placeholder={t.searchPlaceholder}
value={search}
onChange={(event) => setSearch(event.target.value)}
className="rounded border p-2"
/>
<div className="space-y-4">
<div className="flex flex-col gap-3 rounded-[1.5rem] border border-slate-900/10 bg-[#fcfbf8] p-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap gap-2">
<select
className="rounded-full border border-slate-900/10 bg-white px-4 py-2 text-sm text-slate-700 outline-none transition focus:border-slate-900/15"
value={sort}
onChange={(event) =>
setSort(event.target.value as "lastModified" | "title")
}
>
<option value="lastModified">{t.sortUpdated}</option>
<option value="title">{t.sortName}</option>
</select>
</div>
<input
placeholder={t.searchPlaceholder}
value={search}
onChange={(event) => setSearch(event.target.value)}
className="w-full rounded-full border border-slate-900/10 bg-white px-4 py-2 text-sm text-slate-700 outline-none transition focus:border-slate-900/15 sm:max-w-xs"
/>
</div>
<div className="columns-1 gap-4 sm:columns-2 lg:columns-3">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{filtered.map((section) => (
<Link
key={section.key}
href={section.href}
className="mb-4 block break-inside-avoid rounded-3xl border bg-white p-5 shadow-sm ring-1 ring-gray-100 transition hover:-translate-y-1 hover:shadow-lg"
className="group rounded-[1.6rem] border border-slate-900/10 bg-white/92 p-5 shadow-[0_14px_30px_rgba(15,23,42,0.04)] transition duration-200 hover:-translate-y-[1px] hover:bg-white"
>
<div className="flex flex-col gap-3">
{section.root && (
<span className="inline-flex w-fit items-center rounded-full bg-purple-50 px-2 py-0.5 text-xs font-semibold text-purple-600">
{formatSegmentLabel(section.root)}
</span>
)}
<div className="text-4xl font-bold text-gray-900">{section.title.charAt(0).toUpperCase()}</div>
<div className="text-base font-semibold text-gray-900">{section.title}</div>
<div className="space-y-1 text-xs text-gray-600">
{section.lastModified && (
<p>
<span>{t.updatedLabel}</span>
<span className="ml-1">{formatDate(section.lastModified, locale)}</span>
</p>
)}
{section.count !== undefined && (
<p>
<span>{t.itemsLabel}</span>
<span className="ml-1">{section.count.toLocaleString(locale)}</span>
</p>
)}
<div className="flex h-full flex-col gap-4">
<div className="flex items-start justify-between gap-3">
<div className="space-y-3">
{section.root ? (
<span className="inline-flex w-fit items-center rounded-full border border-slate-900/10 bg-[#f8f4ec] px-3 py-1 text-xs font-semibold text-slate-600">
{formatSegmentLabel(section.root)}
</span>
) : null}
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-slate-900/[0.04] text-sm font-semibold text-primary">
{section.title.charAt(0).toUpperCase()}
</div>
</div>
<ArrowUpRight className="h-4 w-4 shrink-0 text-slate-400 transition group-hover:text-primary" />
</div>
<div className="space-y-2">
<div className="text-base font-semibold leading-7 text-slate-900">
{section.title}
</div>
<div className="space-y-1 text-sm text-slate-600">
{section.lastModified ? (
<p>
<span>{t.updatedLabel}</span>
<span className="ml-1">
{formatDate(section.lastModified, locale)}
</span>
</p>
) : null}
{section.count !== undefined ? (
<p>
<span>{t.itemsLabel}</span>
<span className="ml-1">
{section.count.toLocaleString(locale)}
</span>
</p>
) : null}
</div>
</div>
</div>
</Link>
))}
</div>
</div>
)
);
}

View File

@ -1,29 +1,29 @@
'use client'
"use client";
import { Copy } from 'lucide-react'
import { Copy } from "lucide-react";
interface Props {
text: string
label: string
text: string;
label: string;
}
export default function CopyButton({ text, label }: Props) {
const handleClick = async () => {
try {
await navigator.clipboard.writeText(text)
await navigator.clipboard.writeText(text);
} catch (e) {
console.error('copy failed', e)
console.error("copy failed", e);
}
}
};
return (
<button
type="button"
onClick={handleClick}
className="flex h-8 w-8 items-center justify-center rounded border hover:bg-gray-100"
className="flex h-9 w-9 items-center justify-center rounded-full border border-slate-900/10 bg-white text-slate-600 transition hover:border-slate-900/15 hover:bg-[#fcfbf8]"
title={label}
aria-label={label}
>
<Copy className="h-4 w-4" aria-hidden="true" />
</button>
)
);
}

View File

@ -1,19 +1,20 @@
"use client"
"use client";
import { useMemo, useState } from 'react'
import { formatSegmentLabel, type DownloadSection } from '../../lib/download-data'
import { useLanguage } from '@i18n/LanguageProvider'
import { translations } from '@i18n/translations'
import CardGrid from './CardGrid'
import { useMemo, useState } from "react";
import CardGrid from "./CardGrid";
import { useLanguage } from "@i18n/LanguageProvider";
import { translations } from "@i18n/translations";
import { formatSegmentLabel, type DownloadSection } from "@lib/download-data";
interface DownloadBrowserProps {
sectionsMap: Record<string, DownloadSection[]>
sectionsMap: Record<string, DownloadSection[]>;
}
export default function DownloadBrowser({ sectionsMap }: DownloadBrowserProps) {
const { language } = useLanguage()
const locale = language === 'zh' ? 'zh-CN' : 'en-US'
const t = translations[language].download.browser
const { language } = useLanguage();
const locale = language === "zh" ? "zh-CN" : "en-US";
const t = translations[language].download.browser;
const roots = useMemo(
() =>
@ -21,59 +22,74 @@ export default function DownloadBrowser({ sectionsMap }: DownloadBrowserProps) {
formatSegmentLabel(a).localeCompare(formatSegmentLabel(b), locale),
),
[sectionsMap, locale],
)
const [current, setCurrent] = useState<string>('all')
);
const [current, setCurrent] = useState<string>("all");
const totalsByRoot = useMemo(() => {
const totals: Record<string, number> = {}
const totals: Record<string, number> = {};
for (const root of roots) {
const entries = sectionsMap[root] ?? []
const hasChildren = entries.some((section) => section.key !== root)
const entries = sectionsMap[root] ?? [];
const hasChildren = entries.some((section) => section.key !== root);
totals[root] = hasChildren
? entries.filter((section) => section.key !== root).length
: entries.length
: entries.length;
}
return totals
}, [roots, sectionsMap])
return totals;
}, [roots, sectionsMap]);
const allSections = useMemo(
() => roots.flatMap((root) => sectionsMap[root] ?? []),
[roots, sectionsMap],
)
);
const rawSections = current === 'all' ? allSections : sectionsMap[current] ?? []
const rawSections =
current === "all" ? allSections : (sectionsMap[current] ?? []);
const sections =
current === 'all'
current === "all"
? rawSections
: rawSections.some((section) => section.key !== current)
? rawSections.filter((section) => section.key !== current)
: rawSections
: rawSections;
const activeLabel = current === 'all' ? t.allHeading : formatSegmentLabel(current)
const activeLabel =
current === "all" ? t.allHeading : formatSegmentLabel(current);
const description =
current === 'all'
current === "all"
? t.allDescription
: t.collectionDescription.replace('{{collection}}', formatSegmentLabel(current))
: t.collectionDescription.replace(
"{{collection}}",
formatSegmentLabel(current),
);
const itemCountTemplate = sections.length === 1 ? t.itemCount.singular : t.itemCount.plural
const itemCountLabel = itemCountTemplate.replace('{{count}}', sections.length.toLocaleString(locale))
const itemCountTemplate =
sections.length === 1 ? t.itemCount.singular : t.itemCount.plural;
const itemCountLabel = itemCountTemplate.replace(
"{{count}}",
sections.length.toLocaleString(locale),
);
return (
<div className="flex flex-col gap-6 lg:flex-row">
<aside className="lg:w-72">
<div className="sticky top-24 rounded-3xl border bg-white p-5 shadow-sm ring-1 ring-gray-100">
<h2 className="text-sm font-semibold text-gray-700">{t.categoriesTitle}</h2>
<ul className="mt-4 space-y-1 text-sm">
<div className="sticky top-24 rounded-[2rem] border border-slate-900/10 bg-white/92 p-5 shadow-[0_18px_40px_rgba(15,23,42,0.05)]">
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-text-subtle">
{t.categoriesTitle}
</h2>
<ul className="mt-4 space-y-2 text-sm">
<li key="all">
<button
type="button"
onClick={() => setCurrent('all')}
className={`flex w-full items-center justify-between rounded-xl px-3 py-2 transition-colors ${
current === 'all' ? 'bg-purple-100 text-purple-700' : 'hover:bg-gray-100'
onClick={() => setCurrent("all")}
className={`flex w-full items-center justify-between rounded-[1.1rem] px-3 py-2.5 transition ${
current === "all"
? "bg-[#f8f4ec] text-slate-900"
: "text-slate-600 hover:bg-[#fcfbf8]"
}`}
>
<span>{t.allButton}</span>
<span className="text-xs text-gray-500">{allSections.length.toLocaleString(locale)}</span>
<span className="text-xs text-slate-500">
{allSections.length.toLocaleString(locale)}
</span>
</button>
</li>
{roots.map((root) => (
@ -81,38 +97,51 @@ export default function DownloadBrowser({ sectionsMap }: DownloadBrowserProps) {
<button
type="button"
onClick={() => setCurrent(root)}
className={`flex w-full items-center justify-between rounded-xl px-3 py-2 transition-colors ${
current === root ? 'bg-purple-100 text-purple-700' : 'hover:bg-gray-100'
className={`flex w-full items-center justify-between rounded-[1.1rem] px-3 py-2.5 transition ${
current === root
? "bg-[#f8f4ec] text-slate-900"
: "text-slate-600 hover:bg-[#fcfbf8]"
}`}
>
<span>{formatSegmentLabel(root)}</span>
<span className="text-xs text-gray-500">{(totalsByRoot[root] ?? 0).toLocaleString(locale)}</span>
<span className="text-xs text-slate-500">
{(totalsByRoot[root] ?? 0).toLocaleString(locale)}
</span>
</button>
</li>
))}
</ul>
</div>
</aside>
<section className="flex-1 space-y-6">
<div className="rounded-3xl border bg-white p-6 shadow-sm ring-1 ring-gray-100">
<section className="flex-1 space-y-5">
<div className="rounded-[2rem] border border-slate-900/10 bg-white/92 p-6 shadow-[0_18px_40px_rgba(15,23,42,0.05)]">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-gray-900">{activeLabel}</h2>
<p className="mt-1 text-sm text-gray-600">{description}</p>
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.24em] text-text-subtle">
{language === "zh" ? "目录视图" : "Collection view"}
</p>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-slate-900">
{activeLabel}
</h2>
<p className="mt-2 text-sm leading-6 text-slate-600">
{description}
</p>
</div>
<span className="rounded-full bg-purple-50 px-3 py-1 text-xs font-medium text-purple-600">
<span className="rounded-full border border-slate-900/10 bg-[#f8f4ec] px-3 py-1 text-xs font-semibold text-slate-700">
{itemCountLabel}
</span>
</div>
</div>
{sections.length > 0 ? (
<CardGrid sections={sections} />
) : (
<div className="rounded-3xl border border-dashed p-10 text-center text-sm text-gray-500">
<div className="rounded-[1.8rem] border border-dashed border-slate-900/12 bg-white/80 p-10 text-center text-sm text-slate-500">
{t.empty}
</div>
)}
</section>
</div>
)
);
}

View File

@ -1,33 +1,34 @@
'use client'
"use client";
import { useMemo } from 'react'
import Breadcrumbs, { type Crumb } from './Breadcrumbs'
import CardGrid from './CardGrid'
import FileTable from './FileTable'
import { useLanguage } from '@i18n/LanguageProvider'
import { translations } from '@i18n/translations'
import { formatDate } from '@lib/format'
import { formatSegmentLabel, type DownloadSection } from '@lib/download-data'
import type { DirListing } from '@lib/download/types'
import { useMemo } from "react";
import Breadcrumbs, { type Crumb } from "./Breadcrumbs";
import CardGrid from "./CardGrid";
import FileTable from "./FileTable";
import { useLanguage } from "@i18n/LanguageProvider";
import { translations } from "@i18n/translations";
import { formatSegmentLabel, type DownloadSection } from "@lib/download-data";
import { formatDate } from "@lib/format";
import type { DirListing } from "@lib/download/types";
type DownloadListingContentProps = {
segments: string[]
title: string
subdirectorySections: DownloadSection[]
fileListing: DirListing
totalFiles: number
latestModified?: string
relativePath: string
remotePath: string
}
segments: string[];
title: string;
subdirectorySections: DownloadSection[];
fileListing: DirListing;
totalFiles: number;
latestModified?: string;
relativePath: string;
remotePath: string;
};
function formatCount(
templates: { singular: string; plural: string },
count: number,
locale: string,
): string {
const template = count === 1 ? templates.singular : templates.plural
return template.replace('{{count}}', count.toLocaleString(locale))
const template = count === 1 ? templates.singular : templates.plural;
return template.replace("{{count}}", count.toLocaleString(locale));
}
export default function DownloadListingContent({
@ -40,24 +41,31 @@ export default function DownloadListingContent({
relativePath,
remotePath,
}: DownloadListingContentProps) {
const { language } = useLanguage()
const locale = language === 'zh' ? 'zh-CN' : 'en-US'
const t = translations[language].download
const { language } = useLanguage();
const locale = language === "zh" ? "zh-CN" : "en-US";
const t = translations[language].download;
const breadcrumbItems = useMemo<Crumb[]>(() => {
const crumbs: Crumb[] = [{ label: t.breadcrumbRoot, href: '/download' }]
const crumbs: Crumb[] = [{ label: t.breadcrumbRoot, href: "/download" }];
segments.forEach((segment, index) => {
const hrefSegments = segments.slice(0, index + 1)
const hrefSegments = segments.slice(0, index + 1);
crumbs.push({
label: formatSegmentLabel(segment),
href: `/download/${hrefSegments.join('/')}`,
})
})
return crumbs
}, [segments, t.breadcrumbRoot])
href: `/download/${hrefSegments.join("/")}`,
});
});
return crumbs;
}, [segments, t.breadcrumbRoot]);
const description = t.listing.headingDescription.replace('{{directory}}', title)
const entryCountLabel = formatCount(t.listing.collectionsCount, subdirectorySections.length, locale)
const description = t.listing.headingDescription.replace(
"{{directory}}",
title,
);
const entryCountLabel = formatCount(
t.listing.collectionsCount,
subdirectorySections.length,
locale,
);
const stats = [
{
label: t.listing.stats.subdirectories,
@ -75,80 +83,106 @@ export default function DownloadListingContent({
},
]
: []),
]
];
const hasSubdirectories = subdirectorySections.length > 0
const hasFiles = fileListing.entries.length > 0
const hasSubdirectories = subdirectorySections.length > 0;
const hasFiles = fileListing.entries.length > 0;
return (
<div className="space-y-6">
<Breadcrumbs items={breadcrumbItems} />
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
<section className="space-y-6">
<article className="rounded-3xl border bg-white p-6 shadow-sm ring-1 ring-gray-100">
<h1 className="text-2xl font-bold text-gray-900">{title}</h1>
<p className="mt-2 text-sm text-gray-600">{description}</p>
<dl className="mt-6 grid gap-4 text-sm sm:grid-cols-3">
{stats.map((item) => (
<div key={item.label}>
<dt className="text-gray-500">{item.label}</dt>
<dd className="mt-1 text-lg font-semibold text-gray-900">{item.value}</dd>
</div>
))}
</dl>
</article>
<section className="rounded-[2rem] border border-slate-900/10 bg-[linear-gradient(180deg,#ffffff,#faf7f2)] p-6 shadow-[0_20px_48px_rgba(15,23,42,0.05)] lg:p-7">
<Breadcrumbs items={breadcrumbItems} />
<div className="mt-4 grid gap-6 lg:grid-cols-[minmax(0,1fr)_18rem] lg:items-end">
<div className="space-y-3">
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.24em] text-text-subtle">
{language === "zh" ? "下载目录" : "Download directory"}
</p>
<h1 className="text-[2.25rem] font-semibold leading-[0.95] tracking-[-0.06em] text-slate-900 sm:text-[2.9rem]">
{title}
</h1>
<p className="text-sm leading-7 text-slate-600">{description}</p>
</div>
<dl className="grid gap-3 sm:grid-cols-3 lg:grid-cols-1">
{stats.map((item) => (
<div
key={item.label}
className="rounded-[1.4rem] border border-slate-900/10 bg-white/85 p-4"
>
<dt className="text-sm text-slate-500">{item.label}</dt>
<dd className="mt-2 text-xl font-semibold tracking-[-0.04em] text-slate-900">
{item.value}
</dd>
</div>
))}
</dl>
</div>
</section>
{hasSubdirectories && (
<article className="rounded-3xl border bg-white p-6 shadow-sm ring-1 ring-gray-100">
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_18rem]">
<section className="space-y-6">
{hasSubdirectories ? (
<article className="rounded-[2rem] border border-slate-900/10 bg-white/92 p-6 shadow-[0_18px_40px_rgba(15,23,42,0.05)]">
<div className="mb-4 flex items-center justify-between gap-3">
<h2 className="text-lg font-semibold text-gray-900">{t.listing.collectionsTitle}</h2>
<span className="text-xs text-gray-500">{entryCountLabel}</span>
<h2 className="text-lg font-semibold text-slate-900">
{t.listing.collectionsTitle}
</h2>
<span className="text-xs font-semibold text-slate-500">
{entryCountLabel}
</span>
</div>
<CardGrid sections={subdirectorySections} />
</article>
)}
) : null}
{hasFiles && (
<article className="rounded-3xl border bg-white p-4 shadow-sm ring-1 ring-gray-100">
<FileTable listing={fileListing} breadcrumb={breadcrumbItems} showBreadcrumbs={false} />
{hasFiles ? (
<article className="rounded-[2rem] border border-slate-900/10 bg-white/92 p-4 shadow-[0_18px_40px_rgba(15,23,42,0.05)]">
<FileTable
listing={fileListing}
breadcrumb={breadcrumbItems}
showBreadcrumbs={false}
/>
</article>
)}
) : null}
{!hasSubdirectories && !hasFiles && (
<div className="rounded-3xl border border-dashed p-10 text-center text-sm text-gray-500">
{!hasSubdirectories && !hasFiles ? (
<div className="rounded-[1.8rem] border border-dashed border-slate-900/12 bg-white/80 p-10 text-center text-sm text-slate-500">
{t.listing.empty}
</div>
)}
) : null}
</section>
<aside className="space-y-4 lg:sticky lg:top-24">
<article className="rounded-3xl border bg-white p-6 shadow-sm ring-1 ring-gray-100">
<h2 className="text-sm font-semibold text-gray-700">{t.listing.infoTitle}</h2>
<dl className="mt-4 space-y-3 text-xs text-gray-600">
<aside className="space-y-4 lg:sticky lg:top-24 lg:self-start">
<article className="rounded-[2rem] border border-slate-900/10 bg-white/92 p-6 shadow-[0_18px_40px_rgba(15,23,42,0.05)]">
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.24em] text-text-subtle">
{t.listing.infoTitle}
</p>
<dl className="mt-4 space-y-4 text-sm text-slate-600">
<div>
<dt className="text-gray-500">{t.listing.infoPath}</dt>
<dd className="mt-1 font-mono text-sm text-gray-900">
/{relativePath || segments.join('/')}
<dt className="text-slate-500">{t.listing.infoPath}</dt>
<dd className="mt-1 break-all font-mono text-slate-900">
/{relativePath || segments.join("/")}
</dd>
</div>
<div>
<dt className="text-gray-500">{t.listing.infoSource}</dt>
<dd className="mt-1 text-sm">
<dt className="text-slate-500">{t.listing.infoSource}</dt>
<dd className="mt-1 break-all">
<a
href={remotePath}
target="_blank"
rel="noopener noreferrer"
className="text-purple-600 hover:underline"
className="font-medium text-primary transition hover:text-primary-hover hover:underline"
>
{remotePath}
</a>
</dd>
</div>
</dl>
<p className="mt-4 text-xs text-gray-500">{t.listing.infoNotice}</p>
<p className="mt-4 text-xs leading-6 text-text-subtle">
{t.listing.infoNotice}
</p>
</article>
</aside>
</div>
</div>
)
);
}

View File

@ -1,15 +1,15 @@
'use client'
"use client";
import { useLanguage } from '@i18n/LanguageProvider'
import { translations } from '@i18n/translations'
import { useLanguage } from "@i18n/LanguageProvider";
import { translations } from "@i18n/translations";
export default function DownloadNotFound() {
const { language } = useLanguage()
const message = translations[language].download.listing.notFound
const { language } = useLanguage();
const message = translations[language].download.listing.notFound;
return (
<div className="mx-auto max-w-3xl rounded-3xl border border-dashed p-10 text-center text-sm text-red-500">
<div className="mx-auto max-w-3xl rounded-[2rem] border border-dashed border-slate-900/12 bg-white/80 p-10 text-center text-sm leading-6 text-slate-500 shadow-[0_18px_40px_rgba(15,23,42,0.04)]">
{message}
</div>
)
);
}

View File

@ -1,49 +1,72 @@
'use client'
"use client";
import { useLanguage } from '@i18n/LanguageProvider'
import { translations } from '@i18n/translations'
import { Boxes, Files, FolderTree } from "lucide-react";
import { useLanguage } from "@i18n/LanguageProvider";
import { translations } from "@i18n/translations";
type DownloadSummaryProps = {
topLevelCount: number
totalCollections: number
totalFiles: number
}
topLevelCount: number;
totalCollections: number;
totalFiles: number;
};
export default function DownloadSummary({
topLevelCount,
totalCollections,
totalFiles,
}: DownloadSummaryProps) {
const { language } = useLanguage()
const locale = language === 'zh' ? 'zh-CN' : 'en-US'
const t = translations[language].download.home
const { language } = useLanguage();
const isChinese = language === "zh";
const locale = isChinese ? "zh-CN" : "en-US";
const t = translations[language].download.home;
const stats = [
{ label: t.stats.categories, value: topLevelCount },
{ label: t.stats.collections, value: totalCollections },
{ label: t.stats.files, value: totalFiles },
]
{ label: t.stats.categories, value: topLevelCount, icon: FolderTree },
{ label: t.stats.collections, value: totalCollections, icon: Boxes },
{ label: t.stats.files, value: totalFiles, icon: Files },
];
return (
<section className="overflow-hidden rounded-3xl bg-gradient-to-br from-purple-50 via-white to-white p-8 shadow-sm ring-1 ring-purple-100">
<div className="flex flex-col gap-8 lg:flex-row lg:items-center">
<div className="flex-1 space-y-3">
<h1 className="text-3xl font-bold text-gray-900 md:text-4xl">{t.title}</h1>
<p className="max-w-2xl text-sm text-gray-600 md:text-base">{t.description}</p>
<section className="rounded-[2.4rem] border border-slate-900/10 bg-[linear-gradient(180deg,#ffffff,#faf7f2)] p-6 shadow-[0_22px_50px_rgba(15,23,42,0.05)] sm:p-8 lg:p-10">
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_22rem] lg:items-end">
<div className="space-y-4">
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.26em] text-text-subtle">
{isChinese ? "下载中心" : "Download library"}
</p>
<h1
className={
isChinese
? "text-[2.7rem] font-semibold leading-[0.9] tracking-[-0.08em] text-heading sm:text-[3.4rem]"
: "editorial-display text-[2.9rem] leading-[0.9] tracking-[-0.06em] text-heading sm:text-[3.6rem]"
}
>
{t.title}
</h1>
<p className="max-w-2xl text-[1rem] leading-8 text-text-muted sm:text-[1.05rem]">
{t.description}
</p>
</div>
<dl className="grid flex-1 gap-4 sm:grid-cols-3">
{stats.map((item) => (
<div
key={item.label}
className="rounded-2xl border border-purple-100 bg-white/80 p-4 text-sm shadow-sm"
>
<dt className="text-gray-500">{item.label}</dt>
<dd className="mt-2 text-2xl font-semibold text-gray-900">
{item.value.toLocaleString(locale)}
</dd>
</div>
))}
<dl className="grid gap-3 sm:grid-cols-3 lg:grid-cols-1">
{stats.map((item) => {
const Icon = item.icon;
return (
<div
key={item.label}
className="rounded-[1.5rem] border border-slate-900/10 bg-white/85 p-4"
>
<div className="flex items-center gap-2 text-slate-600">
<Icon className="h-4 w-4 text-primary" aria-hidden />
<dt className="text-sm font-medium">{item.label}</dt>
</div>
<dd className="mt-3 text-[2rem] font-semibold leading-none tracking-[-0.05em] text-slate-900">
{item.value.toLocaleString(locale)}
</dd>
</div>
);
})}
</dl>
</div>
</section>
)
);
}

View File

@ -1,95 +1,134 @@
'use client'
"use client";
import { useMemo, useState } from 'react'
import Breadcrumbs, { Crumb } from './Breadcrumbs'
import CopyButton from './CopyButton'
import { useLanguage } from '@i18n/LanguageProvider'
import { translations } from '@i18n/translations'
import { formatBytes, formatDate } from '../../lib/format'
import type { DirListing } from '@lib/download/types'
import { useMemo, useState } from "react";
import Breadcrumbs, { type Crumb } from "./Breadcrumbs";
import CopyButton from "./CopyButton";
import { useLanguage } from "@i18n/LanguageProvider";
import { translations } from "@i18n/translations";
import { formatBytes, formatDate } from "@lib/format";
import type { DirListing } from "@lib/download/types";
interface FileTableProps {
listing: DirListing
breadcrumb: Crumb[]
showBreadcrumbs?: boolean
listing: DirListing;
breadcrumb: Crumb[];
showBreadcrumbs?: boolean;
}
export default function FileTable({ listing, breadcrumb, showBreadcrumbs = true }: FileTableProps) {
const { language } = useLanguage()
const locale = language === 'zh' ? 'zh-CN' : 'en-US'
const t = translations[language].download.fileTable
const copyLabel = translations[language].download.copyButton.tooltip
const [sort, setSort] = useState<'name' | 'lastModified' | 'size'>('name')
const [ext, setExt] = useState('')
export default function FileTable({
listing,
breadcrumb,
showBreadcrumbs = true,
}: FileTableProps) {
const { language } = useLanguage();
const locale = language === "zh" ? "zh-CN" : "en-US";
const t = translations[language].download.fileTable;
const copyLabel = translations[language].download.copyButton.tooltip;
const [sort, setSort] = useState<"name" | "lastModified" | "size">("name");
const [ext, setExt] = useState("");
const filtered = useMemo(() => {
return listing.entries
.filter((item) => !ext || item.name.toLowerCase().endsWith(ext.toLowerCase()))
.filter(
(item) => !ext || item.name.toLowerCase().endsWith(ext.toLowerCase()),
)
.sort((a, b) => {
switch (sort) {
case 'lastModified':
return new Date(b.lastModified || 0).getTime() - new Date(a.lastModified || 0).getTime()
case 'size':
return (b.size || 0) - (a.size || 0)
case "lastModified":
return (
new Date(b.lastModified || 0).getTime() -
new Date(a.lastModified || 0).getTime()
);
case "size":
return (b.size || 0) - (a.size || 0);
default:
return a.name.localeCompare(b.name, locale)
return a.name.localeCompare(b.name, locale);
}
})
}, [listing.entries, sort, ext, locale])
});
}, [listing.entries, sort, ext, locale]);
return (
<div>
{showBreadcrumbs && <Breadcrumbs items={breadcrumb} />}
<div className="mb-2 flex flex-wrap gap-2">
<select className="rounded border p-2" value={sort} onChange={(event) => setSort(event.target.value as any)}>
<div className="space-y-4">
{showBreadcrumbs ? <Breadcrumbs items={breadcrumb} /> : null}
<div className="flex flex-col gap-3 rounded-[1.5rem] border border-slate-900/10 bg-[#fcfbf8] p-4 sm:flex-row sm:items-center sm:justify-between">
<select
className="rounded-full border border-slate-900/10 bg-white px-4 py-2 text-sm text-slate-700 outline-none transition focus:border-slate-900/15"
value={sort}
onChange={(event) =>
setSort(event.target.value as "name" | "lastModified" | "size")
}
>
<option value="name">{t.sortName}</option>
<option value="lastModified">{t.sortUpdated}</option>
<option value="size">{t.sortSize}</option>
</select>
<input
className="rounded border p-2"
className="w-full rounded-full border border-slate-900/10 bg-white px-4 py-2 text-sm text-slate-700 outline-none transition focus:border-slate-900/15 sm:max-w-xs"
placeholder={t.filterPlaceholder}
value={ext}
onChange={(event) => setExt(event.target.value)}
/>
</div>
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="py-2 text-left">{t.headers.name}</th>
<th className="w-24 py-2 text-left">{t.headers.size}</th>
<th className="w-48 py-2 text-left">{t.headers.updated}</th>
<th className="w-40 py-2 text-left">{t.headers.actions}</th>
</tr>
</thead>
<tbody>
{filtered.map((item) => {
const downloadUrl = item.href.startsWith('http') ? item.href : `https://dl.svc.plus${item.href}`
return (
<tr key={item.name} className="border-b last:border-0">
<td className="py-1">
<a
href={downloadUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
{item.name}
</a>
</td>
<td className="py-1">{formatBytes(item.size || 0)}</td>
<td className="py-1">{item.lastModified ? formatDate(item.lastModified, locale) : '--'}</td>
<td className="py-1">
<div className="flex flex-wrap gap-2">
<CopyButton text={downloadUrl} label={copyLabel} />
</div>
</td>
</tr>
)
})}
</tbody>
</table>
<div className="overflow-x-auto rounded-[1.6rem] border border-slate-900/10 bg-white/92 shadow-[0_14px_30px_rgba(15,23,42,0.04)]">
<table className="min-w-full text-sm">
<thead className="bg-[#fcfbf8] text-slate-600">
<tr className="border-b border-slate-900/10">
<th className="px-4 py-3 text-left font-semibold">
{t.headers.name}
</th>
<th className="w-28 px-4 py-3 text-left font-semibold">
{t.headers.size}
</th>
<th className="w-52 px-4 py-3 text-left font-semibold">
{t.headers.updated}
</th>
<th className="w-40 px-4 py-3 text-left font-semibold">
{t.headers.actions}
</th>
</tr>
</thead>
<tbody>
{filtered.map((item) => {
const downloadUrl = item.href.startsWith("http")
? item.href
: `https://dl.svc.plus${item.href}`;
return (
<tr
key={item.name}
className="border-b border-slate-900/8 last:border-0"
>
<td className="px-4 py-3">
<a
href={downloadUrl}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-primary transition hover:text-primary-hover hover:underline"
>
{item.name}
</a>
</td>
<td className="px-4 py-3 text-slate-600">
{formatBytes(item.size || 0)}
</td>
<td className="px-4 py-3 text-slate-600">
{item.lastModified
? formatDate(item.lastModified, locale)
: "--"}
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-2">
<CopyButton text={downloadUrl} label={copyLabel} />
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)
);
}