feat: refine tool UI and result actions

This commit is contained in:
2026-04-24 18:11:12 +08:00
parent 6064b1c809
commit 8254994df8
5 changed files with 895 additions and 425 deletions

View File

@@ -0,0 +1,24 @@
use tauri::{AppHandle, Manager};
use tauri_plugin_opener::OpenerExt;
#[tauri::command]
pub async fn reveal_path(app: AppHandle, path: String) -> Result<(), String> {
app.opener()
.reveal_item_in_dir(path)
.map_err(|error| error.to_string())
}
#[tauri::command]
pub async fn open_generated_dir(app: AppHandle) -> Result<(), String> {
let dir = app
.path()
.app_data_dir()
.map_err(|error| error.to_string())?
.join("images")
.join("generated");
std::fs::create_dir_all(&dir).map_err(|error| error.to_string())?;
app.opener()
.open_path(dir.to_string_lossy().to_string(), None::<&str>)
.map_err(|error| error.to_string())
}

View File

@@ -1,3 +1,4 @@
pub mod dialog; pub mod dialog;
pub mod file;
pub mod generation; pub mod generation;
pub mod provider; pub mod provider;

View File

@@ -54,6 +54,8 @@ pub fn run() {
commands::provider::upsert_provider, commands::provider::upsert_provider,
commands::provider::delete_provider, commands::provider::delete_provider,
commands::dialog::pick_material_images, commands::dialog::pick_material_images,
commands::file::reveal_path,
commands::file::open_generated_dir,
commands::generation::create_generation_task, commands::generation::create_generation_task,
commands::generation::generate_image, commands::generation::generate_image,
]) ])

View File

