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
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ server/src/
server/prisma/ schema.prisma + migrations
client/src/ Feature-Sliced Design: app → pages → widgets → features → entities → shared
entities/widget/ widget types, presets, palettes (mirror server registry) and the HTML/CSS WidgetCanvas used in editor/iframe
pages/widget-editor/ the grid editor (large file, autosave logic)
pages/widget-editor/ the grid editor: model/ (layout rules, normalizeWidget, useWidgetEditor
= load/autosave/save race handling, useGridDrag, useBlockPreviews) + ui/ components
shared/api/ fetch client with access-token + single-flight refresh
shared/locale/ ru/en strings (content.ts)
app/App.tsx custom pathname-based router (no react-router)
Expand All @@ -87,7 +88,7 @@ Server imports must use `.js` extensions (NodeNext).

- Prettier: 2 spaces, single quotes, trailing commas, width 100 (see `.prettierrc`). ESLint flat config in `eslint.config.ts`.
- Match the surrounding style: arrow-function class members on controllers/services, zod `safeParse` + `AppError(400)`, CSS Modules per component.
- Tests are colocated as `*.test.ts(x)`. Server tests use supertest against `createApp()` with Prisma mocked. The client currently has no tests.
- Tests are colocated as `*.test.ts(x)`. Server tests use supertest against `createApp()` with Prisma mocked. Client tests use Testing Library in jsdom (`client/vitest.setup.ts` shims `matchMedia`/`ResizeObserver`); mock `@/shared/api` or stub `fetch` rather than hitting the network. Keep pure logic in `model/` or `lib/` files so it can be tested without rendering.
- Conventional Commits: `feat(scope): …`, `fix(scope): …`, `chore: …`, `docs: …`. Branches: `feature/…`, `fix/…`.
- Update `CHANGELOG.md` under `[Unreleased]` (Keep a Changelog sections: Added / Changed / Fixed / Security) for user-facing changes.
- Don't commit `prompts/`, `.env`, or build output. `prompts/notes.md` is the owner's local backlog — read it for context, don't edit it unless asked.
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ All notable changes to this project are documented here.

### Changed

- Split the 1,200-line widget editor into layout rules, state/autosave, drag and preview hooks, and focused UI components, and shared the embed snippet builder between the gallery and the editor.
- Added client tests for widget rendering, scaling, editor layout rules and autosave, the API client token refresh, and the auth store.
- Removed the automatic palette mode; widgets are either light or dark, and existing auto widgets open as light.
- Unified widget rendering: the editor, public page, iframe embed and SVG export now share one geometry and typography module, so widgets look identical everywhere.
- The editor and public page render the widget at its real size and scale it to fit, instead of reflowing it to the screen width.
Expand Down
2 changes: 1 addition & 1 deletion client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc -b --pretty false",
"test": "vitest run --passWithNoTests"
"test": "vitest run"
},
"dependencies": {
"@gravity-ui/icons": "^2.21.0",
Expand Down
18 changes: 3 additions & 15 deletions client/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,13 @@ import { WidgetEditorPage } from '@/pages/widget-editor';
import {
createWidget,
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 { widgetEmbedCode, type EmbedFormat } from '@/entities/widget/lib/embedCode';
import {
APP_LOCALE_STORAGE_KEY,
APP_THEME_STORAGE_KEY,
Expand Down Expand Up @@ -284,18 +282,8 @@ export const App = () => {
setVisibleWidgets((currentWidgets) => currentWidgets.filter((widget) => widget.id !== id));
};

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}" style="display:block;border:0" loading="lazy"></iframe>`;
})();
await navigator.clipboard?.writeText(code);
const handleCopyWidget = async (widget: WidgetCardData, format: EmbedFormat) => {
await navigator.clipboard?.writeText(widgetEmbedCode(widget, format, locale));
};

