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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,27 @@

All notable changes to this project are documented here.

## [Unreleased]

### Added

- Added live SVG image export URLs and README-ready HTML snippets for public widgets.
- Added proportional `width` and `height` query scaling for SVG image URLs.
- Extracted shared widget SVG-rendering primitives for server-side export.
- Added shared `languageColor` utility used by both client and server.

### Fixed

- Localized LeetCode ranking and contest rating labels in Russian and English.
- Added live LeetCode contest rating data to rendered statistics.
- Added skeleton cards for the initial widgets gallery load instead of showing the empty state prematurely.
- Kept public iframe preview skeletons visible until the widget payload and block data are ready.
- Stabilized the public widget loading layout to prevent the decorative background orb from jumping.

### Security

- Resolved `deepmerge-ts` high-severity vulnerability via npm overrides (GHSA-ggr8-5vv4-36mx).

## [0.3.0] - 2026-08-02

### Added
Expand Down
20 changes: 17 additions & 3 deletions api/widgets-resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,24 @@ const queryValue = (request: Request, key: string): string | undefined => {
return typeof value === 'string' ? value : undefined;
};

const buildQueryString = (params: Record<string, string | undefined>): string => {
const entries = Object.entries(params).filter(
(entry): entry is [string, string] => entry[1] !== undefined,
);
return entries.length > 0
? `?${entries.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`).join('&')}`
: '';
};

export default (request: Request, response: Response) => {
const resource = queryValue(request, 'resource');
const widgetId = queryValue(request, 'widgetId');
const blockId = queryValue(request, 'blockId');
const slug = queryValue(request, 'slug');
const action = queryValue(request, 'action');
const locale = queryValue(request, 'locale');
const queryWidth = queryValue(request, 'width');
const queryHeight = queryValue(request, 'height');

const path =
resource === 'widget' && widgetId
Expand All @@ -26,9 +38,11 @@ export default (request: Request, response: Response) => {
: `/api/widgets/${encodeURIComponent(widgetId)}`
: resource === 'block' && blockId
? `/api/blocks/${encodeURIComponent(blockId)}`
: resource === 'public' && slug
? `/api/public/widgets/${encodeURIComponent(slug)}`
: null;
: resource === 'public-image' && slug
? `/api/public/widgets/${encodeURIComponent(slug)}/image.svg${buildQueryString({ locale, width: queryWidth, height: queryHeight })}`
: resource === 'public' && slug
? `/api/public/widgets/${encodeURIComponent(slug)}`
: null;

if (!path) {
response.status(404).json({ error: 'Resource route not found' });
Expand Down
19 changes: 13 additions & 6 deletions client/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ import {
deleteWidget,
getPublicWidgetUrl,
getPublicWidgetPath,
getPublicWidgetImageUrl,
listWidgets,
API_BASE_URL,
type CreateWidgetInput,
} from '@/shared/api';
import { AuthTransitionLoader } from '@/shared/ui/auth-transition-loader/AuthTransitionLoader';
import { escapeHtmlAttribute } from '@/shared/lib/escapeHtml';
import {
APP_LOCALE_STORAGE_KEY,
APP_THEME_STORAGE_KEY,
Expand Down Expand Up @@ -282,12 +284,17 @@ export const App = () => {
setVisibleWidgets((currentWidgets) => currentWidgets.filter((widget) => widget.id !== id));
};

const handleCopyWidget = async (widget: WidgetCardData) => {
const src = getPublicWidgetUrl(widget.slug, true, {
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 handleCopyWidget = async (widget: WidgetCardData, format: 'iframe' | 'svg') => {
const code =
format === 'svg'
? `<img src="${getPublicWidgetImageUrl(widget.slug, locale)}" alt="${escapeHtmlAttribute(widget.title)}" width="${widget.width}" height="${widget.height}" />`
: (() => {
const src = getPublicWidgetUrl(widget.slug, true, {
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>`;
})();
await navigator.clipboard?.writeText(code);
};

Expand Down
58 changes: 14 additions & 44 deletions client/src/entities/widget/ui/WidgetCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,12 @@ import type {
WidgetBlock,
} from '@/entities/widget/model';
import { paletteTokens } from '@/entities/widget/model';
import { languageColor } from '@shared/widget/WidgetCanvas';
import { messages } from '@/shared/locale/content';
import styles from '@/entities/widget/ui/WidgetCanvas.module.css';

type WidgetLocale = 'ru' | 'en';

