Initial WallMuse project

This commit is contained in:
fenglee
2026-05-09 09:12:41 +00:00
commit 3ea7d29827
91 changed files with 13136 additions and 0 deletions

View File

@@ -0,0 +1,180 @@
import { CheckCircle2, Clock, ImagePlus, Monitor, RefreshCw, Smartphone, Sparkles } from "lucide-react";
import { wallMuseApi } from "@wallmuse/api-client";
import { AppShell } from "../../components/app-shell";
export default async function GeneratePage() {
const history = await wallMuseApi.listGenerationHistory();
const current = await wallMuseApi.createGeneration({
mode: "text_to_image",
prompt: "A blue glass canyon with a silent river and soft morning haze",
style: "Landscape",
resolution: "4k",
outputPair: true,
provider: "OpenAI Compatible",
model: "gpt-image-1",
privateMode: false
});
const desktop = current.assets.find((asset) => asset.label === "Desktop") ?? current.assets[0];
const mobile = current.assets.find((asset) => asset.label === "Mobile") ?? current.assets[1];
return (
<AppShell>
<div className="page-grid">
<section className="page-title">
<p className="eyebrow">
<Sparkles size={18} strokeWidth={1.75} />
Generation workspace
</p>
<h1></h1>
<p></p>
</section>
<section className="form-layout">
<form className="glass-panel panel-pad">
<div className="segmented" aria-label="Generation mode">
<button className="active" type="button"></button>
<button type="button"></button>
</div>
<div className="field" style={{ marginTop: 16 }}>
<label htmlFor="prompt">Prompt</label>
<textarea
id="prompt"
defaultValue="A luminous blue glass canyon with a quiet river, soft morning haze, premium wallpaper, coherent desktop and mobile composition"
/>
</div>
<div className="field">
<label htmlFor="negative">Negative prompt</label>
<textarea
id="negative"
defaultValue="low resolution, watermark, text, distorted architecture, noisy texture"
/>
</div>
<div className="field">
<label htmlFor="style">Style</label>
<select id="style" defaultValue="Landscape">
<option>Landscape</option>
<option>Nature</option>
<option>Minimal</option>
<option>Space</option>
<option>Cyberpunk</option>
</select>
</div>
<div className="field">
<label>Resolution</label>
<div className="segmented">
<button type="button">1K</button>
<button className="active" type="button">2K</button>
<button type="button">4K</button>
</div>
</div>
<div className="switch-row">
<span>
<strong></strong>
<br />
<small>16:9 desktop + 9:16 mobile</small>
</span>
<input type="checkbox" defaultChecked aria-label="Output desktop and mobile pair" />
</div>
<div className="switch-row">
<span>
<strong></strong>
<br />
<small></small>
</span>
<input type="checkbox" aria-label="Private generation" />
</div>
<div className="field">
<label htmlFor="provider">Provider</label>
<select id="provider" defaultValue="OpenAI Compatible">
<option>OpenAI Compatible</option>
<option>Seedream</option>
<option>Qwen Image</option>
<option>SiliconFlow</option>
</select>
</div>
<button className="primary-button" type="button" style={{ width: "100%" }}>
<Sparkles size={20} strokeWidth={1.75} />
Generate wallpapers
</button>
</form>
<div className="glass-panel panel-pad">
<div className="section-heading">
<div>
<h2></h2>
<p></p>
</div>
<span className="score">
<CheckCircle2 size={18} strokeWidth={1.75} />
{current.consistencyScore}%
</span>
</div>
<div className="result-pair">
<figure className="result-frame landscape">
<img src={desktop.imageUrl} alt={`${current.prompt} desktop result`} />
</figure>
<figure className="result-frame portrait">
<img src={mobile.imageUrl} alt={`${current.prompt} mobile result`} />
</figure>
</div>
<div className="quick-specs">
<span className="pill">
<Monitor size={16} strokeWidth={1.75} />
{desktop.width}x{desktop.height}
</span>
<span className="pill">
<Smartphone size={16} strokeWidth={1.75} />
{mobile.width}x{mobile.height}
</span>
<button className="glass-button">
<RefreshCw size={16} strokeWidth={1.75} />
</button>
<button className="glass-button">
<ImagePlus size={16} strokeWidth={1.75} />
</button>
</div>
</div>
<aside className="glass-panel panel-pad">
<div className="section-heading">
<div>
<h2></h2>
<p>Mock API </p>
</div>
</div>
<div className="task-list">
{[current, ...history].map((item) => (
<div className="task-item" key={item.id}>
<strong>{item.style} · {item.resolution}</strong>
<span>{item.prompt}</span>
<div className="status-line">
<span className="score">
<CheckCircle2 size={16} strokeWidth={1.75} />
{item.status}
</span>
<span>
<Clock size={14} strokeWidth={1.75} /> {new Date(item.createdAt).toLocaleTimeString()}
</span>
</div>
</div>
))}
</div>
</aside>
</section>
</div>
</AppShell>
);
}