@@ -66,19 +66,23 @@ const defaultProviderForm: ProviderForm = {
}; };
const initialGenerationSteps: GenerationStep[] = [ const initialGenerationSteps: GenerationStep[] = [
{ label: '保存 Provider 配置', status: 'pending' }, { label: '保存配置', status: 'pending' },
{ label: '提交生成任务', status: 'pending' }, { label: '提交任务', status: 'pending' },
{ label: '请求并等待图像模型返回', status: 'pending' }, { label: '等待模型返回', status: 'pending' },
{ label: '保存图片到应用数据文件夹', status: 'pending' }, { label: '保存到应用文件夹', status: 'pending' },
{ label: '更新本次打开图片列表', status: 'pending' }, { label: '更新结果列表', status: 'pending' },
]; ];
function App() { function App() {
const [providers, setProviders] = useState<ProviderConfig[]>([]); const [providers, setProviders] = useState<ProviderConfig[]>([]);
const [providerForm, setProviderForm] = useState<ProviderForm>(defaultProviderForm); const [providerForm, setProviderForm] = useState<ProviderForm>(defaultProviderForm);
const [prompt, setPrompt] = useState('一只赛博朋克风格的橘猫坐在霓虹灯下'); const [prompt, setPrompt] = useState('一只赛博朋克风格的橘猫坐在霓虹灯下');
const [imageSize, setImageSize] = useState('1024x1024');
const [imageQuality, setImageQuality] = useState('auto');
const [status, setStatus] = useState('准备就绪'); const [status, setStatus] = useState('准备就绪');
const [isBusy, setIsBusy] = useState(false); const [isBusy, setIsBusy] = useState(false);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [previewImage, setPreviewImage] = useState<SessionImage | null>(null);
const [sessionImages, setSessionImages] = useState<SessionImage[]>([]); const [sessionImages, setSessionImages] = useState<SessionImage[]>([]);
const [materialPaths, setMaterialPaths] = useState<string[]>([]); const [materialPaths, setMaterialPaths] = useState<string[]>([]);
const [generationSteps, setGenerationSteps] = useState<GenerationStep[]>(initialGenerationSteps); const [generationSteps, setGenerationSteps] = useState<GenerationStep[]>(initialGenerationSteps);
@@ -124,13 +128,11 @@ function App() {
async function saveProvider() { async function saveProvider() {
setIsBusy(true); setIsBusy(true);
setStatus('正在保存 Provider 配置...'); setStatus('正在保存配置...');
try { try {
await invoke('upsert_provider', { await invoke('upsert_provider', { input: providerForm });
input: providerForm,
});
await refreshProviders(); await refreshProviders();
setStatus('Provider 已保存,可以直接生成图片'); setStatus('配置已保存');
} catch (error) { } catch (error) {
setStatus(`保存失败:${formatError(error)}`); setStatus(`保存失败:${formatError(error)}`);
} finally { } finally {
@@ -140,14 +142,14 @@ function App() {
async function deleteProvider(id: string) { async function deleteProvider(id: string) {
setIsBusy(true); setIsBusy(true);
setStatus('正在删除 Provider...'); setStatus('正在删除配置...');
try { try {
await invoke('delete_provider', { id }); await invoke('delete_provider', { id });
await refreshProviders(); await refreshProviders();
if (providerForm.id === id) { if (providerForm.id === id) {
setProviderForm(defaultProviderForm); setProviderForm(defaultProviderForm);
} }
setStatus('Provider 已删除'); setStatus('配置已删除');
} catch (error) { } catch (error) {
setStatus(`删除失败:${formatError(error)}`); setStatus(`删除失败:${formatError(error)}`);
} finally { } finally {
@@ -167,7 +169,7 @@ function App() {
image_model: provider.image_model ?? defaultProviderForm.image_model, image_model: provider.image_model ?? defaultProviderForm.image_model,
enabled: provider.enabled, enabled: provider.enabled,
})); }));
setStatus('已载入 Provider修改后点击保存即可覆盖当前配置'); setStatus('已切换模型配置');
} }
async function generateImage() { async function generateImage() {
@@ -176,9 +178,7 @@ function App() {
setStatus('正在生成图片...'); setStatus('正在生成图片...');
try { try {
startStep(0); startStep(0);
await invoke('upsert_provider', { await invoke('upsert_provider', { input: providerForm });
input: providerForm,
});
startStep(1); startStep(1);
await new Promise((resolve) => window.setTimeout(resolve, 120)); await new Promise((resolve) => window.setTimeout(resolve, 120));
startStep(2); startStep(2);
@@ -187,8 +187,8 @@ function App() {
provider_id: providerForm.id, provider_id: providerForm.id,
prompt, prompt,
model: providerForm.image_model, model: providerForm.image_model,
size: '1024x1024', size: imageSize,
quality: 'auto', quality: imageQuality,
image_paths: materialPaths, image_paths: materialPaths,
}, },
}); });
@@ -206,7 +206,7 @@ function App() {
...images, ...images,
]); ]);
setStep(4, 'done'); setStep(4, 'done');
setStatus(`生成完成,已保存到应用数据文件夹${result.asset.file_path}`); setStatus(`生成完成:${result.asset.file_path}`);
} catch (error) { } catch (error) {
setGenerationSteps((steps) => setGenerationSteps((steps) =>
steps.map((step) => (step.status === 'active' ? { ...step, status: 'error' } : step)), steps.map((step) => (step.status === 'active' ? { ...step, status: 'error' } : step)),
@@ -218,15 +218,15 @@ function App() {
} }
async function pickMaterialImages() { async function pickMaterialImages() {
setStatus('正在打开素材图片选择器...'); setStatus('正在打开素材选择器...');
try { try {
const paths = await invoke<string[]>('pick_material_images'); const paths = await invoke<string[]>('pick_material_images');
if (paths.length === 0) { if (paths.length === 0) {
setStatus('未选择素材图片'); setStatus('未选择素材');
return; return;
} }
setMaterialPaths((current) => Array.from(new Set([...current, ...paths]))); setMaterialPaths((current) => Array.from(new Set([...current, ...paths])));
setStatus(`已导入 ${paths.length} 张素材图片`); setStatus(`已导入 ${paths.length} 张素材`);
} catch (error) { } catch (error) {
setStatus(`打开素材选择器失败:${formatError(error)}`); setStatus(`打开素材选择器失败:${formatError(error)}`);
} }
@@ -236,104 +236,66 @@ function App() {
setMaterialPaths((current) => current.filter((item) => item !== path)); setMaterialPaths((current) => current.filter((item) => item !== path));
} }
async function revealImage(path: string) {
try {
await invoke('reveal_path', { path });
} catch (error) {
setStatus(`打开文件位置失败:${formatError(error)}`);
}
}
async function openGeneratedDir() {
try {
await invoke('open_generated_dir');
} catch (error) {
setStatus(`打开保存目录失败:${formatError(error)}`);
}
}
useEffect(() => { useEffect(() => {
refreshProviders().catch(() => setStatus('后端未启动或数据库初始化失败')); refreshProviders().catch(() => setStatus('后端未启动或数据库初始化失败'));
}, []); }, []);
return ( return (
<main className="app"> <main className="app-shell">
<section className="hero"> <header className="topbar">
<div className="brand">
<div className="brand-mark"></div>
<div>
<h1>Image Draw AI</h1> <h1>Image Draw AI</h1>
<p></p> <p></p>
</section>
<section className="panel">
<div className="panel-title">
<div>
<p className="eyebrow">Provider</p>
<h2></h2>
</div>
<button onClick={refreshProviders} disabled={isBusy}></button>
</div>
<div className="grid two">
<label>
ID
<input
value={providerForm.id}
onChange={(event) => updateProviderForm('id', event.target.value)}
placeholder="default-openai"
/>
</label>
<label>
<input
value={providerForm.name}
onChange={(event) => updateProviderForm('name', event.target.value)}
placeholder="OpenAI / 中转站"
/>
</label>
</div>
<label>
Base URL
<input
value={providerForm.base_url}
onChange={(event) => updateProviderForm('base_url', event.target.value)}
placeholder="https://api.openai.com/v1"
/>
<small> API /v1 </small>
</label>
<label>
API Key
<input
value={providerForm.api_key}
onChange={(event) => updateProviderForm('api_key', event.target.value)}
placeholder="sk-... 或中转站 key"
type="password"
/>
</label>
<div className="grid two">
<label>
<input
value={providerForm.text_model}
onChange={(event) => updateProviderForm('text_model', event.target.value)}
placeholder="gpt-5"
/>
</label>
<label>
<input
value={providerForm.image_model}
onChange={(event) => updateProviderForm('image_model', event.target.value)}
placeholder="gpt-image-2"
/>
</label>
</div>
<div className="row">
<button onClick={saveProvider} disabled={isBusy}> Provider</button>
</div>
</section>
<section className="panel">
<div className="panel-title">
<div>
<p className="eyebrow">Generate</p>
<h2></h2>
</div> </div>
</div> </div>
<div className="topbar-actions">
<div className="current-provider">
<span></span>
<strong>{providerForm.image_model}</strong>
</div>
<button className="ghost" onClick={() => setIsSettingsOpen(true)}></button>
</div>
</header>
<section className="workspace">
<aside className="compose-card">
<div className="section-heading">
<span></span>
<strong>{materialPaths.length > 0 ? '素材生成' : '文字生成'}</strong>
</div>
<label className="field prompt-field">
<span></span>
<textarea value={prompt} onChange={(event) => setPrompt(event.target.value)} /> <textarea value={prompt} onChange={(event) => setPrompt(event.target.value)} />
</label>
<div className="material-panel">
<div className="material-toolbar"> <div className="material-toolbar">
<button onClick={pickMaterialImages} disabled={isBusy}></button> <button onClick={pickMaterialImages} disabled={isBusy}></button>
{materialPaths.length > 0 && ( {materialPaths.length > 0 && (
<button onClick={() => setMaterialPaths([])} disabled={isBusy}></button> <button className="ghost" onClick={() => setMaterialPaths([])} disabled={isBusy}></button>
)} )}
<span>{materialPaths.length > 0 ? `已选择 ${materialPaths.length} 张素材,将使用图像编辑模式` : '未选择素材,将使用文生图模式'}</span> <span>{materialPaths.length > 0 ? `${materialPaths.length} 张素材` : '未导入素材'}</span>
</div> </div>
<p className="drop-hint"> PNG / JPG / WEBP使</p>
{materialPaths.length > 0 && ( {materialPaths.length > 0 && (
<div className="material-grid"> <div className="material-grid">
@@ -341,19 +303,51 @@ function App() {
<article className="material-card" key={path}> <article className="material-card" key={path}>
<img src={convertFileSrc(path)} alt="素材图片" /> <img src={convertFileSrc(path)} alt="素材图片" />
<button onClick={() => removeMaterialImage(path)} disabled={isBusy}></button> <button onClick={() => removeMaterialImage(path)} disabled={isBusy}></button>
<span>{path}</span>
</article> </article>
))} ))}
</div> </div>
)} )}
</div>
<button className="primary" onClick={generateImage} disabled={isBusy}> <div className="params-card">
{isBusy ? '正在生成...' : '生成图片'} <div className="section-heading">
<span></span>
<strong></strong>
</div>
<div className="segmented">
{['1024x1024', '1024x1536', '1536x1024'].map((size) => (
<button
className={imageSize === size ? 'active' : ''}
key={size}
onClick={() => setImageSize(size)}
disabled={isBusy}
>
{size}
</button> </button>
))}
</div>
<div className="segmented compact">
{['auto', 'high', 'medium', 'low'].map((quality) => (
<button
className={imageQuality === quality ? 'active' : ''}
key={quality}
onClick={() => setImageQuality(quality)}
disabled={isBusy}
>
{quality}
</button>
))}
</div>
</div>
<button className="generate-button" onClick={generateImage} disabled={isBusy}>
{isBusy ? '正在生成...' : '开始生成'}
</button>
<div className={`progress-card ${isBusy ? 'is-loading' : ''}`}> <div className={`progress-card ${isBusy ? 'is-loading' : ''}`}>
<div className="spinner" aria-hidden="true" /> <div className="spinner" aria-hidden="true" />
<div className="progress-content"> <div className="progress-content">
<strong>{isBusy ? '生成流程进行中' : '生成流程'}</strong> <strong>{isBusy ? '生成中' : '生成流程'}</strong>
<ol className="step-list"> <ol className="step-list">
{generationSteps.map((step) => ( {generationSteps.map((step) => (
<li className={`step ${step.status}`} key={step.label}> <li className={`step ${step.status}`} key={step.label}>
@@ -364,27 +358,35 @@ function App() {
</ol> </ol>
</div> </div>
</div> </div>
<p className="status">{status}</p>
</section>
<section className="panel"> <p className="status">{status}</p>
<div className="panel-title"> </aside>
<div>
<p className="eyebrow">Session</p> <section className="result-card">
<h2></h2> <div className="section-heading result-heading">
<span></span>
<div className="heading-actions">
<strong> {sessionImages.length} </strong>
<button className="ghost mini" onClick={openGeneratedDir}></button>
</div> </div>
<span className="count">{sessionImages.length} </span>
</div> </div>
{sessionImages.length === 0 ? ( {sessionImages.length === 0 ? (
<p></p> <div className="empty-state">
<div></div>
<p></p>
</div>
) : ( ) : (
<div className="image-grid"> <div className="image-grid">
{sessionImages.map((image) => ( {sessionImages.map((image) => (
<article className="image-card" key={image.id}> <article className="image-card" key={image.id}>
<button className="image-preview-button" onClick={() => setPreviewImage(image)}>
<img src={convertFileSrc(image.file_path)} alt={image.prompt} /> <img src={convertFileSrc(image.file_path)} alt={image.prompt} />
</button>
<div> <div>
<strong>{image.created_at}</strong> <strong>{image.created_at}</strong>
<p>{image.prompt}</p> <p>{image.prompt}</p>
<button className="ghost mini" onClick={() => revealImage(image.file_path)}></button>
<span>{image.file_path}</span> <span>{image.file_path}</span>
</div> </div>
</article> </article>
@@ -392,11 +394,93 @@ function App() {
</div> </div>
)} )}
</section> </section>
</section>
<section className="panel"> {isSettingsOpen && (
<h2> Provider</h2> <div className="drawer-layer">
<button className="drawer-mask" onClick={() => setIsSettingsOpen(false)} aria-label="关闭设置" />
<aside className="settings-drawer">
<div className="drawer-header">
<div>
<span></span>
<h2></h2>
</div>
<button className="ghost" onClick={() => setIsSettingsOpen(false)}></button>
</div>
<div className="settings-content">
<section className="settings-group">
<div className="section-heading">
<span></span>
<strong></strong>
</div>
<div className="grid two">
<label className="field">
<span> ID</span>
<input value={providerForm.id} onChange={(event) => updateProviderForm('id', event.target.value)} />
</label>
<label className="field">
<span></span>
<input value={providerForm.name} onChange={(event) => updateProviderForm('name', event.target.value)} />
</label>
</div>
</section>
<section className="settings-group">
<div className="section-heading">
<span></span>
<strong> / OpenAI</strong>
</div>
<label className="field">
<span>Base URL</span>
<input
value={providerForm.base_url}
onChange={(event) => updateProviderForm('base_url', event.target.value)}
placeholder="https://api.openai.com/v1"
/>
<small> API /v1 </small>
</label>
<label className="field">
<span>API Key</span>
<input
value={providerForm.api_key}
onChange={(event) => updateProviderForm('api_key', event.target.value)}
placeholder="sk-... 或中转站 key"
type="password"
/>
</label>
</section>
<section className="settings-group">
<div className="section-heading">
<span></span>
<strong></strong>
</div>
<div className="grid two">
<label className="field">
<span></span>
<input value={providerForm.text_model} onChange={(event) => updateProviderForm('text_model', event.target.value)} />
</label>
<label className="field">
<span></span>
<input value={providerForm.image_model} onChange={(event) => updateProviderForm('image_model', event.target.value)} />
</label>
</div>
</section>
<div className="drawer-actions">
<button onClick={saveProvider} disabled={isBusy}></button>
<button className="ghost" onClick={refreshProviders} disabled={isBusy}></button>
</div>
<div className="saved-providers">
<div className="section-heading">
<span></span>
<strong>{providers.length} </strong>
</div>
{providers.length === 0 ? ( {providers.length === 0 ? (
<p> Provider</p> <p className="muted"></p>
) : ( ) : (
<ul className="provider-list"> <ul className="provider-list">
{providers.map((provider) => ( {providers.map((provider) => (
@@ -407,15 +491,38 @@ function App() {
<span>{provider.base_url}</span> <span>{provider.base_url}</span>
<span>{provider.image_model}</span> <span>{provider.image_model}</span>
</button> </button>
<button className="danger" onClick={() => deleteProvider(provider.id)} disabled={isBusy}> <button className="danger" onClick={() => deleteProvider(provider.id)} disabled={isBusy}></button>
</button>
</div> </div>
</li> </li>
))} ))}
</ul> </ul>
)} )}
</div>
</div>
</aside>
</div>
)}
{previewImage && (
<div className="preview-layer">
<button className="drawer-mask" onClick={() => setPreviewImage(null)} aria-label="关闭预览" />
<section className="preview-modal">
<div className="preview-header">
<div>
<span></span>
<h2>{previewImage.created_at}</h2>
</div>
<button className="ghost" onClick={() => setPreviewImage(null)}></button>
</div>
<img src={convertFileSrc(previewImage.file_path)} alt={previewImage.prompt} />
<div className="preview-info">
<p>{previewImage.prompt}</p>
<button className="ghost" onClick={() => revealImage(previewImage.file_path)}></button>
<span>{previewImage.file_path}</span>
</div>
</section> </section>
</div>
)}
</main> </main>
); );
} }

View File

@@ -1,7 +1,11 @@
:root { :root {
color: #f8fafc; color: #172033;
background: radial-gradient(circle at top left, #1e3a8a 0, #0f172a 34%, #020617 100%); background: #eef3fb;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
} }
body { body {
@@ -12,129 +16,487 @@ button, textarea, input {
font: inherit; font: inherit;
} }
button {
border: 0;
border-radius: 14px;
color: #ffffff;
background: #2563eb;
cursor: pointer;
font-weight: 700;
padding: 11px 16px;
transition: transform 0.16s ease, box-shadow 0.16s ease, opacity 0.16s ease;
}
button:hover:not(:disabled) {
box-shadow: 0 12px 28px rgba(37, 99, 235, 0.22);
transform: translateY(-1px);
}
button:disabled { button:disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.62; opacity: 0.58;
} }
.app { button.ghost {
max-width: 1040px; background: #eef4ff;
margin: 0 auto; color: #2563eb;
padding: 48px 24px;
} }
.hero { button.danger {
margin-bottom: 24px; background: #fff1f2;
color: #e11d48;
} }
.eyebrow { button.mini {
color: #38bdf8; border-radius: 10px;
font-size: 12px; font-size: 12px;
font-weight: 800; padding: 7px 10px;
letter-spacing: 0.12em;
margin: 0 0 8px;
text-transform: uppercase;
} }
h1 { .app-shell {
font-size: 48px; min-height: 100vh;
margin: 0 0 12px; padding: 22px;
} }
h2 { .topbar {
margin: 0 0 16px; align-items: center;
} background: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(203, 213, 225, 0.8);
.panel { border-radius: 24px;
background: rgba(15, 23, 42, 0.86); box-shadow: 0 18px 50px rgba(15, 23, 42, 0.08);
border: 1px solid rgba(148, 163, 184, 0.24);
border-radius: 20px;
padding: 24px;
margin-bottom: 20px;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.32);
}
.panel-title {
align-items: flex-start;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
gap: 16px; margin: 0 auto 18px;
margin-bottom: 16px; max-width: 1440px;
padding: 16px 18px;
} }
.row { .brand {
align-items: center;
display: flex;
gap: 14px;
}
.brand-mark {
align-items: center;
background: linear-gradient(135deg, #2563eb, #7c3aed);
border-radius: 18px;
color: #ffffff;
display: grid;
font-size: 24px;
font-weight: 900;
height: 52px;
place-items: center;
width: 52px;
}
.brand h1 {
font-size: 22px;
line-height: 1;
margin: 0 0 7px;
}
.brand p, .muted {
color: #64748b;
margin: 0;
}
.topbar-actions {
align-items: center;
display: flex; display: flex;
gap: 12px; gap: 12px;
margin-top: 16px; }
.current-provider {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 16px;
display: grid;
gap: 4px;
min-width: 160px;
padding: 9px 12px;
}
.current-provider span, .section-heading span, .drawer-header span {
color: #64748b;
font-size: 12px;
font-weight: 800;
}
.current-provider strong {
color: #1d4ed8;
font-size: 14px;
}
.workspace {
display: grid;
gap: 18px;
grid-template-columns: minmax(360px, 440px) minmax(0, 1fr);
margin: 0 auto;
max-width: 1440px;
}
.compose-card, .result-card, .settings-drawer {
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(203, 213, 225, 0.9);
border-radius: 28px;
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.1);
}
.compose-card {
align-self: start;
display: grid;
gap: 18px;
padding: 20px;
}
.result-card {
min-height: calc(100vh - 132px);
padding: 20px;
}
.section-heading {
align-items: center;
display: flex;
justify-content: space-between;
gap: 12px;
}
.heading-actions {
align-items: center;
display: flex;
gap: 10px;
}
.section-heading strong {
color: #0f172a;
}
.field {
color: #334155;
display: grid;
gap: 8px;
font-size: 14px;
font-weight: 800;
}
input, textarea {
width: 100%;
border: 1px solid #dbe4f0;
border-radius: 18px;
color: #172033;
background: #f8fafc;
outline: none;
padding: 13px 14px;
}
input:focus, textarea:focus {
background: #ffffff;
border-color: #60a5fa;
box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.18);
}
.prompt-field textarea {
min-height: 190px;
resize: vertical;
}
small {
color: #64748b;
font-weight: 500;
} }
.grid { .grid {
display: grid; display: grid;
gap: 16px; gap: 14px;
} }
.grid.two { .grid.two {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
label { .material-panel {
color: #cbd5e1; background: #f8fafc;
border: 1px dashed #cbd5e1;
border-radius: 22px;
padding: 14px;
}
.material-toolbar {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.material-toolbar span {
color: #64748b;
font-size: 13px;
}
.drop-hint {
color: #94a3b8;
font-size: 12px;
margin: 10px 0 0;
}
.material-grid {
display: grid;
gap: 10px;
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
margin-top: 12px;
}
.material-card {
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 18px;
display: grid; display: grid;
gap: 8px; gap: 8px;
font-size: 14px; overflow: hidden;
font-weight: 700; padding-bottom: 8px;
margin-bottom: 16px;
} }
small { .material-card img {
color: #94a3b8; aspect-ratio: 1;
font-weight: 500; background: #e2e8f0;
object-fit: cover;
width: 100%;
} }
button { .material-card button {
border: 0; justify-self: center;
border-radius: 12px; padding: 6px 10px;
padding: 10px 16px;
color: #e2e8f0;
background: #334155;
cursor: pointer;
} }
button.primary { .generate-button {
background: linear-gradient(135deg, #2563eb, #7c3aed); background: linear-gradient(135deg, #2563eb, #7c3aed);
border-radius: 18px;
font-size: 17px;
padding: 15px 18px;
}
.progress-card {
align-items: flex-start;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 22px;
display: flex;
gap: 14px;
padding: 15px;
}
.spinner {
border: 3px solid rgba(148, 163, 184, 0.32);
border-top-color: #2563eb;
border-radius: 999px;
flex: 0 0 auto;
height: 24px;
width: 24px;
}
.progress-card.is-loading .spinner {
animation: spin 0.8s linear infinite;
}
.progress-content {
display: grid;
gap: 10px;
}
.step-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
list-style: none;
margin: 0;
padding: 0;
}
.step {
align-items: center;
background: #eef2f7;
border-radius: 999px;
color: #64748b;
display: flex;
font-size: 12px;
gap: 6px;
padding: 6px 9px;
}
.step span {
background: #94a3b8;
border-radius: 999px;
height: 7px;
width: 7px;
}
.step.active {
background: #dbeafe;
color: #1d4ed8;
font-weight: 800; font-weight: 800;
} }
input, textarea { .step.active span {
width: 100%; background: #2563eb;
box-sizing: border-box;
border: 1px solid #475569;
border-radius: 12px;
padding: 12px;
color: #f8fafc;
background: #020617;
outline: none;
} }
input:focus, textarea:focus { .step.done {
border-color: #38bdf8; background: #dcfce7;
box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.16); color: #15803d;
} }
textarea { .step.done span {
min-height: 140px; background: #22c55e;
margin-bottom: 16px; }
resize: vertical;
.step.error {
background: #ffe4e6;
color: #e11d48;
font-weight: 800;
}
.step.error span {
background: #e11d48;
} }
.status { .status {
color: #93c5fd; color: #475569;
font-size: 13px;
line-height: 1.5;
margin: 0;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.result-heading {
margin-bottom: 16px;
}
.empty-state {
align-items: center;
border: 1px dashed #cbd5e1;
border-radius: 26px;
color: #64748b;
display: grid;
min-height: 420px;
place-items: center;
text-align: center;
}
.empty-state div {
color: #334155;
font-size: 22px;
font-weight: 900;
margin-bottom: 8px;
}
.image-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
}
.image-card {
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 22px;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08);
overflow: hidden;
}
.image-card img {
aspect-ratio: 1;
background: #e2e8f0;
display: block;
object-fit: cover;
width: 100%;
}
.image-card div {
display: grid;
gap: 8px;
padding: 13px;
}
.image-card p {
color: #475569;
margin: 0;
}
.image-card span {
color: #94a3b8;
font-size: 12px;
overflow-wrap: anywhere;
}
.drawer-layer {
inset: 0;
position: fixed;
z-index: 20;
}
.drawer-mask {
background: rgba(15, 23, 42, 0.28);
border-radius: 0;
height: 100%;
inset: 0;
padding: 0;
position: absolute;
width: 100%;
}
.drawer-mask:hover {
box-shadow: none !important;
transform: none !important;
}
.settings-drawer {
animation: slideIn 0.22s ease-out;
border-radius: 28px 0 0 28px;
display: grid;
grid-template-rows: auto 1fr;
height: 100%;
margin-left: auto;
max-width: 520px;
overflow: hidden;
position: relative;
width: min(92vw, 520px);
}
.drawer-header {
align-items: center;
border-bottom: 1px solid #e2e8f0;
display: flex;
justify-content: space-between;
padding: 20px;
}
.drawer-header h2 {
margin: 4px 0 0;
}
.settings-content {
display: grid;
gap: 16px;
overflow: auto;
padding: 20px;
}
.drawer-actions {
display: flex;
gap: 10px;
}
.saved-providers {
border-top: 1px solid #e2e8f0;
display: grid;
gap: 14px;
padding-top: 16px;
}
.provider-list { .provider-list {
display: grid; display: grid;
gap: 12px; gap: 10px;
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
@@ -149,209 +511,183 @@ textarea {
.link-button { .link-button {
align-items: flex-start; align-items: flex-start;
background: rgba(30, 41, 59, 0.72); background: #f8fafc;
border: 1px solid rgba(148, 163, 184, 0.2); border: 1px solid #e2e8f0;
color: #172033;
display: grid; display: grid;
gap: 6px; gap: 5px;
text-align: left; text-align: left;
width: 100%; width: 100%;
} }
.danger {
background: rgba(185, 28, 28, 0.9);
color: #fee2e2;
}
.link-button span { .link-button span {
color: #94a3b8; color: #64748b;
font-size: 12px;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes slideIn {
from { transform: translateX(28px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@media (max-width: 980px) {
.workspace {
grid-template-columns: 1fr;
}
.result-card {
min-height: 420px;
}
}
@media (max-width: 720px) { @media (max-width: 720px) {
.grid.two { .app-shell {
grid-template-columns: 1fr; padding: 12px;
} }
h1 { .topbar, .topbar-actions {
font-size: 36px; align-items: stretch;
flex-direction: column;
} }
.provider-item { .grid.two, .provider-item {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
.count { .params-card {
color: #93c5fd; background: #f8fafc;
font-weight: 800; border: 1px solid #e2e8f0;
} border-radius: 22px;
.image-grid {
display: grid; display: grid;
gap: 16px; gap: 12px;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); padding: 14px;
} }
.image-card { .segmented {
background: rgba(2, 6, 23, 0.72); background: #eef2f7;
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 16px; border-radius: 16px;
overflow: hidden; display: grid;
gap: 6px;
grid-template-columns: repeat(3, 1fr);
padding: 6px;
} }
.image-card img { .segmented.compact {
aspect-ratio: 1; grid-template-columns: repeat(4, 1fr);
background: #020617; }
.segmented button {
background: transparent;
color: #64748b;
padding: 9px 8px;
}
.segmented button:hover:not(:disabled) {
box-shadow: none;
}
.segmented button.active {
background: #ffffff;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08);
color: #1d4ed8;
}
.image-preview-button {
background: transparent;
border-radius: 0;
display: block; display: block;
object-fit: cover; padding: 0;
width: 100%; width: 100%;
} }
.image-card div { .image-preview-button:hover:not(:disabled) {
display: grid; box-shadow: none;
gap: 8px; transform: none;
padding: 12px;
} }
.image-card p { .preview-layer {
color: #cbd5e1; inset: 0;
position: fixed;
z-index: 30;
}
.preview-modal {
animation: previewIn 0.22s ease-out;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 30px;
box-shadow: 0 30px 90px rgba(15, 23, 42, 0.24);
display: grid;
gap: 14px;
left: 50%;
max-height: 90vh;
max-width: min(920px, 92vw);
overflow: auto;
padding: 18px;
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 760px;
}
.preview-header {
align-items: center;
display: flex;
justify-content: space-between;
gap: 12px;
}
.preview-header span {
color: #64748b;
font-size: 12px;
font-weight: 800;
}
.preview-header h2 {
font-size: 18px;
margin: 4px 0 0;
}
.preview-modal > img {
background: #eef2f7;
border-radius: 22px;
max-height: 62vh;
object-fit: contain;
width: 100%;
}
.preview-info {
display: grid;
gap: 8px;
}
.preview-info p {
color: #334155;
margin: 0; margin: 0;
} }
.image-card span { .preview-info span {
color: #94a3b8; color: #94a3b8;
font-size: 12px; font-size: 12px;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.progress-card { @keyframes previewIn {
align-items: flex-start; from { opacity: 0; transform: translate(-50%, -48%) scale(0.98); }
background: rgba(2, 6, 23, 0.5); to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
border: 1px solid rgba(148, 163, 184, 0.22); }
border-radius: 16px;
display: flex; .settings-group {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 22px;
display: grid;
gap: 14px; gap: 14px;
margin-top: 16px; padding: 14px;
padding: 16px;
}
.spinner {
border: 3px solid rgba(148, 163, 184, 0.28);
border-top-color: #38bdf8;
border-radius: 999px;
flex: 0 0 auto;
height: 22px;
width: 22px;
}
.progress-card.is-loading .spinner {
animation: spin 0.8s linear infinite;
}
.progress-content {
display: grid;
gap: 10px;
}
.step-list {
display: grid;
gap: 8px;
list-style: none;
margin: 0;
padding: 0;
}
.step {
align-items: center;
color: #94a3b8;
display: flex;
gap: 8px;
}
.step span {
background: #475569;
border-radius: 999px;
height: 9px;
width: 9px;
}
.step.active {
color: #93c5fd;
font-weight: 800;
}
.step.active span {
background: #38bdf8;
box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.16);
}
.step.done {
color: #86efac;
}
.step.done span {
background: #22c55e;
}
.step.error {
color: #fca5a5;
font-weight: 800;
}
.step.error span {
background: #ef4444;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.material-toolbar {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
}
.material-toolbar span {
color: #94a3b8;
}
.material-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
margin-bottom: 16px;
}
.material-card {
background: rgba(2, 6, 23, 0.72);
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 14px;
display: grid;
gap: 8px;
overflow: hidden;
padding-bottom: 10px;
}
.material-card img {
aspect-ratio: 1;
background: #020617;
object-fit: cover;
width: 100%;
}
.material-card button {
justify-self: center;
padding: 6px 10px;
}
.material-card span {
color: #94a3b8;
font-size: 11px;
overflow-wrap: anywhere;
padding: 0 10px;
} }