Release glass material and favicon pipeline v0.6.0
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user