Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion client/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ export const App = () => {
width: widget.width,
height: widget.height,
});
return `<iframe src="${src}" width="${widget.width}" height="${widget.height}" frameborder="0" style="display:block;border:0" loading="lazy"></iframe>`;
return `<iframe src="${src}" width="${widget.width}" height="${widget.height}" style="display:block;border:0" loading="lazy"></iframe>`;
})();
await navigator.clipboard?.writeText(code);
};
Expand Down Expand Up @@ -383,6 +383,11 @@ export const App = () => {
locale={locale}
onBack={() => navigate('/dashboard')}
onOpenPublic={(slug) => navigate(`/w/${slug}`)}
onSave={(saved) =>
setVisibleWidgets((current) =>
current.map((widget) => (widget.id === saved.id ? toCardData(saved) : widget)),
)
}
/>
);

Expand Down
9 changes: 4 additions & 5 deletions client/src/entities/widget/ui/WidgetCard.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@
gap: 0.5rem;
}

.actions button:last-child {
.actions > button:last-child {
grid-column: 2 / 3;
grid-row: 2 / 3;
}
Expand Down Expand Up @@ -307,12 +307,11 @@
}

.copyMenu {
display: contents;
grid-column: 2 / 3;
grid-row: 1 / 2;
}

.copyAction {
grid-column: 2 / 3;
grid-row: 1 / 2;
}

@media (max-width: 760px) {
Expand All @@ -328,7 +327,7 @@
grid-column: 1 / 2;
}

.copyAction {
.copyMenu {
grid-column: 1 / 2;
grid-row: 1 / 2;
}
Expand Down
32 changes: 22 additions & 10 deletions client/src/entities/widget/ui/WidgetCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,15 @@ const PreviewSkeleton = () => (
const WidgetPreviewFrame = ({ widget }: { widget: WidgetCardData }) => {
const viewportRef = useRef<HTMLDivElement | null>(null);
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const [loadedSlug, setLoadedSlug] = useState<string | null>(null);
const [scale, setScale] = useState(1);
const isLoaded = loadedSlug === widget.slug;
const [loaded, setLoaded] = useState<{ slug: string; width: number; height: number } | null>(
null,
);
const [viewportSize, setViewportSize] = useState({ width: 0, height: 0 });
const isLoaded =
loaded !== null &&
loaded.slug === widget.slug &&
loaded.width === widget.width &&
loaded.height === widget.height;

useEffect(() => {
const handleMessage = (event: MessageEvent<unknown>) => {
Expand All @@ -75,33 +81,38 @@ const WidgetPreviewFrame = ({ widget }: { widget: WidgetCardData }) => {
return;
}

setLoadedSlug(widget.slug);
setLoaded({ slug: widget.slug, width: widget.width, height: widget.height });
};

window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [widget.slug]);
}, [widget.height, widget.slug, widget.width]);

useEffect(() => {
const viewport = viewportRef.current;
if (!viewport) return;

const updateScale = () => {
const measure = () => {
const { paddingLeft, paddingRight, paddingTop, paddingBottom } =
window.getComputedStyle(viewport);
const width =
viewport.clientWidth - Number.parseFloat(paddingLeft) - Number.parseFloat(paddingRight);
const height =
viewport.clientHeight - Number.parseFloat(paddingTop) - Number.parseFloat(paddingBottom);
if (!width || !height) return;
setScale(Math.min(1, width / widget.width, height / widget.height));
setViewportSize({ width, height });
};

updateScale();
const observer = new ResizeObserver(updateScale);
measure();
const observer = new ResizeObserver(measure);
observer.observe(viewport);
return () => observer.disconnect();
}, [widget.height, widget.width]);
}, []);

const scale =
viewportSize.width && viewportSize.height
? Math.min(1, viewportSize.width / widget.width, viewportSize.height / widget.height)
: 1;

return (
<div
Expand Down Expand Up @@ -231,6 +242,7 @@ export const WidgetCard = ({
action: () => void onCopy(widget, 'svg'),
},
]}
popupProps={{ placement: 'bottom-end' }}
switcherWrapperClassName={styles.copyMenu}
renderSwitcher={({ onClick, onKeyDown }) => (
<Button
Expand Down
37 changes: 28 additions & 9 deletions client/src/pages/widget-editor/ui/WidgetEditorPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,6 @@ const MAX_BLOCKS = 5;
const MAX_COLUMNS = 2;
const GRID_GAP = 18;
const WIDGET_WIDTH = 600;
const WIDGET_BASE_HEIGHT = 200;
const WIDGET_ROW_HEIGHT = 200;
const DEFAULT_LAYOUT: BlockLayout = { x: 0, y: 0, width: 1, height: 1 };

const blockSizes = [
Expand All @@ -62,6 +60,7 @@ type WidgetEditorPageProps = {
locale: Locale;
onBack: () => void;
onOpenPublic: (slug: string) => void;
onSave?: (widget: Widget) => void;
};

type CachedEditorState = { savedAt: number; widget: Widget };
Expand Down Expand Up @@ -117,9 +116,11 @@ const getWidgetDimensions = (blocks: WidgetBlock[]) => {
return layout.y + layout.height;
}),
);
const padding = Math.min(34, Math.max(20, WIDGET_WIDTH * 0.04));
const cellWidth = (WIDGET_WIDTH - padding * 2 - GRID_GAP * (MAX_COLUMNS - 1)) / MAX_COLUMNS;
return {
width: WIDGET_WIDTH,
height: Math.min(1200, WIDGET_BASE_HEIGHT + rows * WIDGET_ROW_HEIGHT),
height: Math.min(1200, Math.round(rows * cellWidth + GRID_GAP * (rows - 1) + padding * 2)),
};
};

Expand Down Expand Up @@ -310,6 +311,7 @@ export const WidgetEditorPage = ({
locale,
onBack,
onOpenPublic,
onSave,
}: WidgetEditorPageProps) => {
const t = messages[locale];
const prefersReducedMotion = useReducedMotion();
Expand Down Expand Up @@ -654,12 +656,11 @@ export const WidgetEditorPage = ({
}));
};

