add stable media support and sync skillhub UI
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user