801
apps/web/src/app/globals.css Executable file
View File

@@ -0,0 +1,801 @@
* {
box-sizing: border-box;
}
html {
min-height: 100%;
background: var(--color-bg-page);
}
body {
min-height: 100vh;
margin: 0;
color: var(--color-text-primary);
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.72), transparent 34%),
linear-gradient(145deg, var(--color-bg-page), #d9ecff 45%, var(--color-bg-page));
font-size: var(--font-md);
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
letter-spacing: 0;
}
[data-theme="dark"] body {
background:
linear-gradient(140deg, rgba(102, 170, 255, 0.12), transparent 38%),
linear-gradient(145deg, var(--color-bg-page), #0c1b30 58%, #07111f);
}
a {
color: inherit;
text-decoration: none;
}
button,
input,
textarea,
select {
font: inherit;
}
button {
cursor: pointer;
}
img {
display: block;
max-width: 100%;
}
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 3px;
}
.page-stage {
min-height: 100vh;
padding: 24px;
}
.app-shell {
width: min(1800px, 100%);
min-height: calc(100vh - 48px);
margin: 0 auto;
overflow: hidden;
border: 1px solid var(--color-border-soft);
border-radius: var(--radius-xl);
background: var(--color-bg-surface);
box-shadow: var(--shadow-shell);
backdrop-filter: var(--glass-blur);
}
.top-nav {
display: grid;
grid-template-columns: auto minmax(280px, 660px) auto;
gap: 24px;
align-items: center;
min-height: 88px;
padding: 18px 34px;
border-bottom: 1px solid var(--color-border-soft);
background: var(--color-bg-surface-strong);
backdrop-filter: var(--glass-blur);
}
.brand {
display: inline-flex;
align-items: center;
gap: 12px;
min-width: 0;
font-size: var(--font-xl);
font-weight: 750;
}
.brand-mark {
display: grid;
grid-template-columns: repeat(4, 5px);
gap: 3px;
align-items: end;
width: 40px;
height: 40px;
padding: 9px;
border: 1px solid var(--color-border-strong);
border-radius: 15px;
background: linear-gradient(145deg, rgba(255, 255, 255, 0.68), rgba(47, 134, 255, 0.14));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
}
[data-theme="dark"] .brand-mark {
background: linear-gradient(145deg, rgba(102, 170, 255, 0.2), rgba(8, 18, 32, 0.72));
}
.brand-mark span {
border-radius: var(--radius-full);
background: linear-gradient(180deg, #9ed3ff, var(--color-accent));
}
.brand-mark span:nth-child(1) {
height: 14px;
}
.brand-mark span:nth-child(2) {
height: 22px;
}
.brand-mark span:nth-child(3) {
height: 18px;
}
.brand-mark span:nth-child(4) {
height: 26px;
}
.search-box {
display: flex;
align-items: center;
gap: 12px;
height: 52px;
min-width: 0;
padding: 0 16px;
border: 1px solid var(--color-border-soft);
border-radius: var(--radius-lg);
background: var(--color-bg-control);
color: var(--color-text-secondary);
}
.search-box input {
min-width: 0;
width: 100%;
border: 0;
outline: 0;
background: transparent;
color: var(--color-text-primary);
}
.search-box kbd {
flex: 0 0 auto;
border: 1px solid var(--color-border-soft);
border-radius: var(--radius-xs);
padding: 3px 7px;
color: var(--color-text-muted);
font-size: var(--font-xs);
}
.nav-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
}
.icon-button,
.glass-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 44px;
border: 1px solid var(--color-border-soft);
background: var(--color-bg-control);
color: var(--color-text-primary);
transition: transform 180ms ease, border-color 180ms ease, box-shadow 180ms ease;
backdrop-filter: var(--glass-blur);
}
.icon-button {
width: 44px;
border-radius: var(--radius-full);
}
.glass-button {
padding: 0 16px;
border-radius: var(--radius-full);
font-weight: 650;
}
.icon-button:hover,
.glass-button:hover {
transform: translateY(-1px);
border-color: var(--color-border-strong);
box-shadow: var(--shadow-card);
}
.primary-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 48px;
border: 0;
border-radius: var(--radius-full);
padding: 0 20px;
background: linear-gradient(135deg, #2f86ff, #66aaff);
color: #fff;
font-weight: 760;
box-shadow: 0 14px 30px rgba(47, 134, 255, 0.28);
}
.avatar {
display: grid;
place-items: center;
width: 44px;
height: 44px;
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-full);
background: var(--color-accent-soft);
color: var(--color-accent);
font-weight: 800;
}
.shell-content {
padding: 30px 34px 36px;
}
.page-grid {
display: grid;
gap: 24px;
}
.hero-strip {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(340px, 0.9fr);
gap: 24px;
align-items: stretch;
}
.glass-panel {
border: 1px solid var(--color-border-soft);
border-radius: var(--radius-xl);
background: var(--color-bg-surface);
box-shadow: var(--shadow-card);
backdrop-filter: var(--glass-blur);
}
.hero-composer {
min-height: 326px;
padding: 28px;
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
margin: 0 0 16px;
color: var(--color-accent);
font-size: var(--font-sm);
font-weight: 750;
}
.hero-composer h1,
.page-title h1 {
margin: 0;
max-width: 780px;
font-size: var(--font-3xl);
line-height: 1.08;
letter-spacing: 0;
}
.hero-composer p,
.page-title p {
margin: 14px 0 0;
max-width: 720px;
color: var(--color-text-secondary);
line-height: 1.7;
}
.prompt-bar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
margin-top: 26px;
}
.prompt-bar textarea,
.field textarea,
.field input,
.field select {
width: 100%;
border: 1px solid var(--color-border-soft);
border-radius: var(--radius-md);
background: var(--color-bg-surface-strong);
color: var(--color-text-primary);
outline: 0;
}
.prompt-bar textarea {
min-height: 96px;
resize: vertical;
padding: 15px 16px;
}
.quick-specs {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 18px;
}
.pill {
display: inline-flex;
align-items: center;
gap: 7px;
min-height: 34px;
padding: 0 12px;
border: 1px solid var(--color-border-soft);
border-radius: var(--radius-full);
background: var(--color-bg-control);
color: var(--color-text-secondary);
font-size: var(--font-sm);
font-weight: 650;
}
.feature-preview {
position: relative;
min-height: 326px;
overflow: hidden;
}
.feature-preview img {
width: 100%;
height: 100%;
min-height: 326px;
object-fit: cover;
}
.preview-metadata {
position: absolute;
right: 18px;
bottom: 18px;
left: 18px;
display: flex;
justify-content: space-between;
gap: 12px;
padding: 14px;
border: 1px solid rgba(255, 255, 255, 0.26);
border-radius: var(--radius-md);
background: rgba(4, 10, 18, 0.48);
color: #fff;
backdrop-filter: blur(18px);
}
.category-rail {
display: flex;
gap: 10px;
overflow-x: auto;
padding: 2px 0 8px;
scrollbar-width: thin;
}
.category-pill {
flex: 0 0 auto;
min-height: 42px;
border: 1px solid var(--color-border-soft);
border-radius: var(--radius-full);
padding: 0 16px;
background: var(--color-bg-control);
color: var(--color-text-secondary);
font-weight: 650;
}
.category-pill.active {
border-color: var(--color-border-strong);
background: var(--color-bg-control-active);
color: var(--color-accent);
}
.wallpaper-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 22px;
}
.wallpaper-card {
position: relative;
isolation: isolate;
overflow: hidden;
min-height: 260px;
border: 1px solid transparent;
border-radius: var(--radius-md);
background: var(--color-bg-control);
box-shadow: var(--shadow-card);
transition: transform 180ms ease, border-color 180ms ease, box-shadow 180ms ease;
}
.wallpaper-card:hover,
.wallpaper-card:focus-within {
transform: translateY(-2px);
border-color: var(--color-border-strong);
box-shadow: var(--shadow-floating);
}
.wallpaper-card img {
width: 100%;
height: 100%;
min-height: 260px;
object-fit: cover;
transition: transform 180ms ease;
}
.wallpaper-card:hover img {
transform: scale(1.02);
}
.wallpaper-card.portrait {
grid-row: span 2;
}
.wallpaper-card.portrait img,
.wallpaper-card.portrait {
min-height: 520px;
}
.card-badge,
.card-more,
.card-stats,
.card-actions {
position: absolute;
z-index: 2;
}
.card-badge {
top: 12px;
left: 12px;
padding: 7px 10px;
border: 1px solid rgba(255, 255, 255, 0.28);
border-radius: var(--radius-full);
background: rgba(255, 255, 255, 0.58);
color: #172235;
font-size: var(--font-xs);
font-weight: 850;
backdrop-filter: blur(14px);
}
.card-more {
top: 10px;
right: 10px;
}
.card-stats {
right: 12px;
bottom: 12px;
left: 12px;
display: flex;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: var(--radius-sm);
background: rgba(4, 10, 18, 0.52);
color: #fff;
font-size: var(--font-sm);
backdrop-filter: blur(16px);
}
.card-actions {
top: 50%;
left: 50%;
display: flex;
gap: 10px;
opacity: 0;
transform: translate(-50%, -44%);
transition: opacity 180ms ease, transform 180ms ease;
}
.wallpaper-card:hover .card-actions,
.wallpaper-card:focus-within .card-actions {
opacity: 1;
transform: translate(-50%, -50%);
}
.floating-action {
width: 48px;
height: 48px;
border: 1px solid rgba(255, 255, 255, 0.24);
border-radius: var(--radius-full);
background: rgba(255, 255, 255, 0.28);
color: #fff;
backdrop-filter: blur(18px);
}
.section-heading {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.section-heading h2 {
margin: 0;
font-size: var(--font-xl);
}
.section-heading p {
margin: 5px 0 0;
color: var(--color-text-secondary);
}
.form-layout {
display: grid;
grid-template-columns: minmax(320px, 420px) minmax(0, 1fr) minmax(280px, 340px);
gap: 22px;
align-items: start;
}
.panel-pad {
padding: 22px;
}
.field {
display: grid;
gap: 8px;
margin-bottom: 16px;
}
.field label {
color: var(--color-text-secondary);
font-size: var(--font-sm);
font-weight: 700;
}
.field textarea {
min-height: 132px;
resize: vertical;
padding: 13px 14px;
}
.field input,
.field select {
height: 46px;
padding: 0 13px;
}
.segmented {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
gap: 6px;
padding: 5px;
border: 1px solid var(--color-border-soft);
border-radius: var(--radius-md);
background: var(--color-bg-control);
}
.segmented button {
min-height: 38px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-secondary);
font-weight: 700;
}
.segmented button.active {
background: var(--color-bg-control-active);
color: var(--color-accent);
}
.switch-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
min-height: 48px;
margin-bottom: 12px;
padding: 10px 12px;
border: 1px solid var(--color-border-soft);
border-radius: var(--radius-md);
background: var(--color-bg-control);
}
.switch-row input {
width: 18px;
height: 18px;
}
.result-pair {
display: grid;
grid-template-columns: minmax(0, 1.6fr) minmax(180px, 0.75fr);
gap: 18px;
align-items: start;
}
.result-frame {
overflow: hidden;
border: 1px solid var(--color-border-soft);
border-radius: var(--radius-md);
background: var(--color-bg-control);
}
.result-frame.landscape {
aspect-ratio: 16 / 9;
}
.result-frame.portrait {
aspect-ratio: 9 / 16;
}
.result-frame img {
width: 100%;
height: 100%;
object-fit: cover;
}
.task-list {
display: grid;
gap: 12px;
}
.task-item {
display: grid;
gap: 8px;
padding: 13px;
border: 1px solid var(--color-border-soft);
border-radius: var(--radius-md);
background: var(--color-bg-control);
}
.status-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: var(--color-text-secondary);
font-size: var(--font-sm);
}
.score {
display: inline-flex;
align-items: center;
gap: 7px;
color: var(--color-success);
font-weight: 800;
}
.auth-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(320px, 420px);
gap: 24px;
align-items: stretch;
}
.auth-art {
position: relative;
overflow: hidden;
min-height: 560px;
}
.auth-art img {
width: 100%;
height: 100%;
min-height: 560px;
object-fit: cover;
}
.settings-layout {
display: grid;
grid-template-columns: minmax(320px, 460px) minmax(0, 1fr);
gap: 22px;
}
.key-row {
display: grid;
gap: 12px;
padding: 16px;
border: 1px solid var(--color-border-soft);
border-radius: var(--radius-md);
background: var(--color-bg-control);
}
.footer-note {
margin-top: 24px;
color: var(--color-text-muted);
font-size: var(--font-sm);
}
@media (min-width: 1600px) {
.wallpaper-grid {
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 26px;
}
}
@media (max-width: 1199px) {
.hero-strip,
.form-layout,
.auth-layout,
.settings-layout {
grid-template-columns: 1fr;
}
.form-layout {
align-items: stretch;
}
}
@media (max-width: 899px) {
.page-stage {
padding: 0;
}
.app-shell {
min-height: 100vh;
border-width: 0;
border-radius: 0;
}
.top-nav {
grid-template-columns: 1fr auto;
gap: 14px;
min-height: 64px;
padding: 12px 16px;
}
.top-nav .search-box {
grid-column: 1 / -1;
height: 48px;
}
.shell-content {
padding: 18px 16px 28px;
}
.nav-actions .glass-button span,
.search-box kbd {
display: none;
}
.hero-composer {
padding: 20px;
}
.hero-composer h1,
.page-title h1 {
font-size: var(--font-2xl);
}
.prompt-bar {
grid-template-columns: 1fr;
}
.wallpaper-grid {
grid-template-columns: 1fr;
gap: 16px;
}
.wallpaper-card.portrait,
.wallpaper-card.portrait img {
min-height: 440px;
}
.result-pair {
grid-template-columns: 1fr;
}
}
@media (max-width: 479px) {
.brand {
font-size: var(--font-lg);
}
.brand-mark {
width: 36px;
height: 36px;
padding: 8px;
}
.hero-composer h1,
.page-title h1 {
font-size: 25px;
}
.preview-metadata,
.section-heading,
.status-line {
align-items: flex-start;
flex-direction: column;
}
.auth-art,
.auth-art img {
min-height: 360px;
}
}