type WidgetCanvasProps = {
blocks: WidgetBlock[];
palette: PaletteId;
Expand All @@ -23,12 +26,10 @@ type WidgetCanvasProps = {
interactive?: boolean;
selectedBlockId?: string;
onSelectBlock?: (id: string) => void;
locale?: 'ru' | 'en';
locale?: WidgetLocale;
showChrome?: boolean;
};

type WidgetLocale = 'ru' | 'en';

const sampleData: Record<BlockType, Record<string, unknown>> = {
text: { text: 'Build something worth sharing.', align: 'left' },
'github-stats': {
Expand Down Expand Up @@ -57,37 +58,18 @@ const sampleData: Record<BlockType, Record<string, unknown>> = {
const renderedData = (block: WidgetBlock, renderedBlocks?: RenderedBlock[]) =>
renderedBlocks?.find((rendered) => rendered.id === block.id);

const githubLanguageColors: Record<string, string> = {
assembly: '#6e4c13',
c: '#555555',
'c#': '#178600',
'c++': '#f34b7d',
css: '#663399',
dart: '#00b4ab',
go: '#00add8',
html: '#e34c26',
java: '#b07219',
javascript: '#f1e05a',
kotlin: '#a97bff',
lua: '#000080',
'objective-c': '#438eff',
perl: '#0298c3',
php: '#4f5d95',
python: '#3572a5',
r: '#198ce7',
ruby: '#701516',
rust: '#dea584',
scala: '#c22d40',
shell: '#89e051',
svelte: '#ff3e00',
swift: '#f05138',
typescript: '#3178c6',
vue: '#41b883',
const getBlockLayout = (block: WidgetBlock): BlockLayout => {
const value = block.config.layout;
if (!value || typeof value !== 'object') return { x: 0, y: 0, width: 1, height: 1 };
const layout = value as Partial<BlockLayout>;
return {
x: typeof layout.x === 'number' ? layout.x : 0,
y: typeof layout.y === 'number' ? layout.y : 0,
width: typeof layout.width === 'number' ? layout.width : 1,
height: typeof layout.height === 'number' ? layout.height : 1,
};
};

const languageColor = (name: string) =>
githubLanguageColors[name.trim().toLowerCase()] ?? '#8b949e';

const PreviewState = ({
locale,
source,
Expand Down Expand Up @@ -126,18 +108,6 @@ const WidgetBlockSkeleton = () => (
</div>
);

const getBlockLayout = (block: WidgetBlock): BlockLayout => {
const value = block.config.layout;
if (!value || typeof value !== 'object') return { x: 0, y: 0, width: 1, height: 1 };
const layout = value as Partial<BlockLayout>;
return {
x: typeof layout.x === 'number' ? layout.x : 0,
y: typeof layout.y === 'number' ? layout.y : 0,
width: typeof layout.width === 'number' ? layout.width : 1,
height: typeof layout.height === 'number' ? layout.height : 1,
};
};

const formatNumber = (value: number | undefined) =>
value === undefined ? '—' : value.toLocaleString();

Expand Down
14 changes: 14 additions & 0 deletions client/src/entities/widget/ui/WidgetCard.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,15 @@
animation: card-skeleton-shimmer 1s linear infinite;
}

.copyMenu {
display: contents;
}

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

@media (max-width: 760px) {
.configureAction {
display: none;
Expand All @@ -318,6 +327,11 @@
.actions button:last-child {
grid-column: 1 / 2;
}

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

.modalContent {
Expand Down
37 changes: 28 additions & 9 deletions client/src/entities/widget/ui/WidgetCard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Copy, Gear, TrashBin } from '@gravity-ui/icons';
import { Button, Card, Icon, Modal } from '@gravity-ui/uikit';
import { Button, Card, DropdownMenu, Icon, Modal } from '@gravity-ui/uikit';
import { useEffect, useRef, useState, type CSSProperties } from 'react';

import { paletteTokens, type WidgetCardData } from '@/entities/widget/model';
Expand All @@ -11,6 +11,8 @@ export type WidgetCardLabels = {
open: string;
configure: string;
copy: string;
copyIframe: string;
copySvg: string;
published: string;
draft: string;
remove: string;
Expand All @@ -26,7 +28,7 @@ type WidgetCardProps = {
onDelete: (id: string) => void;
onConfigure: (id: string) => void;
onOpenPreview: (widget: WidgetCardData) => void;
onCopy: (widget: WidgetCardData) => void;
onCopy: (widget: WidgetCardData, format: 'iframe' | 'svg') => void | Promise<void>;
isLanguageLoading: boolean;
};

Expand Down Expand Up @@ -217,14 +219,31 @@ export const WidgetCard = ({
>
<Icon data={Gear} size={18} />
</Button>
<Button
<DropdownMenu
disabled={!widget.public}
view="outlined"
onClick={() => onCopy(widget)}
aria-label={labels.copy}
>
<Icon data={Copy} size={18} />
</Button>
items={[
{
text: labels.copyIframe,
action: () => void onCopy(widget, 'iframe'),
},
{
text: labels.copySvg,
action: () => void onCopy(widget, 'svg'),
},
]}
switcherWrapperClassName={styles.copyMenu}
renderSwitcher={({ onClick, onKeyDown }) => (
<Button
className={styles.copyAction}
view="outlined"
onClick={onClick}
onKeyDown={onKeyDown}
aria-label={labels.copy}
>
<Icon data={Copy} size={18} />
</Button>
)}
/>
<Button
view="outlined-danger"
onClick={() => setDeleteModalOpen(true)}
Expand Down
21 changes: 20 additions & 1 deletion client/src/pages/widget-editor/ui/WidgetEditorPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@ import {
addBlock,
deleteBlock,
getWidget,
getPublicWidgetImageUrl,
getPublicWidgetUrl,
previewWidgetBlock,
updateBlock,
updateBlockLayouts,
updateWidget,
} from '@/shared/api';
import { escapeHtmlAttribute } from '@/shared/lib/escapeHtml';
import {
blockDefinitions,
defaultBlockConfig,
Expand Down Expand Up @@ -320,6 +322,7 @@ export const WidgetEditorPage = ({
const [isSaving, setSaving] = useState(false);
const [isDirty, setDirty] = useState(false);
const [isCopied, setCopied] = useState(false);
const [isSvgCopied, setSvgCopied] = useState(false);
const [draggingBlockId, setDraggingBlockId] = useState<string | null>(null);
const [dropCell, setDropCell] = useState<{ x: number; y: number } | null>(null);
const [gridWidth, setGridWidth] = useState(0);
Expand Down Expand Up @@ -739,6 +742,16 @@ export const WidgetEditorPage = ({
window.setTimeout(() => setCopied(false), 1600);
};

const copySvgEmbed = async () => {
if (!widget || !widget.public) return;
const src = getPublicWidgetImageUrl(widget.slug, locale);
const alt = escapeHtmlAttribute(widget.title);
const code = `<img src="${src}" alt="${alt}" width="${widget.width}" height="${widget.height}" />`;
await navigator.clipboard?.writeText(code);
setSvgCopied(true);
window.setTimeout(() => setSvgCopied(false), 1600);
};

const guardLeave = () => {
onBack();
};
Expand Down Expand Up @@ -824,7 +837,13 @@ export const WidgetEditorPage = ({
{widget.public && (
<Button view="outlined-action" onClick={copyEmbed}>
<Icon data={isCopied ? Check : Copy} size={17} />
{isCopied ? t.copied : t.copy}
{isCopied ? t.copied : t.copyIframe}
</Button>
)}
{widget.public && (
<Button view="outlined-action" onClick={copySvgEmbed}>
<Icon data={isSvgCopied ? Check : Copy} size={17} />
{isSvgCopied ? t.copied : t.copySvg}
</Button>
)}
{!widget.public && (
Expand Down
4 changes: 3 additions & 1 deletion client/src/pages/widgets-gallery/ui/WidgetsGalleryPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ type WidgetsGalleryPageProps = {
onCreateWidget: (input: CreateWidgetInput) => Promise<void>;
onOpenWidget: (id: string) => void;
onOpenPreview: (widget: WidgetCardData) => void;
onCopyWidget: (widget: WidgetCardData) => void;
onCopyWidget: (widget: WidgetCardData, format: 'iframe' | 'svg') => void | Promise<void>;
onLogout: () => void;
onDeleteWidget: (id: string) => void;
};
Expand Down Expand Up @@ -63,6 +63,8 @@ export const WidgetsGalleryPage = ({
open: t.open,
configure: t.configure,
copy: t.copy,
copyIframe: t.copyIframe,
copySvg: t.copySvg,
published: t.published,
draft: t.draft,
remove: t.remove,
Expand Down
2 changes: 2 additions & 0 deletions client/src/shared/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export {
deleteBlock,
deleteWidget,
getPublicWidget,
getPublicWidgetImagePath,
getPublicWidgetImageUrl,
getPublicWidgetPath,
getPublicWidgetUrl,
PUBLIC_WIDGET_MESSAGE_SOURCE,
Expand Down
8 changes: 8 additions & 0 deletions client/src/shared/api/widgets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,11 @@ export const getPublicWidgetUrl = (
embed = false,
dimensions?: PublicWidgetDimensions,
) => `${window.location.origin}${getPublicWidgetPath(slug, embed, dimensions)}`;

export const getPublicWidgetImagePath = (slug: string, locale: 'ru' | 'en' = 'en') => {
const query = locale === 'ru' ? '?locale=ru' : '';
return `/api/public/widgets/${encodeURIComponent(slug)}/image.svg${query}`;
};

export const getPublicWidgetImageUrl = (slug: string, locale: 'ru' | 'en' = 'en') =>
`${window.location.origin}${getPublicWidgetImagePath(slug, locale)}`;
11 changes: 11 additions & 0 deletions client/src/shared/lib/escapeHtml.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export const escapeHtmlAttribute = (value: string) =>
value.replace(/[&<>"']/g, (character) => {
const entities: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};
return entities[character] ?? character;
});
Loading
Loading