add stable media support and sync skillhub UI
This commit is contained in:
@@ -1,5 +1,34 @@
|
||||
import Link from "next/link";
|
||||
import { getDictionary, type Locale } from "@/lib/site-content";
|
||||
import { CopyButton } from "@/components/copy-button";
|
||||
import { PopiartApiError, getBudgetSummary, getBudgetUsage, getPopiartEndpoint, getViewerSession } from "@/lib/popiart-api";
|
||||
import { getLiveCopy } from "@/lib/live-copy";
|
||||
import { type Locale } from "@/lib/site-content";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function formatNumber(locale: Locale, value: number) {
|
||||
return new Intl.NumberFormat(locale === "zh" ? "zh-CN" : "en-US").format(value);
|
||||
}
|
||||
|
||||
function maskSecret(value?: string) {
|
||||
if (!value) {
|
||||
return "Unavailable";
|
||||
}
|
||||
if (value.length <= 10) {
|
||||
return value;
|
||||
}
|
||||
return `${value.slice(0, 8)} • • • • ${value.slice(-4)}`;
|
||||
}
|
||||
|
||||
function formatError(error: unknown) {
|
||||
if (error instanceof PopiartApiError) {
|
||||
return error.message;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
export default async function ConsolePage({
|
||||
params,
|
||||
@@ -7,109 +36,167 @@ export default async function ConsolePage({
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
const dictionary = getDictionary(locale as Locale);
|
||||
const typedLocale = locale as Locale;
|
||||
const liveCopy = getLiveCopy(typedLocale);
|
||||
const session = await getViewerSession();
|
||||
const isZh = typedLocale === "zh";
|
||||
|
||||
const pageTitle = isZh ? "控制台" : "Console";
|
||||
const pageSubtitle = isZh
|
||||
? "管理产品层密钥、查看用量、快速接入 CLI"
|
||||
: "Manage product-layer keys, usage, and quick CLI onboarding.";
|
||||
const remainingLabel = isZh ? "剩余 Credit" : "Remaining credits";
|
||||
const remainingHint = isZh ? "总计可用额度" : "Total available quota";
|
||||
const usedLabel = isZh ? "已使用" : "Used";
|
||||
const usedHint = isZh ? "本月累计消耗" : "Consumed this month";
|
||||
const callsLabel = isZh ? "调用次数" : "API calls";
|
||||
const callsHint = isZh ? "本月 CLI / API 调用" : "CLI / API calls this month";
|
||||
const keysTitle = isZh ? "API 密钥" : "API keys";
|
||||
const quickTitle = isZh ? "快速安装指令" : "Quick install commands";
|
||||
const quickBody = isZh
|
||||
? "把下面这段命令复制到终端,完成 CLI 安装、登录和验证。"
|
||||
: "Copy these commands into your terminal to install the CLI, sign in, and verify the workflow.";
|
||||
const sessionKeyLabel = "POPIART_SESSION_KEY";
|
||||
const endpointLabel = "POPIART_ENDPOINT";
|
||||
const copyLabel = isZh ? "复制" : "Copy";
|
||||
const copiedLabel = isZh ? "已复制" : "Copied";
|
||||
const loginCta = isZh ? "去登录" : "Sign in";
|
||||
const docsCta = isZh ? "查看文档" : "Open docs";
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<section className="section-panel console-hero">
|
||||
<div className="section-heading">
|
||||
<h1>{pageTitle}</h1>
|
||||
<p>{pageSubtitle}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-panel console-surface-card">
|
||||
<div className="section-heading compact">
|
||||
<h2>{liveCopy.console.unauthenticatedTitle}</h2>
|
||||
<p>{liveCopy.console.unauthenticatedBody}</p>
|
||||
</div>
|
||||
<div className="hero-actions">
|
||||
<Link className="button button-dark" href={`/${locale}/login`}>
|
||||
{loginCta}
|
||||
</Link>
|
||||
<Link className="button button-light" href={`/${locale}/docs`}>
|
||||
{docsCta}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const [budgetResult, usageResult] = await Promise.allSettled([getBudgetSummary(), getBudgetUsage()]);
|
||||
const budget = budgetResult.status === "fulfilled" ? budgetResult.value : null;
|
||||
const usage = usageResult.status === "fulfilled" ? usageResult.value : null;
|
||||
const loadErrors = [budgetResult, usageResult]
|
||||
.filter((result) => result.status === "rejected")
|
||||
.map((result) => formatError((result as PromiseRejectedResult).reason));
|
||||
|
||||
const metrics = [
|
||||
{
|
||||
label: remainingLabel,
|
||||
value: budget ? formatNumber(typedLocale, budget.remaining.tokens) : "--",
|
||||
hint: remainingHint,
|
||||
},
|
||||
{
|
||||
label: usedLabel,
|
||||
value: budget ? formatNumber(typedLocale, budget.used.tokens) : "--",
|
||||
hint: usedHint,
|
||||
},
|
||||
{
|
||||
label: callsLabel,
|
||||
value: usage ? formatNumber(typedLocale, usage.total.job_count) : "--",
|
||||
hint: callsHint,
|
||||
},
|
||||
];
|
||||
|
||||
const quickStart = [
|
||||
"brew tap wtgoku-create/popi",
|
||||
"brew install wtgoku-create/popi/popiart",
|
||||
`export POPIART_ENDPOINT=${getPopiartEndpoint()}`,
|
||||
"popiart auth login --key <your-popinewapi-key>",
|
||||
"popiart skills list",
|
||||
].join("\n");
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<section className="section-panel">
|
||||
<div className="dashboard-top">
|
||||
<div>
|
||||
<div className="eyebrow">{dictionary.console.tag}</div>
|
||||
<h1>{dictionary.console.title}</h1>
|
||||
<p>{dictionary.console.subtitle}</p>
|
||||
</div>
|
||||
<div className="dashboard-actions">
|
||||
<Link className="button button-light" href={`/${locale}/pricing`}>
|
||||
{dictionary.console.billingCta}
|
||||
</Link>
|
||||
<Link className="button button-dark" href={`/${locale}/login`}>
|
||||
{dictionary.console.loginCta}
|
||||
</Link>
|
||||
</div>
|
||||
<section className="section-panel console-hero">
|
||||
<div className="section-heading">
|
||||
<h1>{pageTitle}</h1>
|
||||
<p>{pageSubtitle}</p>
|
||||
</div>
|
||||
{loadErrors.length > 0 ? (
|
||||
<div className="status-banner status-banner-error">
|
||||
<strong>{liveCopy.console.loadErrorPrefix}</strong>
|
||||
<span>{loadErrors.join(" | ")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<div className="stats-grid">
|
||||
{dictionary.console.metrics.map((metric) => (
|
||||
<article className="stat-card" key={metric.label}>
|
||||
<strong>{metric.value}</strong>
|
||||
<span>{metric.label}</span>
|
||||
</article>
|
||||
))}
|
||||
<section className="console-metrics-grid">
|
||||
{metrics.map((metric) => (
|
||||
<article className="console-metric-card" key={metric.label}>
|
||||
<span>{metric.label}</span>
|
||||
<strong>{metric.value}</strong>
|
||||
<small>{metric.hint}</small>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="console-surface-card">
|
||||
<div className="section-heading compact">
|
||||
<h2>{keysTitle}</h2>
|
||||
</div>
|
||||
<div className="console-key-list">
|
||||
<div className="console-key-row">
|
||||
<div className="console-key-copy">
|
||||
<div>
|
||||
<strong>{sessionKeyLabel}</strong>
|
||||
<span>{maskSecret(session.session_key)}</span>
|
||||
</div>
|
||||
{session.session_key ? (
|
||||
<CopyButton
|
||||
className="button button-light button-small console-copy-button"
|
||||
copiedLabel={copiedLabel}
|
||||
copyLabel={copyLabel}
|
||||
value={session.session_key}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="console-key-row">
|
||||
<div className="console-key-copy">
|
||||
<div>
|
||||
<strong>{endpointLabel}</strong>
|
||||
<span>{getPopiartEndpoint()}</span>
|
||||
</div>
|
||||
<CopyButton
|
||||
className="button button-light button-small console-copy-button"
|
||||
copiedLabel={copiedLabel}
|
||||
copyLabel={copyLabel}
|
||||
value={getPopiartEndpoint()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="dashboard-grid">
|
||||
<article className="dashboard-card">
|
||||
<div className="section-heading compact">
|
||||
<div className="eyebrow">{dictionary.console.skillsTag}</div>
|
||||
<h2>{dictionary.console.skillsTitle}</h2>
|
||||
</div>
|
||||
<div className="table-list">
|
||||
{dictionary.console.officialSkills.map((skill) => (
|
||||
<div className="table-row" key={skill.name}>
|
||||
<div>
|
||||
<strong>{skill.name}</strong>
|
||||
<span>{skill.routeKey}</span>
|
||||
</div>
|
||||
<p>{skill.status}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="dashboard-card">
|
||||
<div className="section-heading compact">
|
||||
<div className="eyebrow">{dictionary.console.keysTag}</div>
|
||||
<h2>{dictionary.console.keysTitle}</h2>
|
||||
</div>
|
||||
<div className="table-list">
|
||||
{dictionary.console.apiKeys.map((key) => (
|
||||
<div className="table-row" key={key.name}>
|
||||
<div>
|
||||
<strong>{key.name}</strong>
|
||||
<span>{key.masked}</span>
|
||||
</div>
|
||||
<p>{key.scope}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="dashboard-grid">
|
||||
<article className="dashboard-card">
|
||||
<div className="section-heading compact">
|
||||
<div className="eyebrow">{dictionary.console.usageTag}</div>
|
||||
<h2>{dictionary.console.usageTitle}</h2>
|
||||
</div>
|
||||
<div className="table-list">
|
||||
{dictionary.console.usageRows.map((row) => (
|
||||
<div className="table-row" key={row.name}>
|
||||
<div>
|
||||
<strong>{row.name}</strong>
|
||||
<span>{row.count}</span>
|
||||
</div>
|
||||
<p>{row.cost}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="dashboard-card billing-card">
|
||||
<div className="section-heading compact">
|
||||
<div className="eyebrow">{dictionary.console.planTag}</div>
|
||||
<h2>{dictionary.console.planTitle}</h2>
|
||||
</div>
|
||||
<p className="billing-copy">{dictionary.console.planDescription}</p>
|
||||
<ul className="bullet-list">
|
||||
{dictionary.console.planBenefits.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link className="button button-dark" href={`/${locale}/pricing`}>
|
||||
{dictionary.console.planCta}
|
||||
</Link>
|
||||
</article>
|
||||
<section className="console-surface-card">
|
||||
<div className="section-heading compact">
|
||||
<h2>{quickTitle}</h2>
|
||||
<p>{quickBody}</p>
|
||||
</div>
|
||||
<div className="console-code-block">
|
||||
<pre>
|
||||
<code>{quickStart}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import Link from "next/link";
|
||||
import { getDictionary, type Locale } from "@/lib/site-content";
|
||||
import { LoginForm } from "@/components/login-form";
|
||||
import { getViewerSession } from "@/lib/popiart-api";
|
||||
import { getLiveCopy } from "@/lib/live-copy";
|
||||
import { type Locale } from "@/lib/site-content";
|
||||
|
||||
export default async function LoginPage({
|
||||
params,
|
||||
@@ -7,39 +10,82 @@ export default async function LoginPage({
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
const dictionary = getDictionary(locale as Locale);
|
||||
const typedLocale = locale as Locale;
|
||||
const liveCopy = getLiveCopy(typedLocale);
|
||||
const session = await getViewerSession();
|
||||
const isZh = typedLocale === "zh";
|
||||
|
||||
const title = isZh ? "使用 PopiNewAPI Key 登录" : "Sign in with your PopiNewAPI key";
|
||||
const subtitle = isZh
|
||||
? "页面登录后会换取产品层 session,控制台与 CLI 共用同一条认证链路。"
|
||||
: "The web app exchanges your key for a product-layer session shared by the console and CLI.";
|
||||
const cliTitle = isZh ? "CLI 登录指引" : "CLI sign-in guide";
|
||||
const cliBody = isZh
|
||||
? "如果你更习惯终端工作流,可以先安装 popiartcli,再用同一个 key 完成登录。"
|
||||
: "If you prefer terminal-first workflows, install popiartcli and sign in with the same key.";
|
||||
const installTitle = isZh ? "推荐命令" : "Recommended commands";
|
||||
const continueCta = isZh ? "进入控制台" : "Open console";
|
||||
|
||||
const cliCommands = [
|
||||
"brew tap wtgoku-create/popi",
|
||||
"brew install wtgoku-create/popi/popiart",
|
||||
"popiart auth login --key <your-popinewapi-key>",
|
||||
"popiart skills list",
|
||||
].join("\n");
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<section className="login-shell">
|
||||
<div className="login-panel">
|
||||
<div className="eyebrow">{dictionary.login.tag}</div>
|
||||
<h1>{dictionary.login.title}</h1>
|
||||
<p>{dictionary.login.subtitle}</p>
|
||||
<div className="login-actions">
|
||||
<button className="button button-dark" type="button">
|
||||
{dictionary.login.primaryProvider}
|
||||
</button>
|
||||
<button className="button button-light" type="button">
|
||||
{dictionary.login.secondaryProvider}
|
||||
</button>
|
||||
</div>
|
||||
<p className="legal-copy">
|
||||
{dictionary.login.termsPrefix}{" "}
|
||||
<Link href={`/${locale}/pricing`}>{dictionary.login.termsLink}</Link>{" "}
|
||||
{dictionary.login.and}{" "}
|
||||
<Link href={`/${locale}/docs`}>{dictionary.login.privacyLink}</Link>
|
||||
</p>
|
||||
<section className="login-shell login-shell-focused">
|
||||
<div className="login-panel login-panel-plain">
|
||||
<div className="eyebrow">{isZh ? "SIGN IN" : "SIGN IN"}</div>
|
||||
<h1>{title}</h1>
|
||||
<p>{subtitle}</p>
|
||||
{session ? (
|
||||
<div className="login-form-stack">
|
||||
<div className="status-banner">
|
||||
<strong>{session.user.name || session.user.email || session.user.id}</strong>
|
||||
<span>{liveCopy.login.alreadySignedIn}</span>
|
||||
</div>
|
||||
<Link className="button button-dark" href={`/${locale}/console`}>
|
||||
{continueCta}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<LoginForm
|
||||
labels={{
|
||||
fieldLabel: liveCopy.login.fieldLabel,
|
||||
fieldHint: liveCopy.login.fieldHint,
|
||||
submit: liveCopy.login.submit,
|
||||
submitting: liveCopy.login.submitting,
|
||||
helperTitle: liveCopy.login.helperTitle,
|
||||
helperBody: liveCopy.login.helperBody,
|
||||
commandLabel: liveCopy.login.commandLabel,
|
||||
invalidKey: liveCopy.login.invalidKey,
|
||||
}}
|
||||
locale={typedLocale}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="login-benefits">
|
||||
{dictionary.login.benefits.map((benefit) => (
|
||||
<article className="benefit-card" key={benefit.title}>
|
||||
<span className="card-kicker">{benefit.kicker}</span>
|
||||
<h2>{benefit.title}</h2>
|
||||
<p>{benefit.description}</p>
|
||||
</article>
|
||||
))}
|
||||
<div className="login-guide-panel">
|
||||
<div className="section-heading compact">
|
||||
<div className="eyebrow">{isZh ? "CLI" : "CLI"}</div>
|
||||
<h2>{cliTitle}</h2>
|
||||
<p>{cliBody}</p>
|
||||
</div>
|
||||
<div className="inline-note">
|
||||
<strong>{installTitle}</strong>
|
||||
<p>{liveCopy.login.helperBody}</p>
|
||||
</div>
|
||||
<div className="code-card login-guide-code">
|
||||
<span className="card-kicker">{liveCopy.login.commandLabel}</span>
|
||||
<pre>
|
||||
<code>{cliCommands}</code>
|
||||
</pre>
|
||||
</div>
|
||||
<Link className="button button-light" href={`/${locale}/docs`}>
|
||||
{isZh ? "查看文档" : "Read docs"}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
+167
-38
@@ -7,17 +7,48 @@ export default async function HomePage({
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
const dictionary = getDictionary(locale as Locale);
|
||||
const typedLocale = locale as Locale;
|
||||
const dictionary = getDictionary(typedLocale);
|
||||
const isZh = typedLocale === "zh";
|
||||
|
||||
const integrationTitle = isZh ? "popiartcli 接入原生流程" : "Native integration flow for popiartcli";
|
||||
const integrationSubtitle = isZh
|
||||
? "按照以下步骤,将 popiartcli 无缝接入你的 agent 工作流"
|
||||
: "Follow these steps to plug popiartcli directly into your coding agent workflow.";
|
||||
const stepOneTitle = isZh ? "复制安装指令到你的环境" : "Copy the install command into your environment";
|
||||
const stepOneHint = isZh
|
||||
? "先安装 CLI,再登录获取产品层 key"
|
||||
: "Install the CLI first, then sign in to get the product-layer key.";
|
||||
const stepTwoTitle = isZh
|
||||
? "执行 bootstrap,让 PopiArt 在你的 agent 环境中可发现"
|
||||
: "Run bootstrap so PopiArt becomes discoverable in your agent environment.";
|
||||
const stepTwoHint = isZh
|
||||
? "生成 completion、默认 discovery profile,并接入 Codex / OpenCode / OpenClaw"
|
||||
: "Generate completion, default discovery profiles, and wire into Codex, OpenCode, or OpenClaw.";
|
||||
const stepThreeTitle = isZh
|
||||
? "如需轮换或重置产品层 token,请前往控制台管理密钥"
|
||||
: "Rotate or reset the product-layer token from the console when needed.";
|
||||
const stepThreeHint = isZh
|
||||
? "使用 API key 与 session,而不是把 provider 密钥直接分发到本地环境"
|
||||
: "Use API keys and sessions instead of distributing raw provider credentials to local machines.";
|
||||
const stepFourTitle = isZh
|
||||
? "了解更多功能及服务使用须知,请查看开发者文档"
|
||||
: "Read the developer docs for capabilities, runtime behavior, and usage guidance.";
|
||||
const docsAction = isZh ? "查看开发者文档" : "Open developer docs";
|
||||
const installAction = isZh ? "登录" : "Sign in";
|
||||
const bootstrapAction = isZh ? "查看 Quick Start" : "Open Quick Start";
|
||||
const consoleAction = isZh ? "前往控制台" : "Open console";
|
||||
const sceneAction = isZh ? "了解更多" : "Learn more";
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<section className="hero-panel">
|
||||
<section className="hero-panel hero-panel-reference">
|
||||
<div className="hero-copy">
|
||||
<div className="eyebrow">{dictionary.home.tag}</div>
|
||||
<h1>{dictionary.home.title}</h1>
|
||||
<p className="hero-description">{dictionary.home.subtitle}</p>
|
||||
<div className="hero-actions">
|
||||
<Link className="button button-dark" href={`/${locale}/login`}>
|
||||
<Link className="button button-dark" href={`/${locale}#integration`}>
|
||||
{dictionary.home.primaryCta}
|
||||
</Link>
|
||||
<Link className="button button-light" href={`/${locale}/docs`}>
|
||||
@@ -29,19 +60,21 @@ export default async function HomePage({
|
||||
<span>{dictionary.home.socialProof}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hero-stage">
|
||||
<div className="stage-card stage-card-primary">
|
||||
<div className="hero-stage hero-stage-reference">
|
||||
<div className="hero-pattern" aria-hidden="true">
|
||||
{Array.from({ length: 5 }).map((_, row) => (
|
||||
<div className="hero-pattern-row" key={row}>
|
||||
{dictionary.home.heroPhrases.map((phrase) => (
|
||||
<span key={`${row}-${phrase}`}>{phrase}</span>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="stage-card stage-card-floating stage-card-primary">
|
||||
<div className="stage-label">{dictionary.home.stageLabel}</div>
|
||||
<div className="stage-stat">{dictionary.home.stageValue}</div>
|
||||
<p>{dictionary.home.stageDescription}</p>
|
||||
</div>
|
||||
<div className="stage-card">
|
||||
<div className="stage-mini-grid">
|
||||
{dictionary.home.heroPhrases.map((phrase) => (
|
||||
<span key={phrase}>{phrase}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -52,43 +85,139 @@ export default async function HomePage({
|
||||
<p>{dictionary.home.scenesSubtitle}</p>
|
||||
</div>
|
||||
<div className="card-grid card-grid-three">
|
||||
{dictionary.home.scenes.map((scene) => (
|
||||
<article className="feature-card" key={scene.name}>
|
||||
{dictionary.home.scenes.map((scene, index) => (
|
||||
<article className="feature-card scene-card" key={scene.name}>
|
||||
<div className={`scene-visual scene-visual-${(index % 6) + 1}`}>
|
||||
<span className="card-kicker">{scene.category}</span>
|
||||
</div>
|
||||
<span className="card-kicker">{scene.category}</span>
|
||||
<h3>{scene.name}</h3>
|
||||
<p>{scene.description}</p>
|
||||
<Link className="scene-link" href={`/${locale}/docs`}>
|
||||
{sceneAction}
|
||||
</Link>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="integration-shell" id="integration">
|
||||
<div className="integration-heading">
|
||||
<div className="eyebrow">{dictionary.home.flowTag}</div>
|
||||
<h2>{integrationTitle}</h2>
|
||||
<p>{integrationSubtitle}</p>
|
||||
</div>
|
||||
<div className="integration-steps">
|
||||
<article className="integration-step-card">
|
||||
<div className="integration-step-head">
|
||||
<span className="integration-step-dot" aria-hidden="true" />
|
||||
<span className="integration-step-label">STEP 1</span>
|
||||
<h3>{stepOneTitle}</h3>
|
||||
</div>
|
||||
<div className="integration-step-body">
|
||||
<pre>
|
||||
<code>{dictionary.home.installMethods[0]?.command}</code>
|
||||
</pre>
|
||||
<div className="integration-method-pills">
|
||||
{dictionary.home.installMethods.map((method) => (
|
||||
<span className="pill" key={method.name}>
|
||||
{method.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="integration-step-actions">
|
||||
<span>{stepOneHint}</span>
|
||||
<Link className="button button-dark" href={`/${locale}/login`}>
|
||||
{installAction}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="integration-step-card">
|
||||
<div className="integration-step-head">
|
||||
<span className="integration-step-dot" aria-hidden="true" />
|
||||
<span className="integration-step-label">STEP 2</span>
|
||||
<h3>{stepTwoTitle}</h3>
|
||||
</div>
|
||||
<div className="integration-step-body">
|
||||
<pre>
|
||||
<code>{dictionary.home.bootstrapCommand}</code>
|
||||
</pre>
|
||||
<div className="integration-step-actions">
|
||||
<span>{stepTwoHint}</span>
|
||||
<Link className="button button-light integration-docs-link" href={`/${locale}/docs`}>
|
||||
{bootstrapAction}
|
||||
<span aria-hidden="true">→</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="integration-step-card">
|
||||
<div className="integration-step-head">
|
||||
<span className="integration-step-dot" aria-hidden="true" />
|
||||
<span className="integration-step-label">STEP 3</span>
|
||||
<h3>{stepThreeTitle}</h3>
|
||||
</div>
|
||||
<div className="integration-step-body integration-step-body-compact">
|
||||
<div className="integration-step-actions">
|
||||
<span>{stepThreeHint}</span>
|
||||
<Link className="button button-light integration-docs-link" href={`/${locale}/console`}>
|
||||
{consoleAction}
|
||||
<span aria-hidden="true">→</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="integration-step-card">
|
||||
<div className="integration-step-head">
|
||||
<span className="integration-step-dot" aria-hidden="true" />
|
||||
<span className="integration-step-label">STEP 4</span>
|
||||
<h3>{stepFourTitle}</h3>
|
||||
</div>
|
||||
<div className="integration-step-body integration-step-body-compact">
|
||||
<Link className="button button-light integration-docs-link" href={`/${locale}/docs`}>
|
||||
{docsAction}
|
||||
<span aria-hidden="true">→</span>
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-panel section-panel-accent">
|
||||
<div className="section-heading">
|
||||
<div className="eyebrow">{dictionary.home.flowTag}</div>
|
||||
<h2>{dictionary.home.flowTitle}</h2>
|
||||
<p>{dictionary.home.flowSubtitle}</p>
|
||||
<div className="eyebrow">{dictionary.pricing.tag}</div>
|
||||
<h2>{dictionary.pricing.title}</h2>
|
||||
<p>{dictionary.pricing.subtitle}</p>
|
||||
</div>
|
||||
<div className="steps-grid">
|
||||
{dictionary.home.flowSteps.map((step, index) => (
|
||||
<article className="step-card" key={step.title}>
|
||||
<div className="step-number">0{index + 1}</div>
|
||||
<h3>{step.title}</h3>
|
||||
<p>{step.description}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-panel">
|
||||
<div className="section-heading">
|
||||
<div className="eyebrow">{dictionary.home.trustTag}</div>
|
||||
<h2>{dictionary.home.trustTitle}</h2>
|
||||
</div>
|
||||
<div className="stats-grid">
|
||||
{dictionary.home.trustStats.map((stat) => (
|
||||
<article className="stat-card" key={stat.label}>
|
||||
<strong>{stat.value}</strong>
|
||||
<span>{stat.label}</span>
|
||||
<div className="pricing-grid">
|
||||
{dictionary.pricing.plans.map((plan) => (
|
||||
<article
|
||||
className={`pricing-card ${plan.highlight ? "pricing-card-highlight" : ""}`}
|
||||
key={plan.name}
|
||||
>
|
||||
<div className="pricing-top">
|
||||
<div>
|
||||
<div className="card-kicker">{plan.badge}</div>
|
||||
<h3>{plan.name}</h3>
|
||||
<p className="pricing-summary">{plan.summary}</p>
|
||||
</div>
|
||||
<div className="price-line">
|
||||
<strong>{plan.price}</strong>
|
||||
<span>{plan.cadence}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="bullet-list">
|
||||
{plan.features.map((feature) => (
|
||||
<li key={feature}>{feature}</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link className={`button ${plan.highlight ? "button-dark" : "button-light"}`} href={`/${locale}/pricing`}>
|
||||
{plan.cta}
|
||||
</Link>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import Link from "next/link";
|
||||
import {
|
||||
PopiartApiError,
|
||||
getSkillsCatalog,
|
||||
getViewerSession,
|
||||
} from "@/lib/popiart-api";
|
||||
import { getLiveCopy } from "@/lib/live-copy";
|
||||
import { type Locale } from "@/lib/site-content";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function formatError(error: unknown) {
|
||||
if (error instanceof PopiartApiError) {
|
||||
return error.message;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
export default async function SkillsPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
searchParams: Promise<{ search?: string | string[] }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
const rawSearch = (await searchParams).search;
|
||||
const search = Array.isArray(rawSearch) ? rawSearch[0] : rawSearch;
|
||||
const typedLocale = locale as Locale;
|
||||
const liveCopy = getLiveCopy(typedLocale);
|
||||
const session = await getViewerSession();
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<section className="section-panel hero-tight">
|
||||
<div className="section-heading">
|
||||
<div className="eyebrow">{liveCopy.skills.tag}</div>
|
||||
<h1>{liveCopy.skills.title}</h1>
|
||||
<p>{liveCopy.skills.subtitle}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-panel empty-state-card">
|
||||
<div className="section-heading compact">
|
||||
<h2>{liveCopy.skills.unauthenticatedTitle}</h2>
|
||||
<p>{liveCopy.skills.unauthenticatedBody}</p>
|
||||
</div>
|
||||
<div className="hero-actions">
|
||||
<Link className="button button-dark" href={`/${locale}/login`}>
|
||||
{liveCopy.skills.loginCta}
|
||||
</Link>
|
||||
<Link className="button button-light" href={`/${locale}/docs`}>
|
||||
{liveCopy.skills.docsCta}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let catalog = null;
|
||||
let loadError: string | null = null;
|
||||
|
||||
try {
|
||||
catalog = await getSkillsCatalog(search?.trim());
|
||||
} catch (error) {
|
||||
loadError = formatError(error);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<section className="section-panel hero-tight">
|
||||
<div className="section-heading">
|
||||
<div className="eyebrow">{liveCopy.skills.tag}</div>
|
||||
<h1>{liveCopy.skills.title}</h1>
|
||||
<p>{liveCopy.skills.subtitle}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-panel">
|
||||
<form className="search-form" method="get">
|
||||
<label className="field-label" htmlFor="skill-search">
|
||||
{liveCopy.skills.searchLabel}
|
||||
</label>
|
||||
<div className="search-row">
|
||||
<input
|
||||
className="text-input"
|
||||
defaultValue={search ?? ""}
|
||||
id="skill-search"
|
||||
name="search"
|
||||
placeholder={liveCopy.skills.searchPlaceholder}
|
||||
type="search"
|
||||
/>
|
||||
<button className="button button-dark" type="submit">
|
||||
{liveCopy.skills.searchAction}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{loadError ? (
|
||||
<div className="status-banner status-banner-error">
|
||||
<strong>{liveCopy.skills.loadErrorPrefix}</strong>
|
||||
<span>{loadError}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="pill-row">
|
||||
<span className="pill">
|
||||
{liveCopy.skills.resultsPrefix}: {catalog?.total ?? 0}
|
||||
</span>
|
||||
<span className="pill">{session.user.name || session.user.email || session.user.id}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="catalog-grid">
|
||||
{catalog && catalog.items.length > 0 ? (
|
||||
catalog.items.map((skill) => (
|
||||
<article className="feature-card skill-card" key={skill.id}>
|
||||
<span className="card-kicker">{skill.version}</span>
|
||||
<h3>{skill.name}</h3>
|
||||
<p>{skill.description}</p>
|
||||
<dl className="meta-grid">
|
||||
<div>
|
||||
<dt>{liveCopy.skills.routeKey}</dt>
|
||||
<dd>{skill.route_key || skill.id}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{liveCopy.skills.modelType}</dt>
|
||||
<dd>{skill.model_type}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{liveCopy.skills.latency}</dt>
|
||||
<dd>{skill.estimated_duration_s}s</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{liveCopy.skills.tags}</dt>
|
||||
<dd>{skill.tags.join(", ") || "-"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
))
|
||||
) : (
|
||||
<article className="section-panel empty-state-card">
|
||||
<div className="section-heading compact">
|
||||
<h2>{liveCopy.skills.noResults}</h2>
|
||||
<p>{search ? `"${search}"` : liveCopy.skills.subtitle}</p>
|
||||
</div>
|
||||
</article>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
SESSION_COOKIE_NAME,
|
||||
type LoginResponse,
|
||||
popiartFetchEnvelope,
|
||||
} from "@/lib/popiart-api";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let body: { key?: string } = {};
|
||||
|
||||
try {
|
||||
body = (await request.json()) as { key?: string };
|
||||
} catch {
|
||||
body = {};
|
||||
}
|
||||
|
||||
const key = body.key?.trim();
|
||||
if (!key) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: {
|
||||
code: "VALIDATION_ERROR",
|
||||
message: "key is required",
|
||||
},
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const { response, payload } = await popiartFetchEnvelope<LoginResponse>("/auth/login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ key }),
|
||||
});
|
||||
|
||||
const proxyResponse = NextResponse.json(
|
||||
payload ?? {
|
||||
ok: false,
|
||||
error: {
|
||||
code: "SERVER_ERROR",
|
||||
message: "invalid response from popiartServer",
|
||||
},
|
||||
},
|
||||
{ status: response.status },
|
||||
);
|
||||
|
||||
if (response.ok && payload?.ok && payload.data?.token) {
|
||||
proxyResponse.cookies.set({
|
||||
name: SESSION_COOKIE_NAME,
|
||||
value: payload.data.token,
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
});
|
||||
}
|
||||
|
||||
return proxyResponse;
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: {
|
||||
code: "SERVER_ERROR",
|
||||
message: "failed to reach popiartServer",
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
import {
|
||||
SESSION_COOKIE_NAME,
|
||||
popiartFetchEnvelope,
|
||||
} from "@/lib/popiart-api";
|
||||
|
||||
export async function POST() {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(SESSION_COOKIE_NAME)?.value;
|
||||
|
||||
try {
|
||||
if (token) {
|
||||
await popiartFetchEnvelope<{ logged_out: boolean }>("/auth/logout", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Clear the local session even if the upstream logout call fails.
|
||||
}
|
||||
|
||||
const response = NextResponse.json({
|
||||
ok: true,
|
||||
data: {
|
||||
logged_out: true,
|
||||
},
|
||||
});
|
||||
|
||||
response.cookies.set({
|
||||
name: SESSION_COOKIE_NAME,
|
||||
value: "",
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
maxAge: 0,
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
+1575
-84
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="256" height="256" rx="72" fill="#F4F8FF"/>
|
||||
<circle cx="128" cy="128" r="56" fill="url(#paint0_linear_120_2)"/>
|
||||
<circle cx="128" cy="128" r="76" fill="#2C6BFF" fill-opacity="0.12"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_120_2" x1="86" y1="80" x2="172" y2="176" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#8AB0FF"/>
|
||||
<stop offset="1" stop-color="#2C6BFF"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 540 B |
Reference in New Issue
Block a user