40
apps/web/src/app/layout.tsx Executable file
View File

@@ -0,0 +1,40 @@
import type { Metadata, Viewport } from "next";
import "@wallmuse/ui-tokens/src/tokens.css";
import "./globals.css";
export const metadata: Metadata = {
title: "WallMuse",
description: "AI wallpaper generation and gallery for desktop and mobile pairs."
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
colorScheme: "light dark"
};
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="zh-CN" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `
(function () {
try {
var saved = localStorage.getItem('wallmuse-theme') || 'system';
var systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var theme = saved === 'system' ? (systemDark ? 'dark' : 'light') : saved;
document.documentElement.dataset.theme = theme;
} catch (error) {
document.documentElement.dataset.theme = 'light';
}
})();
`
}}
/>
</head>
<body>{children}</body>
</html>
);
}

54
apps/web/src/app/login/page.tsx Executable file
View File

@@ -0,0 +1,54 @@
import Link from "next/link";
import { KeyRound, LogIn, ShieldCheck } from "lucide-react";
import { AppShell } from "../../components/app-shell";
export default function LoginPage() {
return (
<AppShell>
<section className="auth-layout">
<div className="glass-panel auth-art">
<img
src="https://images.unsplash.com/photo-1500534314209-a25ddb2bd429?auto=format&fit=crop&w=1500&h=1100&q=86"
alt="Blue canyon wallpaper preview"
/>
<div className="preview-metadata">
<div>
<strong>WallMuse account</strong>
<div>API Key </div>
</div>
<ShieldCheck size={22} strokeWidth={1.75} />
</div>
</div>
<form className="glass-panel panel-pad">
<p className="eyebrow">
<LogIn size={18} strokeWidth={1.75} />
Sign in
</p>
<h1 style={{ margin: 0 }}> WallMuse</h1>
<p className="footer-note"> packages/api-client mock </p>
<div className="field">
<label htmlFor="email">Email</label>
<input id="email" type="email" defaultValue="feng@example.com" />
</div>
<div className="field">
<label htmlFor="password">Password</label>
<input id="password" type="password" defaultValue="wallmuse-demo" />
</div>
<button className="primary-button" type="button" style={{ width: "100%" }}>
<KeyRound size={18} strokeWidth={1.75} />
Login
</button>
<div className="quick-specs">
<Link className="glass-button" href="/register">Create account</Link>
<Link className="glass-button" href="/settings/api-keys">API Key settings</Link>
</div>
</form>
</section>
</AppShell>
);
}

