Release glass material and favicon pipeline v0.6.0

This commit is contained in:
DCCONSTRUCTIONS
2026-07-11 12:19:44 +03:00
parent 6b42d913ea
commit d873427fe2
28 changed files with 550 additions and 53 deletions
+133 -6
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { applyNodedcTheme, type NodedcTheme, type RgbTuple } from "@nodedc/ui-core";
import { applyGlassMaterial, applyNodedcTheme, defaultGlassMaterial, type GlassMaterialSettings, type NodedcTheme, type RgbTuple } from "@nodedc/ui-core";
import {
AdminNavigationPanel,
AppHeader,
@@ -13,6 +13,7 @@ import {
Dropdown,
FieldFrame,
GlassSurface,
GlassMaterialSurface,
HeaderAvatar,
HeaderNavigation,
HeaderProfile,
@@ -39,8 +40,9 @@ import {
type ShareAccessMember,
type ToolbarPlacement,
} from "@nodedc/ui-react";
import { applyFaviconAssets, createIcoBlob, generateFavicons, type FaviconAssetUrls, type GeneratedFavicon } from "./favicon.js";
type CatalogSection = "controls" | "media" | "modals" | "icons";
type CatalogSection = "controls" | "media" | "glass" | "modals" | "icons";
const accents: Array<{ label: string; value: RgbTuple; hex: string }> = [
{ label: "NODE.DC", value: [255, 47, 146], hex: "#ff2f92" },
@@ -83,7 +85,10 @@ interface StoredLayout {
logoUrl?: string;
logoFileName?: string;
logoFileSrc?: string;
faviconFileName?: string;
faviconAssets?: FaviconAssetUrls;
};
glass?: GlassMaterialSettings;
toolbar?: {
placement?: ToolbarPlacement;
background?: string;
@@ -140,14 +145,20 @@ const sectionDefinitions: Record<CatalogSection, { eyebrow: string; title: strin
description: "Общий CMS/Launcher-контракт файла, URL, превью и нейтральных карточек настроек.",
icon: "image",
},
glass: {
eyebrow: "03 / GLASSY MATERIAL",
title: "Glassy material",
description: "Единый материал только для модальных окон и перемещаемого Inspector.",
icon: "apps",
},
modals: {
eyebrow: "03 / MODALS",
eyebrow: "04 / MODALS",
title: "Модалки",
description: "Полная карта modal-паттернов Launcher, нового Engine и BIM Viewer.",
icon: "clipboard",
},
icons: {
eyebrow: "04 / ICONS",
eyebrow: "05 / ICONS",
title: "Иконки",
description: "Канонический общий набор по Launcher, SEO, BIM Viewer и новым участкам Engine.",
icon: "grid",
@@ -306,6 +317,17 @@ export function CatalogApp() {
const [logoError, setLogoError] = useState("");
const logoObjectUrlRef = useRef<string | null>(null);
const pendingLogoFileRef = useRef<File | null>(null);
const [glassMaterial, setGlassMaterial] = useState<GlassMaterialSettings>({ ...defaultGlassMaterial });
const [faviconFileName, setFaviconFileName] = useState("icon-adaptive.svg");
const [faviconError, setFaviconError] = useState("");
const [generatedFavicons, setGeneratedFavicons] = useState<GeneratedFavicon[]>([]);
const [faviconAssets, setFaviconAssets] = useState<FaviconAssetUrls>({
ico: "/favicon/favicon.ico",
apple: "/favicon/apple-touch-icon.png",
icon192: "/favicon/icon-192.png",
icon512: "/favicon/icon-512.png",
});
const pendingFaviconIcoRef = useRef<Blob | null>(null);
const [toolbarPlacement, setToolbarPlacement] = useState<ToolbarPlacement>("bottom");
const [toolbarBg, setToolbarBg] = useState("#111115");
const [toolbarBorder, setToolbarBorder] = useState("#111117");
@@ -350,6 +372,14 @@ export function CatalogApp() {
document.documentElement.style.setProperty("--nodedc-canvas-soft", material.nestedHex);
}, [accent, fieldMaterial, material.fieldOpacity, material.nestedHex, material.panelOpacity, panelMaterial, theme]);
useEffect(() => {
applyGlassMaterial(document.documentElement, glassMaterial);
}, [glassMaterial]);
useEffect(() => {
applyFaviconAssets(faviconAssets);
}, [faviconAssets]);
useEffect(() => {
let active = true;
fetch("/api/layout", { cache: "no-store" })
@@ -386,7 +416,10 @@ export function CatalogApp() {
if (typeof media.logoUrl === "string") setLogoUrl(media.logoUrl);
if (media.logoFileName) setLogoFileName(media.logoFileName);
if (media.logoFileSrc) setLogoFileSrc(media.logoFileSrc);
if (media.faviconFileName) setFaviconFileName(media.faviconFileName);
if (media.faviconAssets) setFaviconAssets(media.faviconAssets);
}
if (stored.glass) setGlassMaterial({ ...defaultGlassMaterial, ...stored.glass });
const toolbar = stored.toolbar;
if (toolbar) {
if (toolbar.placement === "left" || toolbar.placement === "right" || toolbar.placement === "bottom") setToolbarPlacement(toolbar.placement);
@@ -412,7 +445,8 @@ export function CatalogApp() {
useEffect(() => () => {
if (mediaObjectUrlRef.current) URL.revokeObjectURL(mediaObjectUrlRef.current);
if (logoObjectUrlRef.current) URL.revokeObjectURL(logoObjectUrlRef.current);
}, []);
generatedFavicons.forEach((item) => URL.revokeObjectURL(item.previewUrl));
}, [generatedFavicons]);
const handleStageMediaFile = (file?: File) => {
if (!file) return;
@@ -449,6 +483,27 @@ export function CatalogApp() {
setLogoError("");
};
const handleFaviconFile = async (file?: File) => {
if (!file) return;
const isImage = /^(image\/(png|jpeg|webp))$/i.test(file.type) || /\.(png|jpe?g|webp)$/i.test(file.name);
if (!isImage) {
setFaviconError("Для генератора нужен PNG, JPG или WEBP.");
return;
}
try {
const next = await generateFavicons(file);
setGeneratedFavicons((current) => {
current.forEach((item) => URL.revokeObjectURL(item.previewUrl));
return next;
});
pendingFaviconIcoRef.current = await createIcoBlob(next);
setFaviconFileName(file.name);
setFaviconError("");
} catch {
setFaviconError("Не удалось прочитать исходное изображение.");
}
};
const inviteShareMember = () => {
const email = shareEmail.trim().toLowerCase();
if (!email) return;
@@ -506,6 +561,28 @@ export function CatalogApp() {
setLogoFileName(uploaded.fileName);
pendingLogoFileRef.current = null;
}
let persistedFaviconAssets = faviconAssets;
if (generatedFavicons.length && pendingFaviconIcoRef.current) {
const upload = async (blob: Blob, name: string) => {
const response = await fetch("/api/layout/media", {
method: "PUT",
headers: { "content-type": blob.type || "application/octet-stream", "x-file-name": encodeURIComponent(name) },
body: blob,
});
if (!response.ok) throw new Error("favicon_upload_failed");
return await response.json() as { url: string };
};
const bySize = new Map(generatedFavicons.map((item) => [item.size, item]));
const [ico, apple, icon192, icon512] = await Promise.all([
upload(pendingFaviconIcoRef.current, "favicon.ico"),
upload(bySize.get(180)!.blob, "apple-touch-icon.png"),
upload(bySize.get(192)!.blob, "icon-192.png"),
upload(bySize.get(512)!.blob, "icon-512.png"),
]);
persistedFaviconAssets = { ico: ico.url, apple: apple.url, icon192: icon192.url, icon512: icon512.url };
setFaviconAssets(persistedFaviconAssets);
pendingFaviconIcoRef.current = null;
}
const draft: StoredLayout = {
theme,
accentHex,
@@ -532,7 +609,10 @@ export function CatalogApp() {
logoUrl,
logoFileName,
logoFileSrc: persistedLogoFileSrc,
faviconFileName,
faviconAssets: persistedFaviconAssets,
},
glass: glassMaterial,
toolbar: {
placement: toolbarPlacement,
background: toolbarBg,
@@ -785,6 +865,53 @@ export function CatalogApp() {
onFileChange={handleLogoFile}
/>
</SettingsCard>
<SettingsCard
eyebrow="BROWSER BRAND"
title="Favicon generator"
description="Один квадратный исходник превращается в ICO, Apple Touch и PWA-набор; общая Save сохраняет набор на сервере и применяет его к приложению."
>
<div className="catalog-favicon-generator">
<label className="nodedc-media-file__button catalog-favicon-generator__upload">
Выбрать и сгенерировать
<input type="file" accept="image/png,image/jpeg,image/webp,.png,.jpg,.jpeg,.webp" onChange={(event) => { void handleFaviconFile(event.target.files?.[0]); }} />
</label>
<span className="catalog-favicon-generator__name">{faviconFileName}</span>
<div className="catalog-favicon-grid">
{[16, 32, 64, 180, 192, 512].map((size) => {
const generated = generatedFavicons.find((item) => item.size === size);
const fallback = size === 180 ? faviconAssets.apple : size === 192 ? faviconAssets.icon192 : size === 512 ? faviconAssets.icon512 : faviconAssets.ico;
return <figure key={size}><span><img src={generated?.previewUrl ?? fallback} alt="" /></span><figcaption>{size} × {size}</figcaption></figure>;
})}
</div>
{faviconError ? <span className="nodedc-media-field__error" role="alert">{faviconError}</span> : null}
</div>
</SettingsCard>
</div>
);
case "glass":
return (
<div className="catalog-glass-lab">
<section className="catalog-glass-stage">
<video key={stageMediaSrc} autoPlay muted loop playsInline poster="/launcher-stage-poster.png" aria-hidden="true"><source src={stageMediaSrc} type="video/mp4" /></video>
<div className="catalog-glass-stage__shade" />
<GlassMaterialSurface className="catalog-glass-stage__sample">
<span>CANONICAL GLASS</span>
<h2>Модальное окно и Inspector</h2>
<p>Фон остаётся различимым, но текст и контролы сохраняют контраст. Этот материал не применяется к обычным панелям приложения.</p>
<div className="catalog-inline"><TextField label="Пример поля" defaultValue="NODE.DC" /><Button variant="primary" shape="pill">Применить</Button></div>
</GlassMaterialSurface>
</section>
<SettingsCard eyebrow="ENGINE / GLASS V4" title="Параметры материала" description="Общий token-контракт ui-core; изменения сразу видны на preview, модалках и Inspector.">
<div className="catalog-glass-controls">
<ControlRow label="Цвет тонировки"><ColorField value={glassMaterial.tintHex} onChange={(tintHex) => setGlassMaterial((current) => ({ ...current, tintHex }))} /></ControlRow>
<RangeControl label="Прозрачность" value={glassMaterial.tintOpacity} min={0} max={96} formatValue={(value) => `${value}%`} onChange={(tintOpacity) => setGlassMaterial((current) => ({ ...current, tintOpacity }))} />
<RangeControl label="Blur" value={glassMaterial.blur} min={0} max={120} formatValue={(value) => `${value}px`} onChange={(blur) => setGlassMaterial((current) => ({ ...current, blur }))} />
<RangeControl label="Saturation" value={glassMaterial.saturation} min={70} max={220} formatValue={(value) => `${value}%`} onChange={(saturation) => setGlassMaterial((current) => ({ ...current, saturation }))} />
<RangeControl label="Brightness" value={glassMaterial.brightness} min={70} max={160} formatValue={(value) => `${value}%`} onChange={(brightness) => setGlassMaterial((current) => ({ ...current, brightness }))} />
<RangeControl label="Контур" value={glassMaterial.outlineOpacity} min={0} max={40} formatValue={(value) => `${value}%`} onChange={(outlineOpacity) => setGlassMaterial((current) => ({ ...current, outlineOpacity }))} />
<RangeControl label="Тень" value={glassMaterial.shadowOpacity} min={0} max={100} formatValue={(value) => `${value}%`} onChange={(shadowOpacity) => setGlassMaterial((current) => ({ ...current, shadowOpacity }))} />
</div>
</SettingsCard>
</div>
);
case "modals":
@@ -933,7 +1060,7 @@ export function CatalogApp() {
footer={
<>
<span className="nodedc-admin-panel__nav-icon" aria-hidden="true"><Icon name="shield" /></span>
<span>Design System 0.5.1</span>
<span>Design System 0.6.0</span>
</>
}
/>