feat(project): init
This commit is contained in:
422
public/app.js
Normal file
422
public/app.js
Normal file
@@ -0,0 +1,422 @@
|
||||
import {
|
||||
EXAMPLE_PROMPTS,
|
||||
GENERATION_DEFAULTS,
|
||||
PROCESSING_STAGES,
|
||||
STORAGE_KEY,
|
||||
buildDownloadStem,
|
||||
buildPromptStats,
|
||||
createProcessingSnapshot,
|
||||
sanitizePrompt,
|
||||
} from "./prompt-builder.js";
|
||||
|
||||
const sessionStorageKey = "image-prompt-studio:session-id:v1";
|
||||
|
||||
const elements = {
|
||||
promptInput: document.querySelector("#promptInput"),
|
||||
promptStats: document.querySelector("#promptStats"),
|
||||
examplePromptList: document.querySelector("#examplePromptList"),
|
||||
clearPromptButton: document.querySelector("#clearPromptButton"),
|
||||
generateButton: document.querySelector("#generateButton"),
|
||||
healthLabel: document.querySelector("#healthLabel"),
|
||||
modelLabel: document.querySelector("#modelLabel"),
|
||||
processingBadge: document.querySelector("#processingBadge"),
|
||||
processingLead: document.querySelector("#processingLead"),
|
||||
processingSteps: document.querySelector("#processingSteps"),
|
||||
progressFill: document.querySelector("#progressFill"),
|
||||
requestMeta: document.querySelector("#requestMeta"),
|
||||
resultStage: document.querySelector("#resultStage"),
|
||||
errorBanner: document.querySelector("#errorBanner"),
|
||||
downloadButton: document.querySelector("#downloadButton"),
|
||||
};
|
||||
|
||||
const state = {
|
||||
prompt: loadPrompt(),
|
||||
isGenerating: false,
|
||||
health: {
|
||||
configured: false,
|
||||
model: "gpt-image-2",
|
||||
},
|
||||
results: [],
|
||||
error: "",
|
||||
requestMeta: null,
|
||||
sessionId: getSessionId(),
|
||||
processing: {
|
||||
progress: 0,
|
||||
activeStageIndex: -1,
|
||||
lead: "准备就绪,输入 Prompt 后即可开始生成。",
|
||||
},
|
||||
processingTimer: null,
|
||||
startedAt: 0,
|
||||
};
|
||||
|
||||
renderExamplePrompts();
|
||||
elements.promptInput.value = state.prompt;
|
||||
updatePromptStats();
|
||||
render();
|
||||
wireEvents();
|
||||
void checkHealth();
|
||||
|
||||
function wireEvents() {
|
||||
elements.promptInput.addEventListener("input", () => {
|
||||
state.prompt = elements.promptInput.value;
|
||||
persistPrompt();
|
||||
updatePromptStats();
|
||||
render();
|
||||
});
|
||||
|
||||
elements.clearPromptButton.addEventListener("click", () => {
|
||||
state.prompt = "";
|
||||
state.results = [];
|
||||
state.requestMeta = null;
|
||||
state.error = "";
|
||||
state.processing = {
|
||||
progress: 0,
|
||||
activeStageIndex: -1,
|
||||
lead: "准备就绪,输入 Prompt 后即可开始生成。",
|
||||
};
|
||||
elements.promptInput.value = "";
|
||||
persistPrompt();
|
||||
updatePromptStats();
|
||||
render();
|
||||
});
|
||||
|
||||
elements.generateButton.addEventListener("click", () => {
|
||||
void generateImages();
|
||||
});
|
||||
|
||||
elements.downloadButton.addEventListener("click", () => {
|
||||
const result = state.results[0];
|
||||
|
||||
if (result) {
|
||||
downloadResult(result);
|
||||
}
|
||||
});
|
||||
|
||||
document.body.addEventListener("keydown", (event) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
|
||||
void generateImages();
|
||||
}
|
||||
});
|
||||
|
||||
elements.examplePromptList.addEventListener("click", (event) => {
|
||||
const button = event.target.closest("[data-prompt]");
|
||||
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.prompt = button.dataset.prompt ?? "";
|
||||
elements.promptInput.value = state.prompt;
|
||||
persistPrompt();
|
||||
updatePromptStats();
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
async function generateImages() {
|
||||
if (state.isGenerating) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prompt = sanitizePrompt(state.prompt);
|
||||
|
||||
if (!prompt) {
|
||||
setError("请输入 Prompt 后再生成图片。");
|
||||
return;
|
||||
}
|
||||
|
||||
state.prompt = prompt;
|
||||
elements.promptInput.value = prompt;
|
||||
persistPrompt();
|
||||
state.isGenerating = true;
|
||||
state.error = "";
|
||||
state.results = [];
|
||||
state.requestMeta = null;
|
||||
startProcessingTicker();
|
||||
render();
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/generate", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
prompt,
|
||||
...GENERATION_DEFAULTS,
|
||||
user: state.sessionId,
|
||||
}),
|
||||
});
|
||||
const payload = await response.json();
|
||||
|
||||
if (!response.ok || !payload.ok) {
|
||||
throw new Error(payload.error || "图片生成失败。");
|
||||
}
|
||||
|
||||
const stem = buildDownloadStem(prompt);
|
||||
state.results = payload.images.map((image, index) => ({
|
||||
...image,
|
||||
previewUrl: `data:${image.mimeType};base64,${image.base64}`,
|
||||
fileName: `${stem}-${index + 1}.${resolveExtension(image.mimeType)}`,
|
||||
}));
|
||||
state.requestMeta = payload.meta;
|
||||
state.processing = createProcessingSnapshot(
|
||||
Date.now() - state.startedAt,
|
||||
true,
|
||||
);
|
||||
state.error = "";
|
||||
} catch (error) {
|
||||
setError(error.message || "图片生成失败,请稍后重试。");
|
||||
} finally {
|
||||
state.isGenerating = false;
|
||||
stopProcessingTicker();
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
async function checkHealth() {
|
||||
try {
|
||||
const response = await fetch("/api/health");
|
||||
const payload = await response.json();
|
||||
state.health = payload;
|
||||
} catch {
|
||||
state.health = {
|
||||
configured: false,
|
||||
model: "gpt-image-2",
|
||||
};
|
||||
}
|
||||
|
||||
render();
|
||||
}
|
||||
|
||||
function startProcessingTicker() {
|
||||
stopProcessingTicker();
|
||||
state.startedAt = Date.now();
|
||||
state.processing = createProcessingSnapshot(0);
|
||||
state.processingTimer = window.setInterval(() => {
|
||||
state.processing = createProcessingSnapshot(Date.now() - state.startedAt);
|
||||
render();
|
||||
}, 140);
|
||||
}
|
||||
|
||||
function stopProcessingTicker() {
|
||||
if (state.processingTimer) {
|
||||
window.clearInterval(state.processingTimer);
|
||||
state.processingTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
elements.modelLabel.textContent = state.health.model || "gpt-image-2";
|
||||
elements.healthLabel.textContent = state.health.configured
|
||||
? "已连接服务端"
|
||||
: "缺少 OPENAI_API_KEY";
|
||||
elements.processingLead.textContent = state.processing.lead;
|
||||
elements.processingBadge.textContent = state.isGenerating
|
||||
? "处理中"
|
||||
: state.results.length > 0
|
||||
? "已完成"
|
||||
: "待命";
|
||||
elements.progressFill.style.width = `${Math.round(state.processing.progress * 100)}%`;
|
||||
elements.requestMeta.textContent = buildRequestMeta();
|
||||
elements.errorBanner.hidden = !state.error;
|
||||
elements.errorBanner.textContent = state.error;
|
||||
elements.generateButton.disabled = state.isGenerating;
|
||||
elements.generateButton.textContent = state.isGenerating
|
||||
? "生成中..."
|
||||
: "生成图片";
|
||||
elements.downloadButton.disabled = state.results.length === 0;
|
||||
renderProcessingSteps();
|
||||
renderResultStage();
|
||||
}
|
||||
|
||||
function renderExamplePrompts() {
|
||||
elements.examplePromptList.innerHTML = EXAMPLE_PROMPTS.map(
|
||||
(prompt) => `
|
||||
<button class="example-chip" type="button" data-prompt="${escapeHtml(prompt)}">
|
||||
${escapeHtml(truncate(prompt, 42))}
|
||||
</button>
|
||||
`,
|
||||
).join("");
|
||||
}
|
||||
|
||||
function renderProcessingSteps() {
|
||||
elements.processingSteps.innerHTML = PROCESSING_STAGES.map((stage, index) => {
|
||||
const status = resolveStageStatus(index);
|
||||
|
||||
return `
|
||||
<li class="processing-step processing-step-${status}">
|
||||
<div class="step-dot"></div>
|
||||
<div>
|
||||
<strong>${escapeHtml(stage.title)}</strong>
|
||||
<p>${escapeHtml(stage.detail)}</p>
|
||||
</div>
|
||||
</li>
|
||||
`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function renderResultStage() {
|
||||
if (state.isGenerating) {
|
||||
elements.resultStage.className = "result-stage loading-state";
|
||||
elements.resultStage.innerHTML = `
|
||||
<div class="result-skeleton">
|
||||
<div class="skeleton-art"></div>
|
||||
<div class="skeleton-line skeleton-line-wide"></div>
|
||||
<div class="skeleton-line"></div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.results.length === 0) {
|
||||
elements.resultStage.className = "result-stage empty-state";
|
||||
elements.resultStage.innerHTML = `
|
||||
<div class="empty-illustration"></div>
|
||||
<h4>这里会显示最新生成的图片</h4>
|
||||
<p>当前只保留最简流程:输入 Prompt、点击生成、等待完成、下载图片。</p>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const result = state.results[0];
|
||||
const revisedPromptMarkup = result.revisedPrompt
|
||||
? `
|
||||
<div class="result-copy">
|
||||
<span class="result-caption">模型调整后的 Prompt</span>
|
||||
<p>${escapeHtml(result.revisedPrompt)}</p>
|
||||
</div>
|
||||
`
|
||||
: "";
|
||||
|
||||
elements.resultStage.className = "result-stage";
|
||||
elements.resultStage.innerHTML = `
|
||||
<div class="image-frame">
|
||||
<img src="${result.previewUrl}" alt="${escapeHtml(result.fileName)}" />
|
||||
</div>
|
||||
<div class="result-copy">
|
||||
<span class="result-caption">你的 Prompt</span>
|
||||
<p>${escapeHtml(state.prompt)}</p>
|
||||
</div>
|
||||
${revisedPromptMarkup}
|
||||
`;
|
||||
}
|
||||
|
||||
function resolveStageStatus(index) {
|
||||
if (!state.isGenerating && state.results.length > 0) {
|
||||
return "done";
|
||||
}
|
||||
|
||||
if (!state.isGenerating) {
|
||||
return "idle";
|
||||
}
|
||||
|
||||
if (index < state.processing.activeStageIndex) {
|
||||
return "done";
|
||||
}
|
||||
|
||||
if (index === state.processing.activeStageIndex) {
|
||||
return "active";
|
||||
}
|
||||
|
||||
return "idle";
|
||||
}
|
||||
|
||||
function buildRequestMeta() {
|
||||
if (state.requestMeta) {
|
||||
return `${state.requestMeta.model} · ${state.requestMeta.size} · ${state.requestMeta.outputFormat.toUpperCase()} · ${state.requestMeta.durationMs} ms`;
|
||||
}
|
||||
|
||||
if (state.isGenerating) {
|
||||
return "复杂图片生成可能需要几十秒,请耐心等待当前步骤完成。";
|
||||
}
|
||||
|
||||
return "生成完成后会在这里显示耗时和输出信息。";
|
||||
}
|
||||
|
||||
function updatePromptStats() {
|
||||
elements.promptStats.textContent = buildPromptStats(state.prompt);
|
||||
}
|
||||
|
||||
function persistPrompt() {
|
||||
localStorage.setItem(STORAGE_KEY, state.prompt);
|
||||
}
|
||||
|
||||
function loadPrompt() {
|
||||
return String(localStorage.getItem(STORAGE_KEY) ?? "");
|
||||
}
|
||||
|
||||
function getSessionId() {
|
||||
const existing = localStorage.getItem(sessionStorageKey);
|
||||
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const sessionId = `browser-${crypto.randomUUID()}`;
|
||||
localStorage.setItem(sessionStorageKey, sessionId);
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
function setError(message) {
|
||||
state.error = message;
|
||||
stopProcessingTicker();
|
||||
state.processing = {
|
||||
progress: 0,
|
||||
activeStageIndex: -1,
|
||||
lead: "本次生成未完成,请调整 Prompt 后重试。",
|
||||
};
|
||||
render();
|
||||
}
|
||||
|
||||
function downloadResult(result) {
|
||||
const blob = base64ToBlob(result.base64, result.mimeType);
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = blobUrl;
|
||||
anchor.download = result.fileName;
|
||||
anchor.click();
|
||||
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 2000);
|
||||
}
|
||||
|
||||
function base64ToBlob(base64, mimeType) {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
|
||||
return new Blob([bytes], { type: mimeType });
|
||||
}
|
||||
|
||||
function resolveExtension(mimeType) {
|
||||
if (mimeType === "image/jpeg") {
|
||||
return "jpg";
|
||||
}
|
||||
|
||||
if (mimeType === "image/webp") {
|
||||
return "webp";
|
||||
}
|
||||
|
||||
return "png";
|
||||
}
|
||||
|
||||
function truncate(value, maxLength) {
|
||||
const text = String(value ?? "");
|
||||
|
||||
if (text.length <= maxLength) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return `${text.slice(0, maxLength - 1)}…`;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
157
public/index.html
Normal file
157
public/index.html
Normal file
@@ -0,0 +1,157 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Image Prompt Studio</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar panel">
|
||||
<div class="brand-lockup">
|
||||
<div class="brand-mark"></div>
|
||||
<div>
|
||||
<p class="eyebrow">Image Prompt Studio</p>
|
||||
<h1>简洁版图片生成</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<span class="sidebar-label">服务状态</span>
|
||||
<strong id="healthLabel">检查中</strong>
|
||||
<p class="sidebar-copy">
|
||||
服务端代理 OpenAI,浏览器不直接暴露 API Key。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<span class="sidebar-label">默认输出</span>
|
||||
<ul class="sidebar-list">
|
||||
<li id="modelLabel">gpt-image-2</li>
|
||||
<li>1024 × 1024</li>
|
||||
<li>PNG</li>
|
||||
<li>High quality</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<span class="sidebar-label">快捷方式</span>
|
||||
<p class="sidebar-copy">
|
||||
按 <kbd>Ctrl</kbd> + <kbd>Enter</kbd> 可直接开始生成。
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="workspace">
|
||||
<section class="hero panel">
|
||||
<div>
|
||||
<p class="eyebrow">Simple Flow</p>
|
||||
<h2>输入一句 Prompt,剩下的交给生成流程。</h2>
|
||||
<p class="hero-copy">
|
||||
参考你给的设计图,这一版收敛成苹果风格的轻量工作台:浅色背景、大圆角、弱边框和清晰分区。
|
||||
</p>
|
||||
</div>
|
||||
<div class="hero-orbital">
|
||||
<div class="orb orb-blue"></div>
|
||||
<div class="orb orb-pink"></div>
|
||||
<div class="orb orb-silver"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="composer panel">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="section-kicker">Prompt</p>
|
||||
<h3>Describe your image</h3>
|
||||
</div>
|
||||
<button
|
||||
id="clearPromptButton"
|
||||
class="secondary-button"
|
||||
type="button"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label class="prompt-label" for="promptInput"
|
||||
>输入你想生成的画面描述</label
|
||||
>
|
||||
<textarea
|
||||
id="promptInput"
|
||||
rows="8"
|
||||
placeholder="例如:一台银白色笔记本电脑放在浅灰色桌面上,清晨自然光从左侧落下,极简静物摄影,细节干净,像苹果官网产品页。"
|
||||
></textarea>
|
||||
|
||||
<div class="composer-meta">
|
||||
<span id="promptStats">等待输入</span>
|
||||
<span class="muted">系统将使用稳定默认参数生成 1 张图片</span>
|
||||
</div>
|
||||
|
||||
<div class="example-strip">
|
||||
<span class="example-label">示例 Prompt</span>
|
||||
<div id="examplePromptList" class="example-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="composer-footer">
|
||||
<div class="meta-pills">
|
||||
<span>Single prompt</span>
|
||||
<span>Server proxy</span>
|
||||
<span>Download ready</span>
|
||||
</div>
|
||||
<button id="generateButton" class="primary-button" type="button">
|
||||
生成图片
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="dashboard-grid">
|
||||
<article class="panel processing-panel">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="section-kicker">Processing</p>
|
||||
<h3>等待过程</h3>
|
||||
</div>
|
||||
<span id="processingBadge" class="status-badge">待命</span>
|
||||
</div>
|
||||
|
||||
<p id="processingLead" class="processing-lead">
|
||||
准备就绪,输入 Prompt 后即可开始生成。
|
||||
</p>
|
||||
|
||||
<div class="progress-track" aria-hidden="true">
|
||||
<div id="progressFill" class="progress-fill"></div>
|
||||
</div>
|
||||
|
||||
<ol id="processingSteps" class="processing-steps"></ol>
|
||||
</article>
|
||||
|
||||
<article class="panel result-panel">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="section-kicker">Result</p>
|
||||
<h3>生成结果</h3>
|
||||
</div>
|
||||
<button
|
||||
id="downloadButton"
|
||||
class="secondary-button"
|
||||
type="button"
|
||||
disabled
|
||||
>
|
||||
下载图片
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="errorBanner" class="error-banner" hidden></div>
|
||||
<p id="requestMeta" class="request-meta">
|
||||
生成完成后会在这里显示耗时和输出信息。
|
||||
</p>
|
||||
<div id="resultStage" class="result-stage"></div>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script type="module" src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
111
public/prompt-builder.js
Normal file
111
public/prompt-builder.js
Normal file
@@ -0,0 +1,111 @@
|
||||
export const STORAGE_KEY = "image-prompt-studio:simple-prompt:v1";
|
||||
|
||||
export const GENERATION_DEFAULTS = {
|
||||
size: "1024x1024",
|
||||
quality: "high",
|
||||
outputFormat: "png",
|
||||
background: "auto",
|
||||
count: 1,
|
||||
moderation: "auto",
|
||||
outputCompression: 90,
|
||||
};
|
||||
|
||||
export const EXAMPLE_PROMPTS = [
|
||||
"一只白色陶瓷杯放在浅灰色窗边桌面上,晨光柔和,极简产品摄影,干净背景,苹果官网质感。",
|
||||
"未来感电动车停在雾气很轻的海边公路,银色车身,阴天柔光,电影级写实海报。",
|
||||
"一间现代风格客厅,浅木地板,奶油白沙发,大面积自然光,室内设计杂志封面感。",
|
||||
"一款无线耳机悬浮在纯白背景中,蓝色柔和反射光,商业广告风,细节锐利。",
|
||||
];
|
||||
|
||||
export const PROCESSING_STAGES = [
|
||||
{
|
||||
title: "整理 Prompt",
|
||||
detail: "检查内容并准备发送到服务端。",
|
||||
thresholdMs: 1400,
|
||||
},
|
||||
{
|
||||
title: "连接 gpt-image-2",
|
||||
detail: "向 OpenAI Images API 发起图片生成请求。",
|
||||
thresholdMs: 5200,
|
||||
},
|
||||
{
|
||||
title: "等待渲染",
|
||||
detail: "模型正在生成高质量图片,这一步通常最耗时。",
|
||||
thresholdMs: 15000,
|
||||
},
|
||||
{
|
||||
title: "准备下载",
|
||||
detail: "接收 base64 图片并整理成浏览器可下载文件。",
|
||||
thresholdMs: 24000,
|
||||
},
|
||||
];
|
||||
|
||||
export function sanitizePrompt(input) {
|
||||
return String(input ?? "")
|
||||
.replace(/\r/g, "")
|
||||
.replace(/[ \t]+\n/g, "\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function buildPromptStats(prompt) {
|
||||
const cleanPrompt = sanitizePrompt(prompt);
|
||||
|
||||
if (!cleanPrompt) {
|
||||
return "输入一句清晰的画面描述即可开始。";
|
||||
}
|
||||
|
||||
const lineCount = cleanPrompt.split("\n").length;
|
||||
const charCount = cleanPrompt.length;
|
||||
|
||||
return `${charCount} 个字符 · ${lineCount} 行`;
|
||||
}
|
||||
|
||||
export function buildDownloadStem(prompt) {
|
||||
const normalized = sanitizePrompt(prompt)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\u4e00-\u9fa5\s-]/g, "")
|
||||
.trim()
|
||||
.replace(/\s+/g, "-")
|
||||
.slice(0, 48);
|
||||
|
||||
return normalized || "generated-image";
|
||||
}
|
||||
|
||||
export function createProcessingSnapshot(elapsedMs, isComplete = false) {
|
||||
if (isComplete) {
|
||||
return {
|
||||
progress: 1,
|
||||
activeStageIndex: PROCESSING_STAGES.length - 1,
|
||||
lead: "图片已生成完成,可以预览和下载。",
|
||||
};
|
||||
}
|
||||
|
||||
const safeElapsed = Math.max(0, elapsedMs);
|
||||
const finalThreshold =
|
||||
PROCESSING_STAGES[PROCESSING_STAGES.length - 1]?.thresholdMs ?? 24000;
|
||||
const progress = Math.min(0.92, safeElapsed / finalThreshold);
|
||||
const activeStageIndex = resolveActiveStageIndex(safeElapsed);
|
||||
const activeStage = PROCESSING_STAGES[activeStageIndex];
|
||||
|
||||
return {
|
||||
progress,
|
||||
activeStageIndex,
|
||||
lead: activeStage
|
||||
? `${activeStage.title}中... ${activeStage.detail}`
|
||||
: "正在处理,请稍候。",
|
||||
};
|
||||
}
|
||||
|
||||
function resolveActiveStageIndex(elapsedMs) {
|
||||
for (let index = PROCESSING_STAGES.length - 1; index >= 0; index -= 1) {
|
||||
if (elapsedMs >= PROCESSING_STAGES[index].thresholdMs) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
5
public/pwa-badge.svg
Normal file
5
public/pwa-badge.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="96" height="96" rx="24" fill="#0071E3"/>
|
||||
<circle cx="48" cy="48" r="24" fill="white"/>
|
||||
<circle cx="48" cy="48" r="11" fill="#0071E3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 257 B |
20
public/pwa-icon-maskable.svg
Normal file
20
public/pwa-icon-maskable.svg
Normal file
@@ -0,0 +1,20 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="512" height="512" fill="url(#bg)"/>
|
||||
<circle cx="256" cy="256" r="170" fill="white" fill-opacity="0.12"/>
|
||||
<path d="M168 188C168 158.177 192.177 134 222 134H290C319.823 134 344 158.177 344 188V206H374C392.778 206 408 221.222 408 240V318C408 336.778 392.778 352 374 352H138C119.222 352 104 336.778 104 318V240C104 221.222 119.222 206 138 206H168V188Z" fill="white"/>
|
||||
<path d="M202 188C202 176.954 210.954 168 222 168H290C301.046 168 310 176.954 310 188V206H202V188Z" fill="url(#accent)"/>
|
||||
<circle cx="256" cy="276" r="66" fill="url(#accent)"/>
|
||||
<circle cx="256" cy="276" r="36" fill="white"/>
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="74" y1="40" x2="454" y2="476" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#081428"/>
|
||||
<stop offset="0.42" stop-color="#0A84FF"/>
|
||||
<stop offset="0.82" stop-color="#5AC8FA"/>
|
||||
<stop offset="1" stop-color="#FF375F"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="accent" x1="198" y1="166" x2="322" y2="348" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#0A84FF"/>
|
||||
<stop offset="1" stop-color="#6DD6FF"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
19
public/pwa-icon.svg
Normal file
19
public/pwa-icon.svg
Normal file
@@ -0,0 +1,19 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="512" height="512" rx="120" fill="url(#bg)"/>
|
||||
<rect x="72" y="72" width="368" height="368" rx="104" fill="white" fill-opacity="0.1"/>
|
||||
<path d="M172 174C172 145.281 195.281 122 224 122H288C316.719 122 340 145.281 340 174V196H372C391.882 196 408 212.118 408 232V320C408 339.882 391.882 356 372 356H140C120.118 356 104 339.882 104 320V232C104 212.118 120.118 196 140 196H172V174Z" fill="white"/>
|
||||
<path d="M204 174C204 162.954 212.954 154 224 154H288C299.046 154 308 162.954 308 174V196H204V174Z" fill="url(#accent)"/>
|
||||
<circle cx="256" cy="274" r="64" fill="url(#accent)"/>
|
||||
<circle cx="256" cy="274" r="34" fill="white"/>
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="58" y1="36" x2="454" y2="478" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#0A84FF"/>
|
||||
<stop offset="0.58" stop-color="#5AC8FA"/>
|
||||
<stop offset="1" stop-color="#FF375F"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="accent" x1="194" y1="150" x2="321" y2="341" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#0A84FF"/>
|
||||
<stop offset="1" stop-color="#6DD6FF"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
656
public/styles.css
Normal file
656
public/styles.css
Normal file
@@ -0,0 +1,656 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
|
||||
"Segoe UI", sans-serif;
|
||||
--bg: #f5f5f7;
|
||||
--surface: rgba(255, 255, 255, 0.78);
|
||||
--surface-strong: #ffffff;
|
||||
--surface-muted: #f0f1f5;
|
||||
--line-soft: rgba(15, 23, 42, 0.08);
|
||||
--line-strong: rgba(15, 23, 42, 0.12);
|
||||
--ink-0: #111111;
|
||||
--ink-1: #3b3b3f;
|
||||
--ink-2: #6c7078;
|
||||
--accent: #0071e3;
|
||||
--accent-soft: rgba(0, 113, 227, 0.12);
|
||||
--success: #34c759;
|
||||
--danger: #ff3b30;
|
||||
--shadow-lg: 0 24px 80px rgba(16, 24, 40, 0.08);
|
||||
--shadow-sm: 0 12px 36px rgba(16, 24, 40, 0.05);
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at top left,
|
||||
rgba(0, 113, 227, 0.11),
|
||||
transparent 26%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at top right,
|
||||
rgba(255, 45, 85, 0.1),
|
||||
transparent 24%
|
||||
),
|
||||
linear-gradient(180deg, #fafafc 0%, #f4f5f8 100%);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
color: var(--ink-0);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
button,
|
||||
textarea,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
min-height: 220px;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: 26px;
|
||||
padding: 22px 24px;
|
||||
background: rgba(255, 255, 255, 0.74);
|
||||
color: var(--ink-0);
|
||||
outline: none;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.45);
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
box-shadow 160ms ease,
|
||||
background 160ms ease;
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
border-color: rgba(0, 113, 227, 0.26);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
box-shadow:
|
||||
0 0 0 4px rgba(0, 113, 227, 0.08),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
textarea::placeholder {
|
||||
color: #9096a0;
|
||||
}
|
||||
|
||||
kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 24px;
|
||||
padding: 0 8px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line-soft);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
box-shadow: inset 0 -1px 0 rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
width: min(1440px, calc(100% - 40px));
|
||||
margin: 0 auto;
|
||||
padding: 28px 0 44px;
|
||||
display: grid;
|
||||
grid-template-columns: 300px minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border-radius: 32px;
|
||||
border: 1px solid var(--line-soft);
|
||||
background: var(--surface);
|
||||
backdrop-filter: blur(18px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
padding: 28px 22px;
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.brand-lockup {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #0a84ff 0%, #5ac8fa 45%, #ff375f 100%);
|
||||
box-shadow: 0 8px 18px rgba(10, 132, 255, 0.22);
|
||||
}
|
||||
|
||||
.eyebrow,
|
||||
.section-kicker,
|
||||
.sidebar-label {
|
||||
margin: 0;
|
||||
color: var(--ink-2);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.sidebar h1,
|
||||
.hero h2,
|
||||
.composer h3,
|
||||
.processing-panel h3,
|
||||
.result-panel h3 {
|
||||
margin: 0;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.sidebar h1 {
|
||||
font-size: 1.18rem;
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
padding: 18px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid var(--line-soft);
|
||||
background: rgba(255, 255, 255, 0.62);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.sidebar-section strong {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.sidebar-copy {
|
||||
margin: 10px 0 0;
|
||||
color: var(--ink-2);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.sidebar-list {
|
||||
margin: 10px 0 0;
|
||||
padding-left: 1.1rem;
|
||||
color: var(--ink-1);
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.hero,
|
||||
.composer,
|
||||
.processing-panel,
|
||||
.result-panel {
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.3fr) minmax(220px, 0.7fr);
|
||||
gap: 24px;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero h2 {
|
||||
font-size: clamp(2rem, 4vw, 3.4rem);
|
||||
line-height: 0.96;
|
||||
max-width: 12ch;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
margin: 14px 0 0;
|
||||
color: var(--ink-1);
|
||||
max-width: 58ch;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.hero-orbital {
|
||||
position: relative;
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(2px);
|
||||
}
|
||||
|
||||
.orb-blue {
|
||||
inset: 18px auto auto 12px;
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(90, 200, 250, 0.9),
|
||||
rgba(0, 113, 227, 0.08) 72%
|
||||
);
|
||||
}
|
||||
|
||||
.orb-pink {
|
||||
inset: auto 18px 8px auto;
|
||||
width: 190px;
|
||||
height: 190px;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(255, 55, 95, 0.5),
|
||||
rgba(255, 255, 255, 0) 72%
|
||||
);
|
||||
}
|
||||
|
||||
.orb-silver {
|
||||
inset: 66px auto auto 76px;
|
||||
width: 164px;
|
||||
height: 164px;
|
||||
background: radial-gradient(
|
||||
circle at 30% 30%,
|
||||
rgba(255, 255, 255, 0.98),
|
||||
rgba(214, 217, 223, 0.66) 58%,
|
||||
rgba(255, 255, 255, 0.14) 100%
|
||||
);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.section-heading h3 {
|
||||
margin-top: 4px;
|
||||
font-size: 1.48rem;
|
||||
}
|
||||
|
||||
.prompt-label {
|
||||
display: inline-block;
|
||||
margin-bottom: 12px;
|
||||
color: var(--ink-1);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.composer-meta,
|
||||
.composer-footer,
|
||||
.example-strip {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.composer-meta {
|
||||
margin-top: 14px;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.example-strip {
|
||||
margin-top: 18px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.example-label {
|
||||
margin-top: 10px;
|
||||
color: var(--ink-2);
|
||||
font-size: 0.94rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.example-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.example-chip,
|
||||
.secondary-button,
|
||||
.primary-button {
|
||||
border-radius: 999px;
|
||||
min-height: 44px;
|
||||
border: 1px solid var(--line-strong);
|
||||
transition:
|
||||
transform 160ms ease,
|
||||
background 160ms ease,
|
||||
box-shadow 160ms ease,
|
||||
opacity 160ms ease;
|
||||
}
|
||||
|
||||
.example-chip,
|
||||
.secondary-button {
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
color: var(--ink-1);
|
||||
}
|
||||
|
||||
.example-chip {
|
||||
padding: 0.8rem 1rem;
|
||||
max-width: 100%;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
padding: 0.85rem 1.35rem;
|
||||
background: linear-gradient(180deg, #1482f0 0%, #0071e3 100%);
|
||||
color: #ffffff;
|
||||
border-color: transparent;
|
||||
box-shadow: 0 14px 26px rgba(0, 113, 227, 0.24);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
padding: 0.8rem 1.15rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.example-chip:hover,
|
||||
.primary-button:hover,
|
||||
.secondary-button:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.primary-button:disabled,
|
||||
.secondary-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.composer-footer {
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.meta-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.meta-pills span,
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 34px;
|
||||
padding: 0.45rem 0.85rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 113, 227, 0.08);
|
||||
border: 1px solid rgba(0, 113, 227, 0.12);
|
||||
color: #0f4c9a;
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 0.9fr) minmax(420px, 1.1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.processing-lead {
|
||||
margin: 0 0 20px;
|
||||
color: var(--ink-1);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: #e7ebf1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
width: 0;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #5ac8fa 0%, #0071e3 100%);
|
||||
transition: width 180ms ease;
|
||||
}
|
||||
|
||||
.processing-steps {
|
||||
list-style: none;
|
||||
margin: 22px 0 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.processing-step {
|
||||
display: grid;
|
||||
grid-template-columns: 14px minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.processing-step strong {
|
||||
display: block;
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.processing-step p {
|
||||
margin: 6px 0 0;
|
||||
color: var(--ink-2);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.step-dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
margin-top: 4px;
|
||||
border: 1px solid #d0d5dc;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.processing-step-done .step-dot {
|
||||
background: var(--success);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.processing-step-active .step-dot {
|
||||
background: var(--accent);
|
||||
border-color: transparent;
|
||||
box-shadow: 0 0 0 6px rgba(0, 113, 227, 0.12);
|
||||
}
|
||||
|
||||
.request-meta {
|
||||
margin: 0;
|
||||
color: var(--ink-2);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
margin-top: 16px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 59, 48, 0.16);
|
||||
background: rgba(255, 59, 48, 0.08);
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.result-stage {
|
||||
margin-top: 18px;
|
||||
padding: 18px;
|
||||
border-radius: 28px;
|
||||
border: 1px solid var(--line-soft);
|
||||
background: rgba(255, 255, 255, 0.76);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.empty-state {
|
||||
min-height: 520px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.result-skeleton {
|
||||
width: min(100%, 480px);
|
||||
}
|
||||
|
||||
.skeleton-art,
|
||||
.skeleton-line {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(238, 241, 246, 1) 20%,
|
||||
rgba(247, 248, 250, 1) 50%,
|
||||
rgba(238, 241, 246, 1) 80%
|
||||
);
|
||||
background-size: 220% 100%;
|
||||
animation: shimmer 1.6s linear infinite;
|
||||
}
|
||||
|
||||
.skeleton-art {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: 28px;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
height: 14px;
|
||||
margin-top: 16px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.skeleton-line-wide {
|
||||
width: 78%;
|
||||
}
|
||||
|
||||
.skeleton-line:not(.skeleton-line-wide) {
|
||||
width: 56%;
|
||||
}
|
||||
|
||||
.empty-illustration {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
border-radius: 36px;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 30% 30%,
|
||||
rgba(90, 200, 250, 0.95),
|
||||
rgba(0, 113, 227, 0.08) 72%
|
||||
),
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.82),
|
||||
rgba(239, 241, 246, 0.82)
|
||||
);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.empty-state h4 {
|
||||
margin: 18px 0 10px;
|
||||
font-size: 1.16rem;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0;
|
||||
color: var(--ink-2);
|
||||
max-width: 36ch;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.image-frame {
|
||||
overflow: hidden;
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(180deg, #eef2f7 0%, #f7f9fc 100%);
|
||||
}
|
||||
|
||||
.image-frame img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.result-copy {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.result-caption {
|
||||
color: var(--ink-2);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.result-copy p {
|
||||
margin: 8px 0 0;
|
||||
color: var(--ink-1);
|
||||
line-height: 1.65;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: -20% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.app-shell,
|
||||
.dashboard-grid,
|
||||
.hero {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.hero h2 {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.app-shell {
|
||||
width: min(100% - 20px, 100%);
|
||||
padding-top: 14px;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.sidebar,
|
||||
.hero,
|
||||
.composer,
|
||||
.processing-panel,
|
||||
.result-panel {
|
||||
padding: 22px;
|
||||
border-radius: 28px;
|
||||
}
|
||||
|
||||
.section-heading,
|
||||
.composer-meta,
|
||||
.composer-footer,
|
||||
.example-strip {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.example-list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.example-chip {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
100
public/sw.js
Normal file
100
public/sw.js
Normal file
@@ -0,0 +1,100 @@
|
||||
const STATIC_CACHE_PREFIX = "image-prompt-studio-static";
|
||||
const STATIC_CACHE_NAME = `${STATIC_CACHE_PREFIX}-v1`;
|
||||
|
||||
function shouldCache(requestUrl) {
|
||||
if (requestUrl.origin !== self.location.origin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
requestUrl.pathname.startsWith("/_next/static/") ||
|
||||
requestUrl.pathname === "/manifest.webmanifest" ||
|
||||
requestUrl.pathname === "/pwa-icon.svg" ||
|
||||
requestUrl.pathname === "/pwa-icon-maskable.svg" ||
|
||||
requestUrl.pathname === "/pwa-badge.svg"
|
||||
);
|
||||
}
|
||||
|
||||
self.addEventListener("install", () => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
Promise.all([
|
||||
self.clients.claim(),
|
||||
caches.keys().then((cacheNames) =>
|
||||
Promise.all(
|
||||
cacheNames
|
||||
.filter(
|
||||
(cacheName) =>
|
||||
cacheName.startsWith(STATIC_CACHE_PREFIX) &&
|
||||
cacheName !== STATIC_CACHE_NAME,
|
||||
)
|
||||
.map((cacheName) => caches.delete(cacheName)),
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
if (event.request.method !== "GET") {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestUrl = new URL(event.request.url);
|
||||
|
||||
if (!shouldCache(requestUrl)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.respondWith(
|
||||
caches.open(STATIC_CACHE_NAME).then(async (cache) => {
|
||||
const cachedResponse = await cache.match(event.request);
|
||||
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
const networkResponse = await fetch(event.request);
|
||||
|
||||
if (networkResponse.ok) {
|
||||
cache.put(event.request, networkResponse.clone());
|
||||
}
|
||||
|
||||
return networkResponse;
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
|
||||
const destinationUrl =
|
||||
typeof event.notification.data?.url === "string"
|
||||
? new URL(event.notification.data.url, self.location.origin).href
|
||||
: `${self.location.origin}/studio`;
|
||||
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then(
|
||||
async (windowClients) => {
|
||||
for (const client of windowClients) {
|
||||
if ("focus" in client) {
|
||||
await client.focus();
|
||||
|
||||
if ("navigate" in client) {
|
||||
await client.navigate(destinationUrl);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (self.clients.openWindow) {
|
||||
await self.clients.openWindow(destinationUrl);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user