86
apps/web/src/app/page.tsx Executable file
View File

@@ -0,0 +1,86 @@
import Link from "next/link";
import { Download, Monitor, ShieldCheck, Smartphone, Sparkles } from "lucide-react";
import { wallMuseApi } from "@wallmuse/api-client";
import { AppShell } from "../components/app-shell";
import { CategoryRail } from "../components/category-rail";
import { WallpaperGrid } from "../components/wallpaper-grid";
export default async function HomePage() {
const wallpapers = await wallMuseApi.listWallpapers();
const featured = wallpapers.find((item) => item.featured) ?? wallpapers[0];
return (
<AppShell>
<div className="page-grid">
<section className="hero-strip">
<div className="glass-panel hero-composer">
<p className="eyebrow">
<Sparkles size={18} strokeWidth={1.75} />
Desktop and mobile wallpaper pairs
</p>
<h1></h1>
<p>
WallMuse 使 16:9 9:16
</p>
<div className="prompt-bar">
<textarea
aria-label="Quick prompt"
placeholder="例如:蓝色玻璃峡谷、清晨薄雾、柔和体积光、适合桌面和手机锁屏..."
defaultValue="A luminous blue glass canyon with a quiet river, soft morning haze, cinematic wallpaper"
/>
<Link className="primary-button" href="/generate">
<Sparkles size={20} strokeWidth={1.75} />
</Link>
</div>
<div className="quick-specs">
<span className="pill">
<Monitor size={16} strokeWidth={1.75} />
16:9 Desktop
</span>
<span className="pill">
<Smartphone size={16} strokeWidth={1.75} />
9:16 Mobile
</span>
<span className="pill">
<ShieldCheck size={16} strokeWidth={1.75} />
Server-side API keys
</span>
<span className="pill">
<Download size={16} strokeWidth={1.75} />
1K / 2K / 4K
</span>
</div>
</div>
<div className="glass-panel feature-preview">
<img src={featured.imageUrl} alt={featured.prompt} />
<div className="preview-metadata">
<div>
<strong>{featured.title}</strong>
<div>{featured.style} · {featured.model}</div>
</div>
<div>{featured.resolution.toUpperCase()} · {featured.ratio}</div>
</div>
</div>
</section>
<CategoryRail />
<section>
<div className="section-heading">
<div>
<h2></h2>
<p></p>
</div>
<Link className="glass-button" href="/generate">
<Sparkles size={18} strokeWidth={1.75} />
Create pair
</Link>
</div>
<WallpaperGrid wallpapers={wallpapers} />
</section>
</div>
</AppShell>
);
}