const handleSave = (publish = false): Promise<void> => {
const handleSave = async (publish = false): Promise<void> => {
if (savePromiseRef.current) {
const pending = savePromiseRef.current;
return pending.then(() => {
if (publish || isDirtyRef.current) return handleSave(publish);
});
await pending;
if (publish || isDirtyRef.current) return handleSave(publish);
}
const run = (async () => {
const current = widgetRef.current;
Expand Down Expand Up @@ -688,6 +689,7 @@ export const WidgetEditorPage = ({
isDirtyRef.current = true;
setDirty(true);
}
onSave?.(normalized);
} catch (saveError) {
setError(saveError instanceof Error ? saveError.message : t.unavailable);
} finally {
Expand All @@ -704,12 +706,28 @@ export const WidgetEditorPage = ({
void handleSave();
});

const flushOnClose = useEffectEvent(() => {
if (isDirtyRef.current) void handleSave();
});

useEffect(() => {
if (!widget || !isDirty) return;
const timeout = window.setTimeout(triggerAutosave, 1500);
return () => window.clearTimeout(timeout);
}, [isDirty, widget?.id, widget]);

useEffect(() => {
if (!widget) return;
window.addEventListener('pagehide', flushOnClose);
return () => window.removeEventListener('pagehide', flushOnClose);
}, [widget]);

useEffect(() => {
return () => {
flushOnClose();
};
}, []);

const handleUnpublish = async () => {
if (!widget) return;
setSaving(true);
Expand All @@ -736,7 +754,7 @@ export const WidgetEditorPage = ({
width: widget.width,
height: widget.height,
});
const code = `<iframe src="${src}" width="${widget.width}" height="${widget.height}" frameborder="0" style="display:block;border:0" loading="lazy"></iframe>`;
const code = `<iframe src="${src}" width="${widget.width}" height="${widget.height}" style="display:block;border:0" loading="lazy"></iframe>`;
await navigator.clipboard?.writeText(code);
setCopied(true);
window.setTimeout(() => setCopied(false), 1600);
Expand All @@ -752,7 +770,8 @@ export const WidgetEditorPage = ({
window.setTimeout(() => setSvgCopied(false), 1600);
};

const guardLeave = () => {
const guardLeave = async () => {
if (isDirtyRef.current) await handleSave();
onBack();
};

Expand Down
83 changes: 83 additions & 0 deletions server/src/controllers/widgetController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,87 @@ const userId = (req: AuthRequest) => {
return req.userId;
};

const AVATAR_CACHE_TTL_MS = 15 * 60 * 1000;
const AVATAR_NEGATIVE_TTL_MS = 5 * 60 * 1000;
const AVATAR_ERROR_TTL_MS = 30 * 1000;
const avatarCache = new Map<string, { expiresAt: number; dataUri: string | null }>();
const inflightAvatars = new Map<string, Promise<string | null>>();

const asHttpUrl = (value: unknown): string | null => {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return /^https?:\/\//i.test(trimmed) ? trimmed : null;
};

const withAvatarSize = (url: string, size: number): string =>
`${url}${url.includes('?') ? '&' : '?'}s=${size}`;

const storeAvatar = (url: string, dataUri: string | null, ttl: number) => {
avatarCache.set(url, { expiresAt: Date.now() + ttl, dataUri });
};

const doFetchAvatar = async (url: string): Promise<string | null> => {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
const response = await fetch(withAvatarSize(url, 84), {
headers: {
Accept: 'image/avif,image/webp,image/png,image/jpeg',
'User-Agent': 'widgecode-widget-builder',
},
signal: controller.signal,
});
clearTimeout(timer);
if (!response.ok) {
storeAvatar(url, null, AVATAR_NEGATIVE_TTL_MS);
return null;
}

const contentType = response.headers.get('content-type') ?? '';
const type = contentType.split(';')[0].trim() || 'image/png';
const buffer = Buffer.from(await response.arrayBuffer()).toString('base64');
const dataUri = `data:${type};base64,${buffer}`;
storeAvatar(url, dataUri, AVATAR_CACHE_TTL_MS);
return dataUri;
} catch {
storeAvatar(url, null, AVATAR_ERROR_TTL_MS);
return null;
}
};

const fetchAvatarDataUri = async (url: string): Promise<string | null> => {
const now = Date.now();
const cached = avatarCache.get(url);
if (cached && cached.expiresAt > now) return cached.dataUri;
if (cached) avatarCache.delete(url);

const inflight = inflightAvatars.get(url);
if (inflight) return inflight;

const promise = doFetchAvatar(url).finally(() => inflightAvatars.delete(url));
inflightAvatars.set(url, promise);
return promise;
};

const buildAvatarDataUris = async (
renderedBlocks: { id: string; data?: unknown }[],
): Promise<Record<string, string>> => {
const results: Record<string, string> = {};
await Promise.all(
renderedBlocks.map(async (block) => {
const data =
block.data && typeof block.data === 'object'
? (block.data as Record<string, unknown>)
: null;
const url = data ? asHttpUrl(data.avatarUrl) : null;
if (!url) return;
const dataUri = await fetchAvatarDataUri(url);
if (dataUri) results[block.id] = dataUri;
}),
);
return results;
};

export class WidgetController {
list = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
Expand Down Expand Up @@ -247,6 +328,7 @@ export class WidgetController {

const locale = req.query.locale === 'ru' ? 'ru' : 'en';
const rendered = await renderWidgetStats(widget);
const avatarDataUris = await buildAvatarDataUris(rendered.blocks);
const outputDimensions = imageOutputDimensions(
widget.width,
widget.height,
Expand Down Expand Up @@ -277,6 +359,7 @@ export class WidgetController {
outputWidth: outputDimensions?.width,
outputHeight: outputDimensions?.height,
renderedBlocks: rendered.blocks,
avatarDataUris,
locale,
showChrome: false,
}),
Expand Down
41 changes: 41 additions & 0 deletions server/src/services/widgetCanvas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,44 @@ it('scales the SVG viewport without changing the widget viewBox', () => {
expect(svg).toContain('width="300" height="200" viewBox="0 0 600 400"');
expect(svg).toContain('preserveAspectRatio="xMidYMid meet"');
});

it('inlines avatars as data URIs instead of external URLs', () => {
const svg = renderToStaticMarkup(
createElement(WidgetCanvas, {
title: 'Avatar widget',
blocks: [
{
id: 'block-1',
type: 'github-stats',
position: 0,
config: { layout: { x: 0, y: 0, width: 1, height: 1 } },
},
],
palette: 'lavender',
paletteMode: 'light',
columns: 1,
width: 600,
height: 400,
renderedBlocks: [
{
id: 'block-1',
type: 'github-stats',
position: 0,
data: {
username: 'octocat',
name: 'The Octocat',
avatarUrl: 'https://avatars.githubusercontent.com/u/583231',
publicRepositories: 8,
followers: 100,
following: 9,
},
},
],
avatarDataUris: { 'block-1': 'data:image/png;base64,iVBORw0KGgo=' },
showChrome: false,
}),
);

expect(svg).toContain('href="data:image/png;base64,iVBORw0KGgo="');
expect(svg).not.toContain('avatars.githubusercontent.com');
});
Loading
Loading