Phase 1 (Foundation): - Project restructure (presenton-main → backend/ + frontend/) - Database schema (8 new models, Alembic config, seed script) - Auth (Azure AD SSO + dev bypass, JWT sessions, AuthMiddleware) - RBAC (access_service, rbac_middleware, admin routers) - Audit logging (fire-and-forget, AuditMiddleware, admin router) - i18n (react-i18next with 5 namespace files) Phase 2 (Admin Panel & Client Management): - Admin panel shell (sidebar layout, role guard, 12 pages) - Redux admin slice with 18 async thunks - User management (role changes, deactivation) - Client management (CRUD, brand config, team management) - Brand config editor (colors, fonts, logos, voice rules) - Master deck upload & parser (PPTX → HTML → React pipeline) - Audit log viewer with filters and CSV/JSON export Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
203 lines
No EOL
7.4 KiB
TypeScript
203 lines
No EOL
7.4 KiB
TypeScript
import React from "react";
|
|
|
|
export type RemoteSvgOptions = {
|
|
strokeColor?: string;
|
|
fillColor?: string;
|
|
className?: string;
|
|
title?: string;
|
|
color?: string;
|
|
|
|
};
|
|
|
|
function transformSvg(svgText: string, options: RemoteSvgOptions): string {
|
|
try {
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(svgText, "image/svg+xml");
|
|
const svgEl = doc.querySelector("svg");
|
|
if (!svgEl) return svgText;
|
|
svgEl.style.outline = "none";
|
|
svgEl.style.border = "none";
|
|
svgEl.style.margin = "0";
|
|
svgEl.style.padding = "0";
|
|
svgEl.style.display = "inline-block";
|
|
svgEl.style.verticalAlign = "middle";
|
|
svgEl.style.overflow = "visible";
|
|
svgEl.style.position = "relative";
|
|
// Set only provided attributes to avoid clobbering inner shapes
|
|
if (options.className) svgEl.setAttribute("class", options.className);
|
|
if (options.strokeColor) svgEl.setAttribute("stroke", options.strokeColor);
|
|
if (options.fillColor !== undefined) svgEl.setAttribute("fill", options.fillColor);
|
|
|
|
|
|
const viewBox = svgEl.getAttribute("viewBox");
|
|
let vbX = 0, vbY = 0, vbW = 0, vbH = 0;
|
|
if (viewBox) {
|
|
const parts = viewBox.split(/\s+/).map((n) => Number(n));
|
|
if (parts.length === 4 && parts.every((n) => !Number.isNaN(n))) {
|
|
vbX = parts[0];
|
|
vbY = parts[1];
|
|
vbW = parts[2];
|
|
vbH = parts[3];
|
|
}
|
|
}
|
|
// Only consider direct child rects; safer heuristic
|
|
const rects = Array.from(svgEl.querySelectorAll("rect")).filter((r) => r.parentNode === svgEl);
|
|
rects.forEach((r) => {
|
|
const xAttr = r.getAttribute("x") || "0";
|
|
const yAttr = r.getAttribute("y") || "0";
|
|
const wAttr = r.getAttribute("width") || "";
|
|
const hAttr = r.getAttribute("height") || "";
|
|
const fill = r.getAttribute("fill");
|
|
|
|
const x = Number(xAttr);
|
|
const y = Number(yAttr);
|
|
const w = Number(wAttr);
|
|
const h = Number(hAttr);
|
|
|
|
const isExactHundredPercent = wAttr === "100%" && hAttr === "100%" && (xAttr === "0" || xAttr === "0%") && (yAttr === "0" || yAttr === "0%");
|
|
const approximatelyCoversViewBox = (
|
|
vbW > 0 && vbH > 0 &&
|
|
!Number.isNaN(w) && !Number.isNaN(h) &&
|
|
Math.abs(w - vbW) <= Math.max(1, vbW * 0.02) &&
|
|
Math.abs(h - vbH) <= Math.max(1, vbH * 0.02) &&
|
|
Math.abs(x - vbX) <= Math.max(1, vbW * 0.02) &&
|
|
Math.abs(y - vbY) <= Math.max(1, vbH * 0.02)
|
|
);
|
|
const noFill = (fill === null || fill === "none" || fill === "transparent");
|
|
|
|
const looksLikeFrame = noFill && (isExactHundredPercent || approximatelyCoversViewBox);
|
|
if (looksLikeFrame) {
|
|
r.parentElement?.removeChild(r);
|
|
}
|
|
});
|
|
|
|
|
|
return svgEl.outerHTML;
|
|
} catch {
|
|
return svgText;
|
|
}
|
|
}
|
|
|
|
// Simple in-memory LRU cache for transformed SVG markup
|
|
const SVG_CACHE_LIMIT = 15;
|
|
const svgCache: Map<string, string> = new Map();
|
|
|
|
function makeCacheKey(url: string, options: RemoteSvgOptions): string {
|
|
return [
|
|
url,
|
|
`sc=${options.strokeColor || ""}`,
|
|
`fc=${options.fillColor || ""}`,
|
|
`cls=${options.className || ""}`,
|
|
|
|
].join("|");
|
|
}
|
|
|
|
function cacheGet(key: string): string | undefined {
|
|
const value = svgCache.get(key);
|
|
if (value !== undefined) {
|
|
// refresh LRU order
|
|
svgCache.delete(key);
|
|
svgCache.set(key, value);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function cacheSet(key: string, value: string) {
|
|
if (svgCache.has(key)) svgCache.delete(key);
|
|
svgCache.set(key, value);
|
|
if (svgCache.size > SVG_CACHE_LIMIT) {
|
|
const oldestKey = svgCache.keys().next().value as string | undefined;
|
|
if (oldestKey !== undefined) svgCache.delete(oldestKey);
|
|
}
|
|
}
|
|
|
|
export function useRemoteSvgIcon(url?: string, options: RemoteSvgOptions = {}) {
|
|
const [svgMarkup, setSvgMarkup] = React.useState<string | null>(null);
|
|
const [error, setError] = React.useState<string | null>(null);
|
|
|
|
React.useEffect(() => {
|
|
let cancelled = false;
|
|
async function run() {
|
|
if (!url) {
|
|
// build simple fallback svg
|
|
const stroke = options.strokeColor || "currentColor";
|
|
const fill = options.fillColor ?? "none";
|
|
const cls = options.className ? ` class=\"${options.className}\"` : "";
|
|
setSvgMarkup(`<svg${cls} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' stroke='${stroke}' fill='${fill}' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><circle cx='12' cy='12' r='10' fill='currentColor' opacity='0.12'></circle><path d='M8 12l3 3 5-6' fill='none'></path></svg>`);
|
|
return;
|
|
}
|
|
// non-svg extensions fallback
|
|
if (/\.(png|jpe?g|gif|webp)(\?.*)?$/i.test(url)) {
|
|
const stroke = options.strokeColor || "currentColor";
|
|
const fill = options.fillColor ?? "none";
|
|
const cls = options.className ? ` class=\"${options.className}\"` : "";
|
|
setSvgMarkup(`<svg${cls} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' stroke='${stroke}' fill='${fill}' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><circle cx='12' cy='12' r='10' fill='currentColor' opacity='0.12'></circle><path d='M8 12l3 3 5-6' fill='none'></path></svg>`);
|
|
return;
|
|
}
|
|
|
|
// Cache lookup
|
|
const cacheKey = makeCacheKey(url, options);
|
|
const cached = cacheGet(cacheKey);
|
|
if (cached) {
|
|
setSvgMarkup(cached);
|
|
setError(null);
|
|
return;
|
|
}
|
|
try {
|
|
|
|
const res = await fetch(url);
|
|
if (!res.ok) {
|
|
throw new Error(`HTTP ${res.status}`);
|
|
}
|
|
const ct = res.headers.get("content-type") || "";
|
|
if (ct && !ct.includes("svg")) {
|
|
throw new Error(`Non-SVG content: ${ct}`);
|
|
}
|
|
const text = await res.text();
|
|
if (cancelled) return;
|
|
const transformed = transformSvg(text, options);
|
|
cacheSet(cacheKey, transformed);
|
|
setSvgMarkup(transformed);
|
|
setError(null);
|
|
} catch (e: any) {
|
|
if (cancelled) return;
|
|
setError(e?.message || "Failed to load SVG");
|
|
const stroke = options.strokeColor || "currentColor";
|
|
const fill = options.fillColor ?? "none";
|
|
const cls = options.className ? ` class=\"${options.className}\"` : "";
|
|
setSvgMarkup(`<svg${cls} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' stroke='${stroke}' fill='${fill}' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><circle cx='12' cy='12' r='10' fill='currentColor' opacity='0.12'></circle><path d='M8 12l3 3 5-6' fill='none'></path></svg>`);
|
|
if (process.env.NODE_ENV !== "production") {
|
|
// eslint-disable-next-line no-console
|
|
console.warn("RemoteSvgIcon fetch error", e);
|
|
}
|
|
}
|
|
}
|
|
run();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [url, options.strokeColor, options.fillColor, options.className]);
|
|
|
|
return { svgMarkup, error };
|
|
}
|
|
|
|
export const RemoteSvgIcon: React.FC<{
|
|
url?: string;
|
|
strokeColor?: string;
|
|
fillColor?: string;
|
|
className?: string;
|
|
title?: string;
|
|
color?: string;
|
|
}> = ({ url, strokeColor, fillColor, className, title, color }) => {
|
|
const { svgMarkup } = useRemoteSvgIcon(url, { strokeColor, fillColor, className, title, color });
|
|
if (!svgMarkup) return null;
|
|
return (
|
|
<span
|
|
data-path={url}
|
|
role={title ? "img" : undefined}
|
|
aria-label={title}
|
|
dangerouslySetInnerHTML={{ __html: svgMarkup }}
|
|
style={{ display: "inline-block", color: color }}
|
|
/>
|
|
);
|
|
}; |