View File

@@ -0,0 +1,48 @@
import Link from "next/link";
import { UserPlus } from "lucide-react";
import { AppShell } from "../../components/app-shell";
export default function RegisterPage() {
return (
<AppShell>
<section className="auth-layout">
<div className="glass-panel auth-art">
<img
src="https://images.unsplash.com/photo-1446776811953-b23d57bd21aa?auto=format&fit=crop&w=1500&h=1100&q=86"
alt="Orbital wallpaper preview"
/>
</div>
<form className="glass-panel panel-pad">
<p className="eyebrow">
<UserPlus size={18} strokeWidth={1.75} />
Create account
</p>
<h1 style={{ margin: 0 }}> WallMuse</h1>
<p className="footer-note"> API Key</p>
<div className="field">
<label htmlFor="name">Name</label>
<input id="name" defaultValue="Feng Lee" />
</div>
<div className="field">
<label htmlFor="email">Email</label>
<input id="email" type="email" defaultValue="feng@example.com" />
</div>
<div className="field">
<label htmlFor="password">Password</label>
<input id="password" type="password" defaultValue="wallmuse-demo" />
</div>
<button className="primary-button" type="button" style={{ width: "100%" }}>
<UserPlus size={18} strokeWidth={1.75} />
Register
</button>
<div className="quick-specs">
<Link className="glass-button" href="/login">Already have an account</Link>
</div>
</form>
</section>
</AppShell>
);
}