useEffect(() => {
Expand Down
26 changes: 26 additions & 0 deletions client/src/entities/widget/lib/canvasStyle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { blockMetrics, blockStyleVars, canvasStyleVars } from '@/entities/widget/lib/canvasStyle';

it('exposes shared canvas geometry as CSS custom properties', () => {
const vars = canvasStyleVars('mint', { width: 600, height: 315 }) as Record<string, unknown>;
expect(vars).toMatchObject({
'--widget-width': '600px',
'--widget-height': '315px',
'--widget-padding': '24px',
'--widget-gap': '18px',
'--block-padding': '20px',
'--widget-light-accent': '#2caa8a',
});
});

it('sizes block typography from the column span, like the SVG export', () => {
expect(blockMetrics({ x: 0, y: 0, width: 1, height: 1 }).contentWidth).toBe(225);
const half = blockStyleVars({ x: 1, y: 2, width: 1, height: 2 }) as Record<string, unknown>;
const full = blockStyleVars({ x: 0, y: 0, width: 2, height: 1 }) as Record<string, unknown>;

expect(half).toMatchObject({
gridColumn: '2 / span 1',
gridRow: '3 / span 2',
'--block-stat': '22.5px',
});
expect(full).toMatchObject({ '--block-stat': '30px', '--block-meta': '12px' });
});
15 changes: 15 additions & 0 deletions client/src/entities/widget/lib/embedCode.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { widgetEmbedCode } from '@/entities/widget/lib/embedCode';

const widget = { slug: 'gh-stats', title: 'My "stats" <widget>', width: 600, height: 315 };

it('builds an iframe snippet with the stored size', () => {
expect(widgetEmbedCode(widget, 'iframe', 'en')).toBe(
`<iframe src="${window.location.origin}/w/gh-stats?embed=1&amp;width=600&amp;height=315" width="600" height="315" style="display:block;border:0" loading="lazy"></iframe>`,
);
});

it('builds an SVG image snippet with an escaped alt and the locale', () => {
expect(widgetEmbedCode(widget, 'svg', 'ru')).toBe(
`<img src="${window.location.origin}/api/public/widgets/gh-stats/image.svg?locale=ru" alt="My &quot;stats&quot; &lt;widget&gt;" width="600" height="315" />`,
);
});
21 changes: 21 additions & 0 deletions client/src/entities/widget/lib/embedCode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { getPublicWidgetImageUrl, getPublicWidgetUrl } from '@/shared/api';
import { escapeHtmlAttribute } from '@/shared/lib/escapeHtml';

export type EmbedFormat = 'iframe' | 'svg';

type EmbeddableWidget = { slug: string; title: string; width: number; height: number };

/** HTML snippet for embedding a public widget, as copied from the gallery and the editor. */
export const widgetEmbedCode = (
widget: EmbeddableWidget,
format: EmbedFormat,
locale: 'ru' | 'en',
) => {
const size = `width="${widget.width}" height="${widget.height}"`;
if (format === 'svg') {
const src = getPublicWidgetImageUrl(widget.slug, locale);
return `<img src="${src}" alt="${escapeHtmlAttribute(widget.title)}" ${size} />`;
}
const src = getPublicWidgetUrl(widget.slug, true, { width: widget.width, height: widget.height });
return `<iframe src="${escapeHtmlAttribute(src)}" ${size} style="display:block;border:0" loading="lazy"></iframe>`;
};
33 changes: 33 additions & 0 deletions client/src/entities/widget/ui/ScaledWidgetFrame.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { render, screen } from '@testing-library/react';

import { ScaledWidgetFrame } from '@/entities/widget/ui/ScaledWidgetFrame';

const renderWithAvailableWidth = (available: number, maxScale?: number) => {
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(available);
render(
<ScaledWidgetFrame width={600} height={300} maxScale={maxScale}>
<div data-testid="widget" />
</ScaledWidgetFrame>,
);
const content = screen.getByTestId('widget').parentElement as HTMLElement;
return { content, frame: content.parentElement as HTMLElement };
};

afterEach(() => {
vi.restoreAllMocks();
});

it('scales the widget down to the available width without reflowing it', () => {
const { content, frame } = renderWithAvailableWidth(300);

expect(content.style.width).toBe('600px');
expect(content.style.height).toBe('300px');
expect(content.style.transform).toBe('scale(0.5)');
expect(frame.style.width).toBe('300px');
expect(frame.style.height).toBe('150px');
});

it('never scales above maxScale', () => {
const { content } = renderWithAvailableWidth(1200);
expect(content.style.transform).toBe('scale(1)');
});
86 changes: 86 additions & 0 deletions client/src/entities/widget/ui/WidgetCanvas.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { render, screen } from '@testing-library/react';

import type { WidgetBlock } from '@/entities/widget/model';
import { WidgetCanvas } from '@/entities/widget/ui/WidgetCanvas';

const githubBlock: WidgetBlock = {
id: 'gh',
type: 'github-stats',
position: 0,
config: { username: 'octocat', layout: { x: 0, y: 0, width: 1, height: 1 } },
};

const renderedGithub = {
id: 'gh',
type: 'github-stats' as const,
position: 0,
data: {
username: 'octocat',
name: 'The Octocat',
publicRepositories: 8,
followers: 24_351,
following: 9,
},
};

it('renders live stats with localized labels and fits long numbers', () => {
render(
<WidgetCanvas
blocks={[githubBlock]}
palette="lavender"
renderedBlocks={[renderedGithub]}
locale="ru"
/>,
);

expect(screen.getByText('The Octocat')).toBeInTheDocument();
expect(screen.getByText('Подписчики')).toBeInTheDocument();
// A 1-column block is too narrow for "24,351" at stat size, so it switches to compact.
expect(screen.getByText('24.4K')).toBeInTheDocument();
});

it('renders at the stored widget size with the palette mode applied', () => {
const { container } = render(
<WidgetCanvas
blocks={[githubBlock]}
palette="mint"
paletteMode="dark"
width={600}
height={315}
/>,
);
const canvas = container.firstElementChild as HTMLElement;

expect(canvas).toHaveAttribute('data-palette-mode', 'dark');
expect(canvas.style.getPropertyValue('--widget-width')).toBe('600px');
expect(canvas.style.getPropertyValue('--widget-height')).toBe('315px');
});

it('asks for a username before a data block can load', () => {
render(
<WidgetCanvas
blocks={[{ ...githubBlock, config: { layout: githubBlock.config.layout } }]}
palette="lavender"
locale="en"
/>,
);

expect(screen.getByRole('status')).toHaveTextContent('Add a GitHub username in block settings');
});

it('shows block errors instead of data', () => {
render(
<WidgetCanvas
blocks={[githubBlock]}
palette="lavender"
renderedBlocks={[{ id: 'gh', type: 'github-stats', position: 0, error: 'Rate limited' }]}
/>,
);

expect(screen.getByText('Rate limited')).toBeInTheDocument();
});

it('shows the empty state without blocks', () => {
render(<WidgetCanvas blocks={[]} palette="lavender" locale="ru" />);
expect(screen.getByText('Добавьте блок, чтобы начать.')).toBeInTheDocument();
});
57 changes: 57 additions & 0 deletions client/src/features/auth/model/authStore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { useAuthStore } from '@/features/auth/model/authStore';

const user = { id: 'user-1', email: 'person@example.com', name: 'Person' };

beforeEach(() => {
useAuthStore.setState({ token: null, user: null, status: 'idle', error: null });
});

afterEach(() => {
vi.unstubAllGlobals();
});

it('shares one refresh request between concurrent callers', async () => {
let resolveFetch: (response: Response) => void = () => {};
const fetchMock = vi.fn(() => new Promise<Response>((resolve) => (resolveFetch = resolve)));
vi.stubGlobal('fetch', fetchMock);

const first = useAuthStore.getState().refresh();
const second = useAuthStore.getState().refresh();
resolveFetch(
new Response(JSON.stringify({ accessToken: 'token', user }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);

await expect(Promise.all([first, second])).resolves.toEqual([true, true]);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(useAuthStore.getState()).toMatchObject({ token: 'token', user, status: 'authenticated' });
});

it('marks the session unauthenticated when refresh fails', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 401 })));

await useAuthStore.getState().checkAuth();

expect(useAuthStore.getState()).toMatchObject({ token: null, status: 'unauthenticated' });
});

