refactor(public-pages): unify download blogs and auth styling
This commit is contained in:
parent
3aee5aa0bb
commit
be30303bc8
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user