View File

@@ -0,0 +1,69 @@
import Link from "next/link";
import { Download, Heart, Monitor, RefreshCw, Share2, Smartphone } from "lucide-react";
import { wallMuseApi } from "@wallmuse/api-client";
import { AppShell } from "../../../components/app-shell";
export default async function ResultPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const group = await wallMuseApi.getGenerationGroup(id);
const desktop = group.assets.find((asset) => asset.label === "Desktop") ?? group.assets[0];
const mobile = group.assets.find((asset) => asset.label === "Mobile") ?? group.assets[1] ?? desktop;
return (
<AppShell>
<div className="page-grid">
<section className="page-title">
<p className="eyebrow">Result detail</p>
<h1>{group.style} wallpaper pair</h1>
<p>{group.prompt}</p>
</section>
<section className="glass-panel panel-pad">
<div className="result-pair">
<figure className="result-frame landscape">
<img src={desktop.imageUrl} alt={`${group.prompt} desktop wallpaper`} />
</figure>
<figure className="result-frame portrait">
<img src={mobile.imageUrl} alt={`${group.prompt} mobile wallpaper`} />
</figure>
</div>
<div className="quick-specs">
<span className="pill">
<Monitor size={16} strokeWidth={1.75} />
Desktop {desktop.width}x{desktop.height}
</span>
<span className="pill">
<Smartphone size={16} strokeWidth={1.75} />
Mobile {mobile.width}x{mobile.height}
</span>
<span className="pill">Model {group.model}</span>
<span className="pill">Consistency {group.consistencyScore}%</span>
</div>
<div className="quick-specs">
<button className="primary-button">
<Download size={18} strokeWidth={1.75} />
Download pair
</button>
<button className="glass-button">
<Heart size={18} strokeWidth={1.75} />
Favorite
</button>
<button className="glass-button">
<Share2 size={18} strokeWidth={1.75} />
Share
</button>
<button className="glass-button">
<RefreshCw size={18} strokeWidth={1.75} />
Retry group
</button>
<Link className="glass-button" href="/generate">
Generate again
</Link>
</div>
</section>
</div>
</AppShell>
);
}

