Initial WallMuse project
This commit is contained in:
180
apps/web/src/app/generate/page.tsx
Executable file
180
apps/web/src/app/generate/page.tsx
Executable 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
801
apps/web/src/app/globals.css
Executable 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
40
apps/web/src/app/layout.tsx
Executable 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
54
apps/web/src/app/login/page.tsx
Executable 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
86
apps/web/src/app/page.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
48
apps/web/src/app/register/page.tsx
Executable file
48
apps/web/src/app/register/page.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
69
apps/web/src/app/results/[id]/page.tsx
Executable file
69
apps/web/src/app/results/[id]/page.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
108
apps/web/src/app/settings/api-keys/page.tsx
Executable file
108
apps/web/src/app/settings/api-keys/page.tsx
Executable 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>前端只提交到本站 API。Key 的加密保存、连通性测试和调用日志由后端接口处理。</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>
|
||||
);
|
||||
}
|
||||
86
apps/web/src/components/app-shell.tsx
Executable file
86
apps/web/src/components/app-shell.tsx
Executable 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;
|
||||
}
|
||||
28
apps/web/src/components/category-rail.tsx
Executable file
28
apps/web/src/components/category-rail.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
31
apps/web/src/components/wallpaper-card.tsx
Executable file
31
apps/web/src/components/wallpaper-card.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
12
apps/web/src/components/wallpaper-grid.tsx
Executable file
12
apps/web/src/components/wallpaper-grid.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user