it('keeps the error message and rethrows on failed login', async () => {
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValue(
new Response(JSON.stringify({ error: 'Invalid email or password' }), { status: 401 }),
),
);

await expect(useAuthStore.getState().login('person@example.com', 'wrong')).rejects.toThrow(
'Invalid email or password',
);
expect(useAuthStore.getState()).toMatchObject({
status: 'unauthenticated',
error: 'Invalid email or password',
});
});
34 changes: 34 additions & 0 deletions client/src/pages/widget-editor/model/editorCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { Widget } from '@/entities/widget/model';

// Unsaved editor state survives reloads and crashes until the next successful save.
type CachedEditorState = { savedAt: number; widget: Widget };

const cacheKey = (widgetId: string) => `widget-editor:v4:${widgetId}`;

export const readCachedWidget = (widgetId: string): Widget | null => {
try {
const cached = JSON.parse(localStorage.getItem(cacheKey(widgetId)) ?? '') as CachedEditorState;
return cached.widget;
} catch {
return null;
}
};

export const writeCachedWidget = (widget: Widget) => {
try {
localStorage.setItem(
cacheKey(widget.id),
JSON.stringify({ savedAt: Date.now(), widget } satisfies CachedEditorState),
);
} catch {
// Storage can be full or disabled; the editor still works, just without crash recovery.
}
};

export const clearCachedWidget = (widgetId: string) => {
try {
localStorage.removeItem(cacheKey(widgetId));
} catch {
// See writeCachedWidget.
}
};
Loading
Loading