View File

@@ -0,0 +1,108 @@
import { CheckCircle2, Eye, KeyRound, PlugZap, ShieldCheck } from "lucide-react";
import { wallMuseApi } from "@wallmuse/api-client";
import { AppShell } from "../../../components/app-shell";
export default async function ApiKeysPage() {
const keys = await wallMuseApi.listApiKeys();
return (
<AppShell>
<div className="page-grid">
<section className="page-title">
<p className="eyebrow">
<KeyRound size={18} strokeWidth={1.75} />
User API Key
</p>
<h1> API Key</h1>
<p> APIKey </p>
</section>
<section className="settings-layout">
<form className="glass-panel panel-pad">
<div className="field">
<label htmlFor="provider">Provider</label>
<select id="provider" defaultValue="OpenAI Compatible">
<option>OpenAI Compatible</option>
<option>SiliconFlow</option>
<option>Qwen Image</option>
<option>Seedream</option>
</select>
</div>
<div className="field">
<label htmlFor="baseUrl">Base URL</label>
<input id="baseUrl" defaultValue="https://api.example.com/v1" />
</div>
<div className="field">
<label htmlFor="apiKey">API Key</label>
<input id="apiKey" type="password" defaultValue="sk-wallmuse-demo-key" />
</div>
<div className="field">
<label htmlFor="model">Model ID</label>
<input id="model" defaultValue="gpt-image-1" />
</div>
<div className="switch-row">
<span>
<strong></strong>
<br />
<small></small>
</span>
<input type="checkbox" defaultChecked aria-label="Save key to account" />
</div>
<div className="switch-row">
<span>
<strong></strong>
<br />
<small></small>
</span>
<input type="checkbox" defaultChecked aria-label="Set as default key" />
</div>
<div className="quick-specs">
<button className="primary-button" type="button">
<ShieldCheck size={18} strokeWidth={1.75} />
Save key
</button>
<button className="glass-button" type="button">
<PlugZap size={18} strokeWidth={1.75} />
Test connection
</button>
<button className="glass-button" type="button" aria-label="Show key">
<Eye size={18} strokeWidth={1.75} />
</button>
</div>
</form>
<div className="glass-panel panel-pad">
<div className="section-heading">
<div>
<h2> Key</h2>
<p> masked key</p>
</div>
</div>
<div className="task-list">
{keys.map((key) => (
<div className="key-row" key={key.id}>
<div className="status-line">
<strong>{key.provider}</strong>
<span className="score">
<CheckCircle2 size={16} strokeWidth={1.75} />
{key.status}
</span>
</div>
<span>{key.baseUrl}</span>
<span>{key.model} · {key.maskedKey}</span>
<div className="quick-specs">
<span className="pill">{key.isDefault ? "Default" : "Optional"}</span>
<span className="pill">{new Date(key.updatedAt).toLocaleString()}</span>
</div>
</div>
))}
</div>
</div>
</section>
</div>
</AppShell>
);
}

View File

