add stable media support and sync skillhub UI

This commit is contained in:
wtgoku
2026-04-08 23:17:42 +08:00
parent ff4f370763
commit 4b20dc5e37
32 changed files with 6269 additions and 393 deletions
+47
View File
@@ -0,0 +1,47 @@
"use client";
import { useEffect, useRef, useState } from "react";
export function CopyButton({
value,
copyLabel,
copiedLabel,
className = "",
}: {
value: string;
copyLabel: string;
copiedLabel: string;
className?: string;
}) {
const [copied, setCopied] = useState(false);
const timeoutRef = useRef<number | null>(null);
useEffect(() => {
return () => {
if (timeoutRef.current) {
window.clearTimeout(timeoutRef.current);
}
};
}, []);
async function handleCopy() {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
if (timeoutRef.current) {
window.clearTimeout(timeoutRef.current);
}
timeoutRef.current = window.setTimeout(() => {
setCopied(false);
}, 1400);
} catch {
setCopied(false);
}
}
return (
<button className={className} onClick={handleCopy} type="button">
{copied ? copiedLabel : copyLabel}
</button>
);
}
+150
View File
@@ -0,0 +1,150 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useRef, useState, useTransition } from "react";
import type { PopiartUser } from "@/lib/popiart-api";
import type { Locale } from "@/lib/site-content";
function replaceLocale(pathname: string, locale: Locale) {
const parts = pathname.split("/");
if (parts[1] === "zh" || parts[1] === "en") {
parts[1] = locale;
return parts.join("/") || `/${locale}`;
}
return `/${locale}${pathname.startsWith("/") ? pathname : `/${pathname}`}`;
}
function userLabel(user: PopiartUser) {
return user.name || user.email || user.id;
}
function initials(user: PopiartUser) {
const source = user.name || user.email || user.id || "PA";
return source.slice(0, 2).toUpperCase();
}
export function HeaderControls({
locale,
user,
labels,
loginHref,
}: {
locale: Locale;
user: PopiartUser | null;
labels: {
zh: string;
en: string;
logout: string;
loggingOut: string;
loginHint: string;
login: string;
};
loginHref: string;
}) {
const pathname = usePathname();
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
function handlePointerDown(event: MouseEvent) {
if (!menuRef.current?.contains(event.target as Node)) {
setMenuOpen(false);
}
}
function handleEscape(event: KeyboardEvent) {
if (event.key === "Escape") {
setMenuOpen(false);
}
}
window.addEventListener("mousedown", handlePointerDown);
window.addEventListener("keydown", handleEscape);
return () => {
window.removeEventListener("mousedown", handlePointerDown);
window.removeEventListener("keydown", handleEscape);
};
}, []);
function switchLocale(target: Locale) {
setMenuOpen(false);
router.push(replaceLocale(pathname, target));
}
function logout() {
startTransition(async () => {
await fetch("/api/auth/logout", {
method: "POST",
});
router.refresh();
});
}
return (
<div className="header-actions">
<div className="locale-switch" ref={menuRef}>
<button
aria-expanded={menuOpen}
aria-haspopup="menu"
className={`locale-menu-button ${menuOpen ? "locale-menu-button-open" : ""}`}
onClick={() => setMenuOpen((open) => !open)}
type="button"
>
<span>{locale === "zh" ? labels.zh : labels.en}</span>
<span aria-hidden="true" className={`locale-menu-chevron ${menuOpen ? "locale-menu-chevron-open" : ""}`}>
</span>
</button>
{menuOpen ? (
<div className="locale-menu-list" role="menu">
<button
aria-pressed={locale === "zh"}
className={`locale-menu-item ${locale === "zh" ? "locale-menu-item-active" : ""}`}
onClick={() => switchLocale("zh")}
role="menuitemradio"
type="button"
>
{labels.zh}
</button>
<button
aria-pressed={locale === "en"}
className={`locale-menu-item ${locale === "en" ? "locale-menu-item-active" : ""}`}
onClick={() => switchLocale("en")}
role="menuitemradio"
type="button"
>
{labels.en}
</button>
</div>
) : null}
</div>
{user ? (
<div className="auth-cluster">
<Link className="session-pill" href={`/${locale}/console`} title={userLabel(user)}>
<span className="session-avatar">{initials(user)}</span>
<span className="session-name">{userLabel(user)}</span>
</Link>
<button
className="button button-light button-small button-auth-stable"
disabled={isPending}
onClick={logout}
type="button"
>
{isPending ? labels.loggingOut : labels.logout}
</button>
</div>
) : (
<div className="auth-cluster">
<Link className="button button-light button-small button-auth-stable" href={loginHref}>
{labels.login}
</Link>
</div>
)}
</div>
);
}
+98
View File
@@ -0,0 +1,98 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import type { Locale } from "@/lib/site-content";
type ApiResponse = {
ok: boolean;
error?: {
message?: string;
};
};
export function LoginForm({
locale,
labels,
}: {
locale: Locale;
labels: {
fieldLabel: string;
fieldHint: string;
submit: string;
submitting: string;
helperTitle: string;
helperBody: string;
commandLabel: string;
invalidKey: string;
};
}) {
const router = useRouter();
const [key, setKey] = useState("");
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const trimmed = key.trim();
if (!trimmed) {
setError(labels.invalidKey);
return;
}
startTransition(async () => {
setError(null);
let payload: ApiResponse | null = null;
try {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ key: trimmed }),
});
payload = (await response.json()) as ApiResponse;
if (!response.ok || !payload.ok) {
setError(payload.error?.message ?? labels.invalidKey);
return;
}
router.push(`/${locale}/console`);
router.refresh();
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : labels.invalidKey);
}
});
}
return (
<div className="login-form-stack">
<form className="auth-form" onSubmit={handleSubmit}>
<label className="field-label" htmlFor="popinewapi-key">
{labels.fieldLabel}
</label>
<input
aria-invalid={error ? "true" : "false"}
autoComplete="off"
className="text-input"
id="popinewapi-key"
inputMode="text"
onChange={(event) => setKey(event.target.value)}
placeholder="pk_live_..."
spellCheck={false}
type="password"
value={key}
/>
<p className="field-hint">{labels.fieldHint}</p>
{error ? <div className="status-banner status-banner-error">{error}</div> : null}
<button className="button button-dark" disabled={isPending} type="submit">
{isPending ? labels.submitting : labels.submit}
</button>
</form>
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import type { Locale } from "@/lib/site-content";
function localize(locale: Locale, path: string) {
if (!path || path === "/") {
return `/${locale}`;
}
return `/${locale}${path}`;
}
function isActive(pathname: string, href: string) {
if (href === "/") {
return pathname === href;
}
return pathname === href || pathname.startsWith(`${href}/`);
}
export function MainNav({
locale,
labels,
}: {
locale: Locale;
labels: {
home: string;
docs: string;
skills: string;
console: string;
pricing: string;
};
}) {
const pathname = usePathname();
const items = [
{ href: localize(locale, "/"), label: labels.home },
{ href: localize(locale, "/docs"), label: labels.docs },
{ href: localize(locale, "/skills"), label: labels.skills },
{ href: localize(locale, "/console"), label: labels.console },
{ href: localize(locale, "/pricing"), label: labels.pricing },
];
return (
<nav className="main-nav" aria-label="Primary">
{items.map((item) => (
<Link
aria-current={isActive(pathname, item.href) ? "page" : undefined}
className={`nav-link ${isActive(pathname, item.href) ? "nav-link-active" : ""}`}
href={item.href}
key={item.href}
>
{item.label}
</Link>
))}
</nav>
);
}
+32 -26
View File
@@ -1,4 +1,8 @@
import Link from "next/link";
import { HeaderControls } from "@/components/header-controls";
import { MainNav } from "@/components/main-nav";
import { getViewerSession } from "@/lib/popiart-api";
import { getLiveCopy } from "@/lib/live-copy";
import type { AppDictionary, Locale } from "@/lib/site-content";
function localize(locale: Locale, path: string) {
@@ -8,7 +12,7 @@ function localize(locale: Locale, path: string) {
return `/${locale}${path}`;
}
export function SiteChrome({
export async function SiteChrome({
children,
dictionary,
locale,
@@ -17,6 +21,9 @@ export function SiteChrome({
dictionary: AppDictionary;
locale: Locale;
}) {
const liveCopy = getLiveCopy(locale);
const session = await getViewerSession();
return (
<div className="site-shell">
<header className="site-header">
@@ -30,32 +37,30 @@ export function SiteChrome({
</div>
</div>
<nav className="main-nav">
<Link href={localize(locale, "/")}>{dictionary.nav.home}</Link>
<Link href={localize(locale, "/docs")}>{dictionary.nav.docs}</Link>
<Link href={localize(locale, "/console")}>{dictionary.nav.console}</Link>
<Link href={localize(locale, "/pricing")}>{dictionary.nav.pricing}</Link>
</nav>
<MainNav
labels={{
home: dictionary.nav.home,
docs: dictionary.nav.docs,
skills: liveCopy.nav.skills,
console: dictionary.nav.console,
pricing: dictionary.nav.pricing,
}}
locale={locale}
/>
<div className="header-actions">
<div className="locale-switch">
<Link
className={locale === "zh" ? "locale-active" : ""}
href={localize("zh", "/")}
>
</Link>
<Link
className={locale === "en" ? "locale-active" : ""}
href={localize("en", "/")}
>
EN
</Link>
</div>
<Link className="button button-light button-small" href={localize(locale, "/login")}>
{dictionary.nav.login}
</Link>
</div>
<HeaderControls
labels={{
zh: liveCopy.header.zh,
en: liveCopy.header.en,
logout: liveCopy.header.logout,
loggingOut: liveCopy.header.loggingOut,
loginHint: liveCopy.header.loginHint,
login: dictionary.nav.login,
}}
locale={locale}
loginHref={localize(locale, "/login")}
user={session?.user ?? null}
/>
</header>
<main className="main-content">{children}</main>
@@ -67,6 +72,7 @@ export function SiteChrome({
</div>
<div className="footer-links">
<Link href={localize(locale, "/docs")}>{dictionary.nav.docs}</Link>
<Link href={localize(locale, "/skills")}>{liveCopy.nav.skills}</Link>
<Link href={localize(locale, "/pricing")}>{dictionary.nav.pricing}</Link>
<Link href={localize(locale, "/console")}>{dictionary.nav.console}</Link>
</div>