Initial commit: AI-фоторедактор (Codex-редактирование, ComfyUI upscale, HEIC, история версий, Docker, Gitea Actions)
This commit is contained in:
575
public/app.js
Normal file
575
public/app.js
Normal file
@@ -0,0 +1,575 @@
|
||||
"use strict";
|
||||
|
||||
const MAX_FILE_SIZE = 25 * 1024 * 1024;
|
||||
const ACCEPTED_TYPES = new Set([
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/heic",
|
||||
"image/heif"
|
||||
]);
|
||||
|
||||
const healthStatus = document.querySelector("#health-status");
|
||||
const healthText = document.querySelector("#health-text");
|
||||
|
||||
const dropZone = document.querySelector("#drop-zone");
|
||||
const photoInput = document.querySelector("#photo-input");
|
||||
const dropTitle = document.querySelector("#drop-title");
|
||||
const fileMeta = document.querySelector("#file-meta");
|
||||
|
||||
const promptInput = document.querySelector("#prompt");
|
||||
const promptCounter = document.querySelector("#prompt-counter");
|
||||
|
||||
const editButton = document.querySelector("#edit-button");
|
||||
const upscaleButton = document.querySelector("#upscale-button");
|
||||
|
||||
const workspace = document.querySelector("#workspace");
|
||||
const sourceImage = document.querySelector("#source-image");
|
||||
const sourcePlaceholder = document.querySelector("#source-placeholder");
|
||||
const resultImage = document.querySelector("#result-image");
|
||||
const resultPlaceholder = document.querySelector("#result-placeholder");
|
||||
const resultLabel = document.querySelector("#result-label");
|
||||
|
||||
const jobStatus = document.querySelector("#job-status");
|
||||
const jobStatusText = document.querySelector("#job-status-text");
|
||||
const downloadLink = document.querySelector("#download-link");
|
||||
|
||||
let currentVersionId = null;
|
||||
let historyVersions = [];
|
||||
let sourceObjectUrl = null;
|
||||
let isBusy = false;
|
||||
let fileSelectionSequence = 0;
|
||||
|
||||
function isHeicFile(file) {
|
||||
const type = String(file?.type || "").toLowerCase();
|
||||
const name = String(file?.name || "").toLowerCase();
|
||||
|
||||
return (
|
||||
type === "image/heic" ||
|
||||
type === "image/heif" ||
|
||||
name.endsWith(".heic") ||
|
||||
name.endsWith(".heif")
|
||||
);
|
||||
}
|
||||
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes < 1024 * 1024) {
|
||||
return `${Math.max(1, Math.round(bytes / 1024))} КБ`;
|
||||
}
|
||||
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} МБ`;
|
||||
}
|
||||
|
||||
function setHealthState(state, message, title = "") {
|
||||
healthStatus.className = `health-status health-${state}`;
|
||||
healthText.textContent = message;
|
||||
healthStatus.title = title;
|
||||
}
|
||||
|
||||
function setJobStatus(kind, message) {
|
||||
if (!message) {
|
||||
jobStatus.hidden = true;
|
||||
jobStatus.removeAttribute("data-kind");
|
||||
jobStatusText.textContent = "";
|
||||
return;
|
||||
}
|
||||
|
||||
jobStatus.hidden = false;
|
||||
jobStatus.dataset.kind = kind;
|
||||
jobStatusText.textContent = message;
|
||||
}
|
||||
|
||||
function setBusy(busy) {
|
||||
isBusy = busy;
|
||||
workspace.setAttribute("aria-busy", String(busy));
|
||||
|
||||
editButton.disabled = busy || !currentVersionId;
|
||||
upscaleButton.disabled = busy || !currentVersionId;
|
||||
promptInput.disabled = busy;
|
||||
photoInput.disabled = busy;
|
||||
|
||||
dropZone.setAttribute("aria-disabled", String(busy));
|
||||
}
|
||||
|
||||
function resetResult() {
|
||||
resultImage.hidden = true;
|
||||
resultImage.removeAttribute("src");
|
||||
resultPlaceholder.hidden = false;
|
||||
resultLabel.textContent = "";
|
||||
downloadLink.hidden = true;
|
||||
downloadLink.removeAttribute("href");
|
||||
downloadLink.removeAttribute("download");
|
||||
}
|
||||
|
||||
function clearSourcePreview() {
|
||||
if (sourceObjectUrl) {
|
||||
URL.revokeObjectURL(sourceObjectUrl);
|
||||
sourceObjectUrl = null;
|
||||
}
|
||||
|
||||
sourceImage.hidden = true;
|
||||
sourceImage.removeAttribute("src");
|
||||
sourcePlaceholder.hidden = false;
|
||||
}
|
||||
|
||||
function showSourcePreview(url) {
|
||||
if (sourceObjectUrl) {
|
||||
URL.revokeObjectURL(sourceObjectUrl);
|
||||
sourceObjectUrl = null;
|
||||
}
|
||||
|
||||
sourceImage.src = url;
|
||||
sourceImage.hidden = false;
|
||||
sourcePlaceholder.hidden = true;
|
||||
}
|
||||
|
||||
function showSourcePreviewFromUrl(url, title, meta) {
|
||||
showSourcePreview(url);
|
||||
|
||||
dropZone.classList.add("has-file");
|
||||
dropTitle.textContent = title || "Текущая фотография";
|
||||
fileMeta.textContent = meta || "Готово к обработке";
|
||||
}
|
||||
|
||||
async function selectFile(file) {
|
||||
const selectionToken = ++fileSelectionSequence;
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileType = String(file.type || "").toLowerCase();
|
||||
|
||||
if (!ACCEPTED_TYPES.has(fileType) && !isHeicFile(file)) {
|
||||
setJobStatus(
|
||||
"error",
|
||||
"Поддерживаются только фотографии JPEG, PNG, WebP или HEIC."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
setJobStatus(
|
||||
"error",
|
||||
"Файл слишком большой. Максимальный размер — 25 МБ."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
setJobStatus("loading", "Загружаем фотографию в историю…");
|
||||
|
||||
const form = new FormData();
|
||||
form.append("photo", file, file.name);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/history/import", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
cache: "no-store"
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let message = `Не удалось загрузить фотографию. HTTP ${response.status}.`;
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
|
||||
if (contentType.includes("application/json")) {
|
||||
const data = await response.json().catch(() => null);
|
||||
|
||||
if (data?.error) {
|
||||
message = data.error;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (selectionToken !== fileSelectionSequence) {
|
||||
return;
|
||||
}
|
||||
|
||||
historyVersions.push(data.version);
|
||||
currentVersionId = data.version.id;
|
||||
|
||||
renderHistory();
|
||||
resetResult();
|
||||
showSourcePreviewFromUrl(
|
||||
data.version.url,
|
||||
file.name,
|
||||
`${formatFileSize(file.size)} · готово к обработке`
|
||||
);
|
||||
setJobStatus("", "");
|
||||
setBusy(false);
|
||||
} catch (error) {
|
||||
if (selectionToken !== fileSelectionSequence) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(false);
|
||||
setJobStatus(
|
||||
"error",
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Не удалось загрузить фотографию."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function openFilePicker() {
|
||||
if (!isBusy) {
|
||||
photoInput.click();
|
||||
}
|
||||
}
|
||||
|
||||
function preventDragDefaults(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
async function readJsonResponse(response) {
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
|
||||
if (!contentType.includes("application/json")) {
|
||||
throw new Error(
|
||||
`Сервер вернул неожиданный ответ HTTP ${response.status}.`
|
||||
);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function runJob(kind) {
|
||||
if (isBusy) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentVersionId) {
|
||||
setJobStatus("error", "Сначала выберите фотографию.");
|
||||
return;
|
||||
}
|
||||
|
||||
const prompt = promptInput.value.trim();
|
||||
|
||||
if (kind === "edit" && !prompt) {
|
||||
setJobStatus(
|
||||
"error",
|
||||
"Введите промпт — опишите, как нужно изменить фотографию."
|
||||
);
|
||||
promptInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const isEdit = kind === "edit";
|
||||
const endpoint = isEdit ? "/api/edit" : "/api/upscale";
|
||||
const form = new FormData();
|
||||
|
||||
form.append("sourceId", currentVersionId);
|
||||
|
||||
if (isEdit) {
|
||||
form.append("prompt", prompt);
|
||||
}
|
||||
|
||||
resetResult();
|
||||
setBusy(true);
|
||||
setJobStatus(
|
||||
"loading",
|
||||
isEdit
|
||||
? "Codex редактирует фотографию… Обычно 1–3 минуты."
|
||||
: "Увеличиваем разрешение фотографии…"
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
cache: "no-store"
|
||||
});
|
||||
|
||||
const data = await readJsonResponse(response);
|
||||
|
||||
if (!response.ok || !data.ok) {
|
||||
throw new Error(data.error || "Не удалось обработать фотографию.");
|
||||
}
|
||||
|
||||
const version = data.version;
|
||||
|
||||
if (version) {
|
||||
historyVersions.push(version);
|
||||
currentVersionId = version.id;
|
||||
renderHistory();
|
||||
}
|
||||
|
||||
const url = version?.url || data.result?.url;
|
||||
|
||||
resultImage.src = url;
|
||||
resultImage.hidden = false;
|
||||
resultPlaceholder.hidden = true;
|
||||
|
||||
resultLabel.textContent = isEdit ? "Редактирование · Codex" : "4× upscale";
|
||||
|
||||
downloadLink.href = url;
|
||||
downloadLink.download =
|
||||
data.result?.filename ||
|
||||
(isEdit ? "edited-photo.jpg" : "upscaled-photo.png");
|
||||
downloadLink.hidden = false;
|
||||
|
||||
const summary = isEdit && data.summary
|
||||
? String(data.summary).replace(/\s+/g, " ").trim().slice(0, 180)
|
||||
: "";
|
||||
|
||||
setJobStatus(
|
||||
"success",
|
||||
isEdit
|
||||
? summary
|
||||
? `Готово — фотография отредактирована. Codex: ${summary}`
|
||||
: "Готово — фотография отредактирована."
|
||||
: `Готово — разрешение увеличено с помощью ${data.job?.model || "upscale-модели"}.`
|
||||
);
|
||||
|
||||
if (window.matchMedia("(max-width: 1040px)").matches) {
|
||||
resultImage.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center"
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setJobStatus(
|
||||
"error",
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Не удалось обработать фотографию."
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function formatHistoryDate(iso) {
|
||||
try {
|
||||
return new Date(iso).toLocaleString("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
});
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function historyItemBadge(version) {
|
||||
if (version.kind === "original") {
|
||||
return "загрузка";
|
||||
}
|
||||
|
||||
if (version.kind === "upscale") {
|
||||
return "4× upscale";
|
||||
}
|
||||
|
||||
return "Codex";
|
||||
}
|
||||
|
||||
function renderHistory() {
|
||||
const list = document.querySelector("#history-list");
|
||||
const empty = document.querySelector("#history-empty");
|
||||
|
||||
list.querySelectorAll(".history-item").forEach((item) => item.remove());
|
||||
empty.hidden = historyVersions.length > 0;
|
||||
|
||||
for (const version of historyVersions) {
|
||||
const item = document.createElement("button");
|
||||
item.type = "button";
|
||||
item.className = "history-item";
|
||||
item.dataset.id = version.id;
|
||||
item.setAttribute("aria-label", version.label || "Версия");
|
||||
|
||||
if (version.id === currentVersionId) {
|
||||
item.classList.add("is-current");
|
||||
}
|
||||
|
||||
const thumb = document.createElement("img");
|
||||
thumb.src = version.url;
|
||||
thumb.alt = "";
|
||||
thumb.loading = "lazy";
|
||||
|
||||
const meta = document.createElement("span");
|
||||
meta.className = "history-item-meta";
|
||||
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = version.label || "Версия";
|
||||
|
||||
const sub = document.createElement("span");
|
||||
const size =
|
||||
version.width && version.height
|
||||
? ` · ${version.width}×${version.height}`
|
||||
: "";
|
||||
sub.textContent = `${formatHistoryDate(version.createdAt)}${size}`;
|
||||
|
||||
meta.append(title, sub);
|
||||
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "history-item-badge";
|
||||
badge.textContent = historyItemBadge(version);
|
||||
|
||||
item.append(thumb, meta, badge);
|
||||
item.addEventListener("click", () => selectHistoryVersion(version));
|
||||
list.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
function selectHistoryVersion(version) {
|
||||
if (isBusy) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentVersionId = version.id;
|
||||
|
||||
resetResult();
|
||||
showSourcePreviewFromUrl(
|
||||
version.url,
|
||||
version.label || "Версия",
|
||||
version.width && version.height
|
||||
? `${version.width}×${version.height}`
|
||||
: "Готово к обработке"
|
||||
);
|
||||
renderHistory();
|
||||
setJobStatus("", "");
|
||||
}
|
||||
|
||||
async function loadHistoryOnStart() {
|
||||
try {
|
||||
const response = await fetch("/api/history", {
|
||||
cache: "no-store"
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
historyVersions = Array.isArray(data.versions) ? data.versions : [];
|
||||
|
||||
if (historyVersions.length > 0) {
|
||||
const latest = historyVersions[historyVersions.length - 1];
|
||||
currentVersionId = latest.id;
|
||||
showSourcePreviewFromUrl(
|
||||
latest.url,
|
||||
latest.label || "Текущая фотография",
|
||||
latest.width && latest.height
|
||||
? `${latest.width}×${latest.height}`
|
||||
: "Готово к обработке"
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
historyVersions = [];
|
||||
} finally {
|
||||
renderHistory();
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkHealth() {
|
||||
setHealthState("loading", "Проверяем сервер обработки…");
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/health", {
|
||||
cache: "no-store"
|
||||
});
|
||||
const data = await readJsonResponse(response);
|
||||
|
||||
if (data.ok) {
|
||||
const modelTitle = data.selectedUpscaleModel
|
||||
? `Upscale: ${data.selectedUpscaleModel}`
|
||||
: "";
|
||||
|
||||
setHealthState(
|
||||
"ok",
|
||||
"Сервер обработки: доступен",
|
||||
modelTitle
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.comfyAvailable) {
|
||||
const missing = Array.isArray(data.missingNodes)
|
||||
? data.missingNodes.join(", ")
|
||||
: "";
|
||||
|
||||
setHealthState(
|
||||
"warning",
|
||||
data.message || "Сервер настроен не полностью",
|
||||
missing ? `Отсутствуют узлы: ${missing}` : ""
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setHealthState(
|
||||
"error",
|
||||
"Сервер обработки: недоступен",
|
||||
data.message || ""
|
||||
);
|
||||
} catch (error) {
|
||||
setHealthState(
|
||||
"error",
|
||||
"Сервер обработки: недоступен",
|
||||
error instanceof Error ? error.message : ""
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
dropZone.addEventListener("click", openFilePicker);
|
||||
|
||||
dropZone.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
openFilePicker();
|
||||
}
|
||||
});
|
||||
|
||||
photoInput.addEventListener("change", () => {
|
||||
selectFile(photoInput.files?.[0]);
|
||||
photoInput.value = "";
|
||||
});
|
||||
|
||||
["dragenter", "dragover", "dragleave", "drop"].forEach((eventName) => {
|
||||
dropZone.addEventListener(eventName, preventDragDefaults);
|
||||
});
|
||||
|
||||
["dragenter", "dragover"].forEach((eventName) => {
|
||||
dropZone.addEventListener(eventName, () => {
|
||||
if (!isBusy) {
|
||||
dropZone.classList.add("is-dragging");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
["dragleave", "drop"].forEach((eventName) => {
|
||||
dropZone.addEventListener(eventName, () => {
|
||||
dropZone.classList.remove("is-dragging");
|
||||
});
|
||||
});
|
||||
|
||||
dropZone.addEventListener("drop", (event) => {
|
||||
if (!isBusy) {
|
||||
selectFile(event.dataTransfer?.files?.[0]);
|
||||
}
|
||||
});
|
||||
|
||||
promptInput.addEventListener("input", () => {
|
||||
promptCounter.textContent = `${promptInput.value.length} / 1000`;
|
||||
});
|
||||
|
||||
editButton.addEventListener("click", () => runJob("edit"));
|
||||
upscaleButton.addEventListener("click", () => runJob("upscale"));
|
||||
|
||||
window.addEventListener("beforeunload", () => {
|
||||
if (sourceObjectUrl) {
|
||||
URL.revokeObjectURL(sourceObjectUrl);
|
||||
}
|
||||
});
|
||||
|
||||
checkHealth();
|
||||
loadHistoryOnStart();
|
||||
229
public/index.html
Normal file
229
public/index.html
Normal file
@@ -0,0 +1,229 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1"
|
||||
>
|
||||
<meta
|
||||
name="description"
|
||||
content="Локальный редактор фотографий по текстовому промпту с увеличением разрешения."
|
||||
>
|
||||
<title>Кадр — AI-фоторедактор</title>
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%23202321'/%3E%3Ctext x='32' y='45' font-size='38' font-weight='bold' text-anchor='middle' fill='%23ffffff' font-family='sans-serif'%3E%D0%9A%3C/text%3E%3C/svg%3E"
|
||||
>
|
||||
<link rel="stylesheet" href="/style.css?v=7">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<a class="brand" href="/" aria-label="Кадр, главная">
|
||||
<span class="brand-mark" aria-hidden="true">К</span>
|
||||
<span class="brand-name">Кадр</span>
|
||||
</a>
|
||||
|
||||
<div
|
||||
id="health-status"
|
||||
class="health-status health-loading"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span class="health-dot" aria-hidden="true"></span>
|
||||
<span id="health-text">Проверяем сервер обработки…</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="editor-layout">
|
||||
<section class="control-panel" aria-labelledby="page-title">
|
||||
<div class="intro">
|
||||
<p class="eyebrow">Локальная AI-студия</p>
|
||||
<h1 id="page-title">Меняйте кадр словами.</h1>
|
||||
<p class="lead">
|
||||
Загрузите фотографию, опишите желаемое изменение или увеличьте
|
||||
разрешение одним нажатием.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="control-stack">
|
||||
<div class="field-group">
|
||||
<span class="field-label">Фотография</span>
|
||||
|
||||
<div
|
||||
id="drop-zone"
|
||||
class="drop-zone"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Выбрать или перетащить фотографию"
|
||||
>
|
||||
<input
|
||||
id="photo-input"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/heic,image/heif,.heic,.heif"
|
||||
hidden
|
||||
>
|
||||
|
||||
<span class="upload-symbol" aria-hidden="true">+</span>
|
||||
|
||||
<span class="drop-copy">
|
||||
<strong id="drop-title">Перетащите фото сюда</strong>
|
||||
<span id="file-meta">
|
||||
или нажмите, чтобы выбрать JPEG, PNG или WebP
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span class="choose-chip">Выбрать</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label class="field-label" for="prompt">Промпт</label>
|
||||
<textarea
|
||||
id="prompt"
|
||||
rows="4"
|
||||
maxlength="1000"
|
||||
placeholder="Например: сделай закат, фотореалистично"
|
||||
></textarea>
|
||||
<div class="field-foot">
|
||||
<span>Опишите свет, настроение и нужные детали.</span>
|
||||
<span id="prompt-counter">0 / 1000</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button
|
||||
id="edit-button"
|
||||
class="button button-primary"
|
||||
type="button"
|
||||
disabled
|
||||
>
|
||||
<span>Редактировать</span>
|
||||
<span class="button-arrow" aria-hidden="true">→</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
id="upscale-button"
|
||||
class="button button-secondary"
|
||||
type="button"
|
||||
disabled
|
||||
>
|
||||
<span class="spark" aria-hidden="true">✦</span>
|
||||
<span>Увеличить разрешение</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="privacy-note">
|
||||
Фото передаётся только вашему локальному серверу ComfyUI.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="workspace"
|
||||
class="workspace"
|
||||
aria-labelledby="workspace-title"
|
||||
>
|
||||
<div class="workspace-head">
|
||||
<div>
|
||||
<p class="workspace-kicker">Предпросмотр</p>
|
||||
<h2 id="workspace-title">До и после</h2>
|
||||
</div>
|
||||
<span class="local-badge">LAN · локально</span>
|
||||
</div>
|
||||
|
||||
<div class="comparison">
|
||||
<figure class="photo-frame">
|
||||
<figcaption>Исходник</figcaption>
|
||||
|
||||
<div class="image-stage">
|
||||
<img
|
||||
id="source-image"
|
||||
alt="Выбранная исходная фотография"
|
||||
hidden
|
||||
>
|
||||
|
||||
<div id="source-placeholder" class="placeholder">
|
||||
<span class="placeholder-frame" aria-hidden="true"></span>
|
||||
<strong>Здесь появится фотография</strong>
|
||||
<span>Выберите файл слева, чтобы начать</span>
|
||||
</div>
|
||||
</div>
|
||||
</figure>
|
||||
|
||||
<figure class="photo-frame result-frame">
|
||||
<figcaption>
|
||||
<span>Результат</span>
|
||||
<span id="result-label" class="result-kind"></span>
|
||||
</figcaption>
|
||||
|
||||
<div class="image-stage">
|
||||
<img
|
||||
id="result-image"
|
||||
alt="Обработанная фотография"
|
||||
hidden
|
||||
>
|
||||
|
||||
<div id="result-placeholder" class="placeholder">
|
||||
<span class="placeholder-spark" aria-hidden="true">✦</span>
|
||||
<strong>Результат появится здесь</strong>
|
||||
<span>Редактирование через Codex — 1–3 минуты, апскейл — несколько секунд</span>
|
||||
</div>
|
||||
</div>
|
||||
</figure>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="job-status"
|
||||
class="job-status"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
hidden
|
||||
>
|
||||
<div class="status-row">
|
||||
<span class="status-spinner" aria-hidden="true"></span>
|
||||
<span id="job-status-text"></span>
|
||||
</div>
|
||||
<div class="progress-track" aria-hidden="true">
|
||||
<span class="progress-bar"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="result-actions">
|
||||
<a
|
||||
id="download-link"
|
||||
class="download-link"
|
||||
href="#"
|
||||
download
|
||||
hidden
|
||||
>
|
||||
Скачать результат
|
||||
<span aria-hidden="true">↓</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<section class="history-section" aria-labelledby="history-title">
|
||||
<div class="history-head">
|
||||
<h2 id="history-title">История версий</h2>
|
||||
<span class="history-hint">
|
||||
Нажмите на версию, чтобы вернуться к ней и продолжить правки
|
||||
</span>
|
||||
</div>
|
||||
<div id="history-list" class="history-list">
|
||||
<div id="history-empty" class="history-empty">
|
||||
Пока пусто — загрузите фото, и все версии появятся здесь
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<noscript>
|
||||
<div class="noscript">
|
||||
Для работы редактора необходимо включить JavaScript.
|
||||
</div>
|
||||
</noscript>
|
||||
|
||||
<script src="/app.js?v=7" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
955
public/style.css
Normal file
955
public/style.css
Normal file
@@ -0,0 +1,955 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--paper: #f3f0e8;
|
||||
--panel: #fbfaf6;
|
||||
--ink: #171817;
|
||||
--muted: #666962;
|
||||
--line: #d9d7ce;
|
||||
--dark: #202321;
|
||||
--accent: #6c5ce7;
|
||||
--accent-dark: #5544d2;
|
||||
--accent-soft: #e7e2ff;
|
||||
--success: #20825c;
|
||||
--warning: #b06e1f;
|
||||
--danger: #b84343;
|
||||
--shadow: 0 26px 70px rgba(28, 31, 29, 0.12);
|
||||
--radius-large: 28px;
|
||||
--radius-medium: 18px;
|
||||
--radius-small: 12px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
html {
|
||||
min-width: 320px;
|
||||
min-height: 100%;
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(circle at 85% 10%, rgba(108, 92, 231, 0.12), transparent 28rem),
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.28), transparent 55%),
|
||||
var(--paper);
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||
"Segoe UI", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
button,
|
||||
textarea,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
[role="button"],
|
||||
a {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
min-height: 78px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 16px clamp(20px, 4vw, 64px);
|
||||
border-bottom: 1px solid rgba(23, 24, 23, 0.1);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: grid;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
place-items: center;
|
||||
border-radius: 11px;
|
||||
color: white;
|
||||
background: var(--dark);
|
||||
font-size: 18px;
|
||||
font-weight: 750;
|
||||
transform: rotate(-3deg);
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 19px;
|
||||
font-weight: 760;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.health-status {
|
||||
display: inline-flex;
|
||||
max-width: 440px;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 620;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.health-dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
background: #a9aaa6;
|
||||
box-shadow: 0 0 0 4px rgba(169, 170, 166, 0.15);
|
||||
}
|
||||
|
||||
.health-loading .health-dot {
|
||||
animation: health-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.health-ok .health-dot {
|
||||
background: var(--success);
|
||||
box-shadow: 0 0 0 4px rgba(32, 130, 92, 0.13);
|
||||
}
|
||||
|
||||
.health-warning .health-dot {
|
||||
background: var(--warning);
|
||||
box-shadow: 0 0 0 4px rgba(176, 110, 31, 0.13);
|
||||
}
|
||||
|
||||
.health-error .health-dot {
|
||||
background: var(--danger);
|
||||
box-shadow: 0 0 0 4px rgba(184, 67, 67, 0.13);
|
||||
}
|
||||
|
||||
.editor-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(310px, 0.72fr) minmax(520px, 1.45fr);
|
||||
gap: clamp(24px, 4vw, 58px);
|
||||
width: min(1500px, 100%);
|
||||
margin: 0 auto;
|
||||
padding: clamp(28px, 5vw, 72px) clamp(20px, 4vw, 64px) 64px;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.intro {
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.eyebrow,
|
||||
.workspace-kicker {
|
||||
margin: 0 0 10px;
|
||||
color: var(--accent-dark);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
max-width: 520px;
|
||||
margin-bottom: 18px;
|
||||
font-size: clamp(40px, 5vw, 70px);
|
||||
font-weight: 720;
|
||||
line-height: 0.96;
|
||||
letter-spacing: -0.065em;
|
||||
}
|
||||
|
||||
.lead {
|
||||
max-width: 520px;
|
||||
margin-bottom: 0;
|
||||
color: var(--muted);
|
||||
font-size: clamp(15px, 1.5vw, 18px);
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.control-stack {
|
||||
display: grid;
|
||||
gap: 23px;
|
||||
}
|
||||
|
||||
.field-group {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 13px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
min-height: 104px;
|
||||
padding: 17px;
|
||||
border: 1.5px dashed #bdbbb1;
|
||||
border-radius: var(--radius-medium);
|
||||
background: rgba(251, 250, 246, 0.65);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
background 160ms ease,
|
||||
box-shadow 160ms ease,
|
||||
transform 160ms ease;
|
||||
}
|
||||
|
||||
.drop-zone:hover,
|
||||
.drop-zone:focus-visible,
|
||||
.drop-zone.is-dragging {
|
||||
border-color: var(--accent);
|
||||
background: #faf8ff;
|
||||
box-shadow: 0 0 0 4px rgba(108, 92, 231, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.drop-zone.has-file {
|
||||
border-style: solid;
|
||||
border-color: rgba(32, 130, 92, 0.45);
|
||||
background: rgba(239, 250, 245, 0.72);
|
||||
}
|
||||
|
||||
.upload-symbol {
|
||||
display: grid;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border-radius: 14px;
|
||||
color: var(--accent-dark);
|
||||
background: var(--accent-soft);
|
||||
font-size: 27px;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.has-file .upload-symbol {
|
||||
color: var(--success);
|
||||
background: rgba(32, 130, 92, 0.11);
|
||||
}
|
||||
|
||||
.drop-copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.drop-copy strong {
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.drop-copy span {
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.choose-chip {
|
||||
padding: 8px 11px;
|
||||
border-radius: 999px;
|
||||
color: var(--dark);
|
||||
background: #e8e7e1;
|
||||
font-size: 11px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
resize: vertical;
|
||||
padding: 16px 17px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-medium);
|
||||
outline: none;
|
||||
color: var(--ink);
|
||||
background: var(--panel);
|
||||
line-height: 1.55;
|
||||
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.8) inset;
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
textarea::placeholder {
|
||||
color: #9a9b96;
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 4px rgba(108, 92, 231, 0.1);
|
||||
}
|
||||
|
||||
textarea:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.field-foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.button {
|
||||
min-height: 54px;
|
||||
padding: 13px 16px;
|
||||
border: 0;
|
||||
border-radius: 15px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 760;
|
||||
transition:
|
||||
transform 150ms ease,
|
||||
background 150ms ease,
|
||||
box-shadow 150ms ease,
|
||||
opacity 150ms ease;
|
||||
}
|
||||
|
||||
.button:not(:disabled):hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.button:not(:disabled):active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.button:focus-visible {
|
||||
outline: 3px solid rgba(108, 92, 231, 0.28);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.43;
|
||||
}
|
||||
|
||||
.button-primary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: white;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 10px 25px rgba(108, 92, 231, 0.24);
|
||||
}
|
||||
|
||||
.button-primary:not(:disabled):hover {
|
||||
background: var(--accent-dark);
|
||||
box-shadow: 0 14px 28px rgba(108, 92, 231, 0.29);
|
||||
}
|
||||
|
||||
.button-arrow {
|
||||
font-size: 20px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.button-secondary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 9px;
|
||||
color: var(--ink);
|
||||
background: #deddd6;
|
||||
}
|
||||
|
||||
.button-secondary:not(:disabled):hover {
|
||||
background: #d2d0c8;
|
||||
}
|
||||
|
||||
.spark {
|
||||
color: var(--accent-dark);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.privacy-note {
|
||||
margin: -4px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
min-width: 0;
|
||||
padding: clamp(20px, 3vw, 32px);
|
||||
border: 1px solid rgba(23, 24, 23, 0.08);
|
||||
border-radius: var(--radius-large);
|
||||
background: rgba(251, 250, 246, 0.84);
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.workspace-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.workspace-kicker {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.workspace h2 {
|
||||
margin-bottom: 0;
|
||||
font-size: clamp(24px, 3vw, 34px);
|
||||
letter-spacing: -0.045em;
|
||||
}
|
||||
|
||||
.local-badge {
|
||||
padding: 8px 11px;
|
||||
border: 1px solid #dad8d0;
|
||||
border-radius: 999px;
|
||||
color: var(--muted);
|
||||
background: rgba(255, 255, 255, 0.58);
|
||||
font-size: 10px;
|
||||
font-weight: 760;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.comparison {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.photo-frame {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.photo-frame figcaption {
|
||||
display: flex;
|
||||
min-height: 28px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 0 2px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 760;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.result-kind {
|
||||
overflow: hidden;
|
||||
max-width: 60%;
|
||||
color: var(--accent-dark);
|
||||
font-size: 9px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.image-stage {
|
||||
position: relative;
|
||||
display: grid;
|
||||
min-height: clamp(360px, 54vw, 660px);
|
||||
overflow: hidden;
|
||||
place-items: center;
|
||||
border-radius: 18px;
|
||||
background:
|
||||
linear-gradient(45deg, #252826 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #252826 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #252826 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #252826 75%),
|
||||
#2b2e2c;
|
||||
background-position: 0 0, 0 8px, 8px -8px, -8px 0;
|
||||
background-size: 16px 16px;
|
||||
}
|
||||
|
||||
.image-stage::after {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: inherit;
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.image-stage img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 660px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 30px;
|
||||
color: rgba(255, 255, 255, 0.76);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.placeholder strong {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.placeholder > span:last-child {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.placeholder-frame {
|
||||
position: relative;
|
||||
width: 58px;
|
||||
height: 47px;
|
||||
margin-bottom: 8px;
|
||||
border: 1.5px solid rgba(255, 255, 255, 0.27);
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
.placeholder-frame::before {
|
||||
position: absolute;
|
||||
right: 9px;
|
||||
bottom: 9px;
|
||||
left: 9px;
|
||||
height: 17px;
|
||||
background:
|
||||
linear-gradient(140deg, transparent 45%, rgba(255, 255, 255, 0.19) 46% 69%, transparent 70%),
|
||||
linear-gradient(40deg, transparent 35%, rgba(255, 255, 255, 0.13) 36% 62%, transparent 63%);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.placeholder-frame::after {
|
||||
position: absolute;
|
||||
top: 9px;
|
||||
right: 10px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.28);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.placeholder-spark {
|
||||
display: grid;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
margin-bottom: 7px;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(255, 255, 255, 0.13);
|
||||
border-radius: 50%;
|
||||
color: #bcb2ff !important;
|
||||
background: rgba(108, 92, 231, 0.17);
|
||||
font-size: 20px !important;
|
||||
}
|
||||
|
||||
.job-status {
|
||||
margin-top: 16px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid #dedbd0;
|
||||
border-radius: 14px;
|
||||
background: #f2f0e8;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 19px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.status-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex: 0 0 auto;
|
||||
border: 2px solid rgba(108, 92, 231, 0.2);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.75s linear infinite;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
height: 3px;
|
||||
overflow: hidden;
|
||||
margin-top: 12px;
|
||||
border-radius: 999px;
|
||||
background: #dbd7ca;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
display: block;
|
||||
width: 42%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--accent);
|
||||
animation: progress-slide 1.7s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.job-status[data-kind="success"] {
|
||||
border-color: rgba(32, 130, 92, 0.22);
|
||||
background: rgba(32, 130, 92, 0.07);
|
||||
}
|
||||
|
||||
.job-status[data-kind="success"] .status-row {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.job-status[data-kind="success"] .status-spinner {
|
||||
border: 0;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.job-status[data-kind="success"] .status-spinner::after {
|
||||
content: "✓";
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.job-status[data-kind="success"] .progress-track {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.job-status[data-kind="error"] {
|
||||
border-color: rgba(184, 67, 67, 0.22);
|
||||
background: rgba(184, 67, 67, 0.07);
|
||||
}
|
||||
|
||||
.job-status[data-kind="error"] .status-row {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.job-status[data-kind="error"] .status-spinner {
|
||||
border: 0;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.job-status[data-kind="error"] .status-spinner::after {
|
||||
content: "!";
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.job-status[data-kind="error"] .progress-track {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.result-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.download-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
padding: 11px 14px;
|
||||
border-radius: 12px;
|
||||
color: white;
|
||||
background: var(--dark);
|
||||
font-size: 12px;
|
||||
font-weight: 740;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
transform 150ms ease,
|
||||
background 150ms ease;
|
||||
}
|
||||
|
||||
.download-link:hover {
|
||||
background: #303531;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.download-link:focus-visible {
|
||||
outline: 3px solid rgba(108, 92, 231, 0.28);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.noscript {
|
||||
margin: 20px;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
color: #7a2424;
|
||||
background: #ffe4e4;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes health-pulse {
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
transform: scale(0.82);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes progress-slide {
|
||||
0% {
|
||||
transform: translateX(-115%);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateX(85%);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(245%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1040px) {
|
||||
.editor-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 0.75fr) minmax(330px, 1fr);
|
||||
gap: 36px;
|
||||
}
|
||||
|
||||
.intro {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(42px, 7vw, 68px);
|
||||
}
|
||||
|
||||
.image-stage {
|
||||
min-height: min(62vw, 600px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.topbar {
|
||||
min-height: 68px;
|
||||
padding: 14px 18px;
|
||||
}
|
||||
|
||||
.health-status {
|
||||
max-width: 58%;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.editor-layout {
|
||||
gap: 34px;
|
||||
padding: 34px 16px 40px;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.intro {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
max-width: 450px;
|
||||
font-size: clamp(40px, 13vw, 58px);
|
||||
}
|
||||
|
||||
.actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
padding: 18px 14px;
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.comparison {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.image-stage {
|
||||
min-height: min(105vw, 520px);
|
||||
}
|
||||
|
||||
.field-foot span:first-child {
|
||||
max-width: 70%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 460px) {
|
||||
.brand-name {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.health-status {
|
||||
max-width: calc(100% - 56px);
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.choose-chip {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workspace-head {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.local-badge {
|
||||
padding: 7px 9px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* -- Èñòîðèÿ âåðñèé ------------------------------------------- */
|
||||
|
||||
.history-section {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.history-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.history-head h2 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.history-hint {
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.history-list {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
overflow-x: auto;
|
||||
padding: 4px 4px 10px;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
flex: 0 0 150px;
|
||||
width: 150px;
|
||||
padding: 9px;
|
||||
border: 1.5px solid var(--line);
|
||||
border-radius: var(--radius-small);
|
||||
background: var(--panel);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.history-item:hover {
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.history-item.is-current {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
|
||||
.history-item img {
|
||||
width: 100%;
|
||||
height: 92px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
background: #ecece6;
|
||||
}
|
||||
|
||||
.history-item-meta strong {
|
||||
display: block;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.3;
|
||||
color: var(--ink);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.history-item-meta span {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.history-item-badge {
|
||||
align-self: flex-start;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-dark);
|
||||
}
|
||||
|
||||
.history-empty {
|
||||
padding: 10px 2px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
Reference in New Issue
Block a user