@@ -0,0 +1,86 @@
"use client";
import Link from "next/link";
import { Bell, Image, KeyRound, Moon, Search, Settings, Sparkles, Sun, User } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import type { ThemePreference } from "@wallmuse/shared";
const navItems = [
{ href: "/", label: "Gallery", icon: Image },
{ href: "/generate", label: "Generate", icon: Sparkles },
{ href: "/settings/api-keys", label: "API Keys", icon: KeyRound }
];
export function AppShell({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<ThemePreference>("system");
useEffect(() => {
const saved = (localStorage.getItem("wallmuse-theme") as ThemePreference | null) ?? "system";
setTheme(saved);
applyTheme(saved);
}, []);
const ThemeIcon = useMemo(() => (theme === "dark" ? Moon : Sun), [theme]);
function cycleTheme() {
const next: ThemePreference = theme === "system" ? "light" : theme === "light" ? "dark" : "system";
setTheme(next);
localStorage.setItem("wallmuse-theme", next);
applyTheme(next);
}
return (
<div className="page-stage">
<main className="app-shell">
<nav className="top-nav" aria-label="WallMuse navigation">
<Link href="/" className="brand" aria-label="WallMuse home">
<span className="brand-mark" aria-hidden="true">
<span />
<span />
<span />
<span />
</span>
<span>WallMuse</span>
</Link>
<label className="search-box">
<Search size={19} strokeWidth={1.75} />
<input placeholder="搜索壁纸、风格或关键词..." aria-label="搜索壁纸、风格或关键词" />
<kbd>Ctrl K</kbd>
</label>
<div className="nav-actions">
{navItems.map((item) => {
const Icon = item.icon;
return (
<Link className="glass-button" href={item.href} key={item.href}>
<Icon size={18} strokeWidth={1.75} />
<span>{item.label}</span>
</Link>
);
})}
<button className="icon-button" aria-label={`Theme: ${theme}`} onClick={cycleTheme}>
<ThemeIcon size={19} strokeWidth={1.75} />
</button>
<button className="icon-button" aria-label="Notifications">
<Bell size={19} strokeWidth={1.75} />
</button>
<Link className="icon-button" href="/login" aria-label="Login">
<User size={19} strokeWidth={1.75} />
</Link>
<Link className="avatar" href="/settings/api-keys" aria-label="User settings">
<Settings size={18} strokeWidth={1.75} />
</Link>
</div>
</nav>
<div className="shell-content">{children}</div>
</main>
</div>
);
}
function applyTheme(theme: ThemePreference) {
const isSystemDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
document.documentElement.dataset.theme =
theme === "system" ? (isSystemDark ? "dark" : "light") : theme;
}

View File

@@ -0,0 +1,28 @@
"use client";
const categories = [
"All",
"4K Ultra HD",
"Nature",
"Landscape",
"Anime",
"Abstract",
"Minimal",
"Space",
"Architecture",
"Cyberpunk",
"Mobile",
"Desktop"
];
export function CategoryRail() {
return (
<div className="category-rail" aria-label="Wallpaper categories">
{categories.map((category, index) => (
<button className={`category-pill ${index === 0 ? "active" : ""}`} key={category}>
{category}
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,31 @@
import Link from "next/link";
import { Download, Heart, MoreHorizontal } from "lucide-react";
import type { WebWallpaper } from "@wallmuse/shared";
export function WallpaperCard({ wallpaper }: { wallpaper: WebWallpaper }) {
const isPortrait = wallpaper.ratio === "9:16";
return (
<article className={`wallpaper-card ${isPortrait ? "portrait" : ""}`}>
<img src={wallpaper.imageUrl} alt={wallpaper.prompt} />
<span className="card-badge">{wallpaper.resolution.toUpperCase()} ULTRA HD</span>
<button className="icon-button card-more" aria-label={`More actions for ${wallpaper.title}`}>
<MoreHorizontal size={19} strokeWidth={1.75} />
</button>
<div className="card-actions">
<button className="floating-action" aria-label={`Favorite ${wallpaper.title}`}>
<Heart size={22} strokeWidth={1.75} />
</button>
<button className="floating-action" aria-label={`Download ${wallpaper.title}`}>
<Download size={22} strokeWidth={1.75} />
</button>
</div>
<Link className="card-stats" href={`/results/${wallpaper.id}`}>
<span>{wallpaper.title}</span>
<span>
{wallpaper.likes.toLocaleString()} likes · {wallpaper.downloads.toLocaleString()} downloads
</span>
</Link>
</article>
);
}

View File

@@ -0,0 +1,12 @@
import type { WebWallpaper } from "@wallmuse/shared";
import { WallpaperCard } from "./wallpaper-card";
export function WallpaperGrid({ wallpapers }: { wallpapers: WebWallpaper[] }) {
return (
<div className="wallpaper-grid">
{wallpapers.map((wallpaper) => (
<WallpaperCard wallpaper={wallpaper} key={wallpaper.id} />
))}
</div>
);
}