From 525cadeaeb467627daf58b54210b0be2fafe6992 Mon Sep 17 00:00:00 2001 From: TheGeniusOfEternity Date: Mon, 28 Sep 2026 23:27:29 +0300 Subject: [PATCH] refactor(editor): split widget editor and add client tests - Move layout rules and widget normalization to model/layout.ts, the local draft cache to model/editorCache.ts - Extract useWidgetEditor (load, autosave, save race), useGridDrag and useBlockPreviews hooks - Split UI into EditorHeader, BlockLibrary, EditorCanvas, EditorBlock, WidgetConfigPanel, BlockConfigPanel and MobileFallback - Share the iframe/SVG embed snippet builder with the gallery - Add client tests (31) and drop --passWithNoTests --- AGENTS.md | 5 +- CHANGELOG.md | 2 + client/package.json | 2 +- client/src/app/App.tsx | 18 +- .../entities/widget/lib/canvasStyle.test.ts | 26 + .../src/entities/widget/lib/embedCode.test.ts | 15 + client/src/entities/widget/lib/embedCode.ts | 21 + .../widget/ui/ScaledWidgetFrame.test.tsx | 33 + .../entities/widget/ui/WidgetCanvas.test.tsx | 86 ++ .../src/features/auth/model/authStore.test.ts | 57 + .../pages/widget-editor/model/editorCache.ts | 34 + .../pages/widget-editor/model/layout.test.ts | 125 ++ .../src/pages/widget-editor/model/layout.ts | 164 +++ .../widget-editor/model/useBlockPreviews.ts | 77 ++ .../pages/widget-editor/model/useGridDrag.ts | 148 +++ .../model/useWidgetEditor.test.ts | 146 +++ .../widget-editor/model/useWidgetEditor.ts | 262 ++++ .../widget-editor/ui/BlockConfigPanel.tsx | 102 ++ .../pages/widget-editor/ui/BlockLibrary.tsx | 54 + .../pages/widget-editor/ui/EditorBlock.tsx | 94 ++ .../pages/widget-editor/ui/EditorCanvas.tsx | 112 ++ .../pages/widget-editor/ui/EditorHeader.tsx | 78 ++ .../pages/widget-editor/ui/MobileFallback.tsx | 21 + .../widget-editor/ui/WidgetConfigPanel.tsx | 77 ++ .../widget-editor/ui/WidgetEditorPage.tsx | 1165 ++--------------- client/src/shared/api/client.test.ts | 58 + client/vitest.setup.ts | 23 + 27 files changed, 1908 insertions(+), 1097 deletions(-) create mode 100644 client/src/entities/widget/lib/canvasStyle.test.ts create mode 100644 client/src/entities/widget/lib/embedCode.test.ts create mode 100644 client/src/entities/widget/lib/embedCode.ts create mode 100644 client/src/entities/widget/ui/ScaledWidgetFrame.test.tsx create mode 100644 client/src/entities/widget/ui/WidgetCanvas.test.tsx create mode 100644 client/src/features/auth/model/authStore.test.ts create mode 100644 client/src/pages/widget-editor/model/editorCache.ts create mode 100644 client/src/pages/widget-editor/model/layout.test.ts create mode 100644 client/src/pages/widget-editor/model/layout.ts create mode 100644 client/src/pages/widget-editor/model/useBlockPreviews.ts create mode 100644 client/src/pages/widget-editor/model/useGridDrag.ts create mode 100644 client/src/pages/widget-editor/model/useWidgetEditor.test.ts create mode 100644 client/src/pages/widget-editor/model/useWidgetEditor.ts create mode 100644 client/src/pages/widget-editor/ui/BlockConfigPanel.tsx create mode 100644 client/src/pages/widget-editor/ui/BlockLibrary.tsx create mode 100644 client/src/pages/widget-editor/ui/EditorBlock.tsx create mode 100644 client/src/pages/widget-editor/ui/EditorCanvas.tsx create mode 100644 client/src/pages/widget-editor/ui/EditorHeader.tsx create mode 100644 client/src/pages/widget-editor/ui/MobileFallback.tsx create mode 100644 client/src/pages/widget-editor/ui/WidgetConfigPanel.tsx create mode 100644 client/src/shared/api/client.test.ts diff --git a/AGENTS.md b/AGENTS.md index 6bdfbce..540ce56 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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) @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d8c300..cc0f19e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/client/package.json b/client/package.json index 60542f2..44f6a65 100644 --- a/client/package.json +++ b/client/package.json @@ -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", diff --git a/client/src/app/App.tsx b/client/src/app/App.tsx index e97a791..529c008 100644 --- a/client/src/app/App.tsx +++ b/client/src/app/App.tsx @@ -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, @@ -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' - ? `${escapeHtmlAttribute(widget.title)}` - : (() => { - const src = getPublicWidgetUrl(widget.slug, true, { - width: widget.width, - height: widget.height, - }); - return ``; - })(); - await navigator.clipboard?.writeText(code); + const handleCopyWidget = async (widget: WidgetCardData, format: EmbedFormat) => { + await navigator.clipboard?.writeText(widgetEmbedCode(widget, format, locale)); }; useEffect(() => { diff --git a/client/src/entities/widget/lib/canvasStyle.test.ts b/client/src/entities/widget/lib/canvasStyle.test.ts new file mode 100644 index 0000000..35b5b18 --- /dev/null +++ b/client/src/entities/widget/lib/canvasStyle.test.ts @@ -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; + 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; + const full = blockStyleVars({ x: 0, y: 0, width: 2, height: 1 }) as Record; + + expect(half).toMatchObject({ + gridColumn: '2 / span 1', + gridRow: '3 / span 2', + '--block-stat': '22.5px', + }); + expect(full).toMatchObject({ '--block-stat': '30px', '--block-meta': '12px' }); +}); diff --git a/client/src/entities/widget/lib/embedCode.test.ts b/client/src/entities/widget/lib/embedCode.test.ts new file mode 100644 index 0000000..349f68f --- /dev/null +++ b/client/src/entities/widget/lib/embedCode.test.ts @@ -0,0 +1,15 @@ +import { widgetEmbedCode } from '@/entities/widget/lib/embedCode'; + +const widget = { slug: 'gh-stats', title: 'My "stats" ', width: 600, height: 315 }; + +it('builds an iframe snippet with the stored size', () => { + expect(widgetEmbedCode(widget, 'iframe', 'en')).toBe( + ``, + ); +}); + +it('builds an SVG image snippet with an escaped alt and the locale', () => { + expect(widgetEmbedCode(widget, 'svg', 'ru')).toBe( + `My "stats" <widget>`, + ); +}); diff --git a/client/src/entities/widget/lib/embedCode.ts b/client/src/entities/widget/lib/embedCode.ts new file mode 100644 index 0000000..8f96607 --- /dev/null +++ b/client/src/entities/widget/lib/embedCode.ts @@ -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 `${escapeHtmlAttribute(widget.title)}`; + } + const src = getPublicWidgetUrl(widget.slug, true, { width: widget.width, height: widget.height }); + return ``; +}; diff --git a/client/src/entities/widget/ui/ScaledWidgetFrame.test.tsx b/client/src/entities/widget/ui/ScaledWidgetFrame.test.tsx new file mode 100644 index 0000000..d337795 --- /dev/null +++ b/client/src/entities/widget/ui/ScaledWidgetFrame.test.tsx @@ -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( + +
+ , + ); + 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)'); +}); diff --git a/client/src/entities/widget/ui/WidgetCanvas.test.tsx b/client/src/entities/widget/ui/WidgetCanvas.test.tsx new file mode 100644 index 0000000..341c946 --- /dev/null +++ b/client/src/entities/widget/ui/WidgetCanvas.test.tsx @@ -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( + , + ); + + 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( + , + ); + 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( + , + ); + + expect(screen.getByRole('status')).toHaveTextContent('Add a GitHub username in block settings'); +}); + +it('shows block errors instead of data', () => { + render( + , + ); + + expect(screen.getByText('Rate limited')).toBeInTheDocument(); +}); + +it('shows the empty state without blocks', () => { + render(); + expect(screen.getByText('Добавьте блок, чтобы начать.')).toBeInTheDocument(); +}); diff --git a/client/src/features/auth/model/authStore.test.ts b/client/src/features/auth/model/authStore.test.ts new file mode 100644 index 0000000..4a5338a --- /dev/null +++ b/client/src/features/auth/model/authStore.test.ts @@ -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((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', + }); +}); diff --git a/client/src/pages/widget-editor/model/editorCache.ts b/client/src/pages/widget-editor/model/editorCache.ts new file mode 100644 index 0000000..69f5504 --- /dev/null +++ b/client/src/pages/widget-editor/model/editorCache.ts @@ -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. + } +}; diff --git a/client/src/pages/widget-editor/model/layout.test.ts b/client/src/pages/widget-editor/model/layout.test.ts new file mode 100644 index 0000000..9a1dba6 --- /dev/null +++ b/client/src/pages/widget-editor/model/layout.test.ts @@ -0,0 +1,125 @@ +import type { Widget, WidgetBlock } from '@/entities/widget/model'; +import { + canPlaceBlock, + dragGridRows, + findPlacement, + normalizeWidget, + withBlockLayout, +} from '@/pages/widget-editor/model/layout'; + +const block = (id: string, layout: unknown, extra: Partial = {}): WidgetBlock => ({ + id, + type: 'github-stats', + position: 0, + config: { layout }, + ...extra, +}); + +const widgetWith = (blocks: WidgetBlock[], config: Partial = {}): Widget => ({ + id: 'widget-1', + title: 'Widget', + slug: 'widget', + width: 600, + height: 315, + public: false, + createdAt: '', + updatedAt: '', + config: { + palette: 'lavender', + paletteMode: 'light', + grid: { columns: 2 }, + renderFormat: 'iframe', + ...config, + }, + blocks, +}); + +describe('canPlaceBlock', () => { + const widget = widgetWith([ + block('a', { x: 0, y: 0, width: 1, height: 1 }), + block('b', { x: 1, y: 0, width: 1, height: 2 }), + ]); + + it('rejects overlaps and out-of-grid layouts', () => { + expect(canPlaceBlock(widget, 'a', { x: 1, y: 1, width: 1, height: 1 })).toBe(false); + expect(canPlaceBlock(widget, 'a', { x: 1, y: 2, width: 2, height: 1 })).toBe(false); + }); + + it('ignores the block being moved', () => { + expect(canPlaceBlock(widget, 'b', { x: 1, y: 1, width: 1, height: 1 })).toBe(true); + expect(canPlaceBlock(widget, 'a', { x: 0, y: 1, width: 1, height: 2 })).toBe(true); + }); +}); + +describe('findPlacement', () => { + it('keeps the preferred cell when it is free', () => { + const widget = widgetWith([block('a', { x: 0, y: 0, width: 1, height: 1 })]); + expect(findPlacement(widget, 'a', 2, 1, 0, 0)).toEqual({ x: 0, y: 0, width: 2, height: 1 }); + }); + + it('moves a grown block below the blocks it would cover', () => { + const widget = widgetWith([ + block('a', { x: 0, y: 0, width: 1, height: 1 }), + block('b', { x: 1, y: 0, width: 1, height: 1 }), + ]); + expect(findPlacement(widget, 'a', 2, 1, 0, 0)).toEqual({ x: 0, y: 1, width: 2, height: 1 }); + }); +}); + +it('offers one spare drop row below the lowest block', () => { + expect(dragGridRows(widgetWith([]))).toBe(2); + expect(dragGridRows(widgetWith([block('a', { x: 0, y: 1, width: 1, height: 2 })]))).toBe(4); +}); + +it('updates one block layout without touching the others', () => { + const widget = widgetWith([ + block('a', { x: 0, y: 0, width: 1, height: 1 }), + block('b', { x: 1, y: 0, width: 1, height: 1 }), + ]); + const next = withBlockLayout(widget, 'b', { x: 0, y: 1, width: 2, height: 1 }); + expect(next.blocks[0]).toBe(widget.blocks[0]); + expect(next.blocks[1].config.layout).toEqual({ x: 0, y: 1, width: 2, height: 1 }); +}); + +describe('normalizeWidget', () => { + it('leaves an already normalized widget unchanged', () => { + const { widget, changed } = normalizeWidget( + widgetWith([ + block( + 'a', + { x: 0, y: 0, width: 1, height: 1 }, + { config: { username: 'octocat', layout: { x: 0, y: 0, width: 1, height: 1 } } }, + ), + ]), + ); + expect(changed).toBe(false); + expect(widget.height).toBe(315); + }); + + it('migrates legacy widgets and marks them for saving', () => { + const legacy = widgetWith( + [ + { ...block('second', undefined), position: 1 }, + { ...block('first', { x: 5, y: 0, width: 3, height: 9 }), position: 0 }, + ], + { + sources: { github: { username: 'octocat' } }, + paletteMode: 'auto' as never, + grid: { columns: 1 }, + }, + ); + + const { widget, changed } = normalizeWidget(legacy); + + expect(changed).toBe(true); + expect(widget.config.paletteMode).toBe('light'); + expect(widget.config.grid.columns).toBe(2); + expect(widget.blocks.map((item) => item.id)).toEqual(['first', 'second']); + expect(widget.blocks[0].config).toMatchObject({ + username: 'octocat', + layout: { x: 1, y: 0, width: 1, height: 2 }, + }); + expect(widget.blocks[1].config.layout).toEqual({ x: 0, y: 0, width: 1, height: 1 }); + expect(widget.height).toBe(600); + }); +}); diff --git a/client/src/pages/widget-editor/model/layout.ts b/client/src/pages/widget-editor/model/layout.ts new file mode 100644 index 0000000..f2e5fbb --- /dev/null +++ b/client/src/pages/widget-editor/model/layout.ts @@ -0,0 +1,164 @@ +import { MAX_GRID_COLUMNS, widgetDimensions } from '@shared/widget/geometry'; +import type { + BlockLayout, + BlockType, + PaletteMode, + SourceType, + Widget, + WidgetBlock, +} from '@/entities/widget/model'; + +export const MAX_BLOCKS = 5; +export const MAX_COLUMNS = MAX_GRID_COLUMNS; +const MAX_ROW = 100; +const DEFAULT_LAYOUT: BlockLayout = { x: 0, y: 0, width: 1, height: 1 }; + +export const blockSizes = [ + { width: 1, height: 1, label: '1 × 1' }, + { width: 1, height: 2, label: '1 × 2' }, + { width: 2, height: 1, label: '2 × 1' }, + { width: 2, height: 2, label: '2 × 2' }, +] as const; + +export const clamp = (value: number, min: number, max: number) => + Math.min(Math.max(value, min), max); + +export const sourceForBlock = (type: BlockType): SourceType | null => { + if (type.startsWith('github')) return 'github'; + if (type.startsWith('leetcode')) return 'leetcode'; + return null; +}; + +/** Layout stored on a block, trusted as-is (blocks are normalized when the widget loads). */ +export const getLayout = (block: WidgetBlock): BlockLayout => { + const value = block.config.layout; + return value && typeof value === 'object' ? (value as BlockLayout) : DEFAULT_LAYOUT; +}; + +/** Layout read from possibly legacy block config, clamped into the grid. */ +const layoutFromBlock = (block: WidgetBlock, index: number): BlockLayout => { + const input = block.config.layout; + const value = + input && typeof input === 'object' ? (input as Partial) : DEFAULT_LAYOUT; + const x = clamp(typeof value.x === 'number' ? value.x : 0, 0, MAX_COLUMNS - 1); + const width = clamp(typeof value.width === 'number' ? value.width : 1, 1, MAX_COLUMNS - x); + return { + x, + y: clamp(typeof value.y === 'number' ? value.y : index, 0, MAX_ROW), + width, + height: clamp(typeof value.height === 'number' ? value.height : 1, 1, 2), + }; +}; + +export const getWidgetDimensions = (blocks: WidgetBlock[]) => + widgetDimensions(blocks.map(layoutFromBlock)); + +export const layoutsFor = (widget: Widget) => + widget.blocks.map((block) => ({ blockId: block.id, layout: getLayout(block) })); + +const layoutsOverlap = (left: BlockLayout, right: BlockLayout) => + left.x < right.x + right.width && + left.x + left.width > right.x && + left.y < right.y + right.height && + left.y + left.height > right.y; + +export const canPlaceBlock = (widget: Widget, blockId: string, nextLayout: BlockLayout) => + nextLayout.x >= 0 && + nextLayout.x + nextLayout.width <= MAX_COLUMNS && + !widget.blocks.some( + (block) => block.id !== blockId && layoutsOverlap(nextLayout, getLayout(block)), + ); + +/** First free spot for a block of the given size: the preferred cell, then scanning down column 0. */ +export const findPlacement = ( + widget: Widget, + blockId: string, + width: number, + height: number, + preferredX: number, + preferredY: number, +) => { + const startY = clamp(preferredY, 0, MAX_ROW); + const startX = clamp(preferredX, 0, MAX_COLUMNS - width); + const candidates = [ + { x: startX, y: startY }, + ...Array.from({ length: MAX_ROW + 1 - startY }, (_, index) => ({ x: 0, y: startY + index })), + ]; + return candidates + .map(({ x, y }) => ({ x, y, width, height })) + .find((layout) => canPlaceBlock(widget, blockId, layout)); +}; + +/** Rows shown while dragging: occupied rows plus one spare drop row (at least two). */ +export const dragGridRows = (widget: Widget) => + Math.min( + MAX_ROW, + Math.max( + 2, + ...widget.blocks.map((block) => { + const layout = getLayout(block); + return layout.y + layout.height + 1; + }), + ), + ); + +export const occupiedRows = (widget: Widget) => + Math.max(1, ...widget.blocks.map((block) => getLayout(block).y + getLayout(block).height)); + +export const withBlockLayout = (widget: Widget, blockId: string, layout: BlockLayout): Widget => ({ + ...widget, + config: { ...widget.config, grid: { columns: MAX_COLUMNS } }, + blocks: widget.blocks.map((block) => + block.id === blockId ? { ...block, config: { ...block.config, layout } } : block, + ), +}); + +/** + * Brings a widget from the API or local cache into the shape the editor works with: every block + * has a clamped layout and its own username (legacy widgets kept it on the widget), sizes are + * recomputed, and removed options fall back to defaults. `changed` means it should be saved. + */ +export const normalizeWidget = (widget: Widget) => { + const legacySources = widget.config?.sources ?? {}; + const paletteMode: PaletteMode = widget.config?.paletteMode === 'dark' ? 'dark' : 'light'; + let changed = + widget.config?.grid?.columns !== MAX_COLUMNS || widget.config?.paletteMode !== paletteMode; + const blocks = [...widget.blocks] + .sort((left, right) => left.position - right.position) + .map((block, index) => { + const layout = layoutFromBlock(block, index); + const source = sourceForBlock(block.type); + const currentUsername = + typeof block.config.username === 'string' ? block.config.username : ''; + const legacyUsername = source ? legacySources[source]?.username : undefined; + const username = currentUsername || legacyUsername; + if (!block.config.layout || (source && !currentUsername && legacyUsername)) changed = true; + return { + ...block, + position: index, + config: { + ...block.config, + ...(source && username ? { username } : {}), + layout, + }, + }; + }); + const dimensions = getWidgetDimensions(blocks); + changed = changed || widget.width !== dimensions.width || widget.height !== dimensions.height; + + return { + widget: { + ...widget, + ...dimensions, + config: { + ...widget.config, + palette: widget.config?.palette ?? 'lavender', + paletteMode, + grid: { columns: MAX_COLUMNS }, + renderFormat: 'iframe' as const, + }, + blocks, + }, + changed, + }; +}; diff --git a/client/src/pages/widget-editor/model/useBlockPreviews.ts b/client/src/pages/widget-editor/model/useBlockPreviews.ts new file mode 100644 index 0000000..7b84495 --- /dev/null +++ b/client/src/pages/widget-editor/model/useBlockPreviews.ts @@ -0,0 +1,77 @@ +import { useEffect, useState, type RefObject } from 'react'; + +import type { RenderedBlock, Widget } from '@/entities/widget/model'; +import { previewWidgetBlock } from '@/shared/api'; +import { sourceForBlock } from '@/pages/widget-editor/model/layout'; + +const PREVIEW_DEBOUNCE_MS = 350; + +const previewSignature = (widget: Widget | null) => + widget?.blocks + .map((block) => + JSON.stringify({ + id: block.id, + type: block.type, + username: block.config.username, + limit: block.config.limit, + }), + ) + .join('|') ?? ''; + +/** + * Live data for blocks that have a data source and a username. Refetches only when a block's + * identity, username or limit changes, not on every edit (layout, text, palette). + */ +export const useBlockPreviews = ( + widget: Widget | null, + widgetRef: RefObject, + unavailableMessage: string, +) => { + const [previews, setPreviews] = useState>({}); + const signature = previewSignature(widget); + + useEffect(() => { + const currentWidget = widgetRef.current; + if (!currentWidget) return; + let cancelled = false; + const timeout = window.setTimeout(() => { + const previewable = currentWidget.blocks.filter((block) => { + const username = + typeof block.config.username === 'string' ? block.config.username.trim() : ''; + return sourceForBlock(block.type) !== null && Boolean(username); + }); + + if (previewable.length === 0) { + setPreviews({}); + return; + } + + void Promise.all( + previewable.map(async (block) => { + try { + return [block.id, await previewWidgetBlock(currentWidget.id, block)] as const; + } catch (previewError) { + return [ + block.id, + { + id: block.id, + type: block.type, + position: block.position, + error: previewError instanceof Error ? previewError.message : unavailableMessage, + }, + ] as const; + } + }), + ).then((results) => { + if (!cancelled) setPreviews(Object.fromEntries(results)); + }); + }, PREVIEW_DEBOUNCE_MS); + + return () => { + cancelled = true; + window.clearTimeout(timeout); + }; + }, [signature, unavailableMessage, widget?.id, widgetRef]); + + return previews; +}; diff --git a/client/src/pages/widget-editor/model/useGridDrag.ts b/client/src/pages/widget-editor/model/useGridDrag.ts new file mode 100644 index 0000000..2b68ef3 --- /dev/null +++ b/client/src/pages/widget-editor/model/useGridDrag.ts @@ -0,0 +1,148 @@ +import { useRef, useState, type PointerEvent, type RefObject } from 'react'; +import { flushSync } from 'react-dom'; + +import type { Widget } from '@/entities/widget/model'; +import { GRID_GAP } from '@shared/widget/geometry'; +import { + MAX_COLUMNS, + canPlaceBlock, + clamp, + findPlacement, + getLayout, + withBlockLayout, +} from '@/pages/widget-editor/model/layout'; + +type Cell = { x: number; y: number }; + +/** + * Pointer drag between grid cells and size presets for editor blocks. Works on a scaled canvas: + * pointer math converts screen pixels to grid cells using the grid's rendered size. + */ +export const useGridDrag = ({ + widgetRef, + updateLocalWidget, + onDragStart, +}: { + widgetRef: RefObject; + updateLocalWidget: (updater: (current: Widget) => Widget) => void; + onDragStart: (blockId: string) => void; +}) => { + const gridRef = useRef(null); + const dragRef = useRef<{ blockId: string; pointerId: number; preview: Cell } | null>(null); + const [draggingBlockId, setDraggingBlockId] = useState(null); + const [dropCell, setDropCell] = useState(null); + + const cellFromPoint = (clientX: number, clientY: number, width: number): Cell | null => { + const grid = gridRef.current; + if (!grid) return null; + const bounds = grid.getBoundingClientRect(); + // Bounds are in screen pixels, which differ from layout pixels when the canvas is scaled. + const gap = GRID_GAP * (bounds.width / grid.offsetWidth || 1); + const cell = (bounds.width - gap * (MAX_COLUMNS - 1)) / MAX_COLUMNS; + if (cell <= 0) return null; + return { + x: clamp(Math.floor((clientX - bounds.left) / (cell + gap)), 0, MAX_COLUMNS - width), + y: clamp(Math.floor((clientY - bounds.top) / (cell + gap)), 0, 100), + }; + }; + + const endDrag = () => { + dragRef.current = null; + setDraggingBlockId(null); + setDropCell(null); + }; + + const onPointerDown = (event: PointerEvent, blockId: string) => { + if (event.button !== 0) return; + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.setPointerCapture(event.pointerId); + const block = widgetRef.current?.blocks.find((item) => item.id === blockId); + if (!block) return; + const { x, y } = getLayout(block); + dragRef.current = { blockId, pointerId: event.pointerId, preview: { x, y } }; + setDraggingBlockId(blockId); + setDropCell({ x, y }); + onDragStart(blockId); + }; + + const onPointerMove = (event: PointerEvent) => { + const drag = dragRef.current; + if (drag?.pointerId !== event.pointerId) return; + event.preventDefault(); + const block = widgetRef.current?.blocks.find((item) => item.id === drag.blockId); + if (!block) return; + const cell = cellFromPoint(event.clientX, event.clientY, getLayout(block).width); + if (!cell) return; + drag.preview = cell; + setDropCell(cell); + }; + + const onPointerUp = (event: PointerEvent) => { + const drag = dragRef.current; + const current = widgetRef.current; + if (!drag || drag.pointerId !== event.pointerId || !current) return; + const block = current.blocks.find((item) => item.id === drag.blockId); + if (block) { + const nextLayout = { ...getLayout(block), ...drag.preview }; + if (canPlaceBlock(current, drag.blockId, nextLayout)) { + updateLocalWidget((widget) => withBlockLayout(widget, drag.blockId, nextLayout)); + } + } + event.currentTarget.releasePointerCapture(event.pointerId); + endDrag(); + }; + + // FLIP animation from the old block rect to the new one after a size change. + const animateResize = (blockId: string, applyChange: () => void) => { + const blockElement = gridRef.current?.querySelector( + `[data-block-id="${CSS.escape(blockId)}"]`, + ); + const first = blockElement?.getBoundingClientRect(); + flushSync(applyChange); + if ( + !blockElement || + !first || + !blockElement.animate || + window.matchMedia('(prefers-reduced-motion: reduce)').matches + ) { + return; + } + requestAnimationFrame(() => { + const last = blockElement.getBoundingClientRect(); + if (!last.width || !last.height) return; + // The canvas may be scaled down; translate in the block's own (unscaled) pixels. + const scale = last.width / blockElement.offsetWidth || 1; + blockElement.animate( + [ + { + transform: `translate(${(first.left - last.left) / scale}px, ${(first.top - last.top) / scale}px) scale(${first.width / last.width}, ${first.height / last.height})`, + transformOrigin: 'top left', + }, + { transform: 'translate(0, 0) scale(1, 1)', transformOrigin: 'top left' }, + ], + { duration: 260, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' }, + ); + }); + }; + + const resizeBlock = (blockId: string, width: number, height: number) => { + const current = widgetRef.current; + const block = current?.blocks.find((item) => item.id === blockId); + if (!current || !block) return; + const { x, y } = getLayout(block); + const nextLayout = findPlacement(current, blockId, width, height, x, y); + if (!nextLayout) return; + animateResize(blockId, () => + updateLocalWidget((widget) => withBlockLayout(widget, blockId, nextLayout)), + ); + }; + + return { + gridRef, + draggingBlockId, + dropCell, + pointerHandlers: { onPointerDown, onPointerMove, onPointerUp, onPointerCancel: endDrag }, + resizeBlock, + }; +}; diff --git a/client/src/pages/widget-editor/model/useWidgetEditor.test.ts b/client/src/pages/widget-editor/model/useWidgetEditor.test.ts new file mode 100644 index 0000000..36e4e8f --- /dev/null +++ b/client/src/pages/widget-editor/model/useWidgetEditor.test.ts @@ -0,0 +1,146 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; + +import type { Widget } from '@/entities/widget/model'; +import { useWidgetEditor } from '@/pages/widget-editor/model/useWidgetEditor'; + +const api = vi.hoisted(() => ({ + getWidget: vi.fn(), + updateBlockLayouts: vi.fn(), + updateBlock: vi.fn(), + updateWidget: vi.fn(), + addBlock: vi.fn(), + deleteBlock: vi.fn(), +})); + +vi.mock('@/shared/api', () => api); + +const serverWidget: Widget = { + id: 'widget-1', + title: 'Widget', + slug: 'widget', + width: 600, + height: 315, + public: false, + createdAt: '', + updatedAt: '', + config: { + palette: 'lavender', + paletteMode: 'light', + grid: { columns: 2 }, + renderFormat: 'iframe', + }, + blocks: [ + { + id: 'block-1', + type: 'text', + position: 0, + config: { text: 'Hello', layout: { x: 0, y: 0, width: 1, height: 1 } }, + }, + ], +}; + +const messages = { unavailable: 'Unavailable', blocksLimit: 'Too many blocks' }; + +const renderEditor = async () => { + const hook = renderHook(() => + useWidgetEditor({ widgetId: serverWidget.id, enabled: true, messages }), + ); + await waitFor(() => expect(hook.result.current.isLoading).toBe(false)); + return hook; +}; + +beforeEach(() => { + localStorage.clear(); + vi.clearAllMocks(); + api.getWidget.mockResolvedValue(serverWidget); + api.updateBlockLayouts.mockResolvedValue(serverWidget); + api.updateBlock.mockResolvedValue({}); + api.updateWidget.mockImplementation(async (_id: string, input: Partial) => ({ + ...serverWidget, + ...input, + })); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +it('loads a normalized widget and selects its first block', async () => { + const { result } = await renderEditor(); + + expect(result.current.widget?.title).toBe('Widget'); + expect(result.current.selectedBlock?.id).toBe('block-1'); + expect(result.current.isDirty).toBe(false); +}); + +it('autosaves local edits after the debounce and clears the dirty flag', async () => { + const { result } = await renderEditor(); + vi.useFakeTimers(); + + act(() => result.current.updateLocalWidget((widget) => ({ ...widget, title: 'Renamed' }))); + expect(result.current.isDirty).toBe(true); + expect(localStorage.length).toBe(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1500); + }); + + expect(api.updateWidget).toHaveBeenCalledWith( + 'widget-1', + expect.objectContaining({ title: 'Renamed' }), + ); + expect(result.current.isDirty).toBe(false); + expect(localStorage.length).toBe(0); +}); + +it('keeps edits made while a save is in flight', async () => { + const { result } = await renderEditor(); + let finishSave: () => void = () => {}; + api.updateBlockLayouts.mockImplementationOnce( + () => new Promise((resolve) => (finishSave = () => resolve(serverWidget))), + ); + + act(() => result.current.updateLocalWidget((widget) => ({ ...widget, title: 'First' }))); + let saving: Promise = Promise.resolve(); + act(() => { + saving = result.current.save(); + }); + act(() => result.current.updateLocalWidget((widget) => ({ ...widget, title: 'Second' }))); + await act(async () => { + finishSave(); + await saving; + }); + + expect(result.current.widget?.title).toBe('Second'); + expect(result.current.isDirty).toBe(true); +}); + +it('restores unsaved edits from the local cache', async () => { + localStorage.setItem( + 'widget-editor:v4:widget-1', + JSON.stringify({ savedAt: 1, widget: { ...serverWidget, title: 'Unsaved' } }), + ); + + const { result } = await renderEditor(); + + expect(result.current.widget?.title).toBe('Unsaved'); + expect(result.current.isDirty).toBe(true); +}); + +it('refuses to add blocks past the limit', async () => { + api.getWidget.mockResolvedValue({ + ...serverWidget, + blocks: Array.from({ length: 5 }, (_, index) => ({ + ...serverWidget.blocks[0], + id: `block-${index}`, + position: index, + config: { text: 'x', layout: { x: 0, y: index, width: 1, height: 1 } }, + })), + }); + const { result } = await renderEditor(); + + await act(() => result.current.addBlock('text')); + + expect(api.addBlock).not.toHaveBeenCalled(); + expect(result.current.error).toBe('Too many blocks'); +}); diff --git a/client/src/pages/widget-editor/model/useWidgetEditor.ts b/client/src/pages/widget-editor/model/useWidgetEditor.ts new file mode 100644 index 0000000..83e8f45 --- /dev/null +++ b/client/src/pages/widget-editor/model/useWidgetEditor.ts @@ -0,0 +1,262 @@ +import { useEffect, useEffectEvent, useRef, useState } from 'react'; + +import { defaultBlockConfig, type BlockType, type Widget } from '@/entities/widget/model'; +import { + addBlock, + deleteBlock, + getWidget, + updateBlock, + updateBlockLayouts, + updateWidget, +} from '@/shared/api'; +import { + clearCachedWidget, + readCachedWidget, + writeCachedWidget, +} from '@/pages/widget-editor/model/editorCache'; +import { + MAX_BLOCKS, + MAX_COLUMNS, + getWidgetDimensions, + layoutsFor, + normalizeWidget, +} from '@/pages/widget-editor/model/layout'; + +export type EditorPanel = 'widget' | 'block'; + +const AUTOSAVE_DELAY_MS = 1500; + +type Messages = { unavailable: string; blocksLimit: string }; + +const errorMessage = (error: unknown, fallback: string) => + error instanceof Error ? error.message : fallback; + +/** + * Editor state: loads the widget, applies local edits, and saves them with debounced autosave. + * Edits made while a save is in flight bump `editVersion`, so the save result doesn't overwrite + * them and the widget stays dirty for the next save. + */ +export const useWidgetEditor = ({ + widgetId, + enabled, + messages, + onSave, +}: { + widgetId: string; + enabled: boolean; + messages: Messages; + onSave?: (widget: Widget) => void; +}) => { + const [widget, setWidget] = useState(null); + const [selectedBlockId, setSelectedBlockId] = useState(null); + const [activePanel, setActivePanel] = useState('widget'); + const [isLoading, setLoading] = useState(true); + const [isSaving, setSaving] = useState(false); + const [isDirty, setDirty] = useState(false); + const [error, setError] = useState(null); + const widgetRef = useRef(null); + const savePromiseRef = useRef | null>(null); + const editVersionRef = useRef(0); + const isDirtyRef = useRef(false); + + const markSaved = (saved: Widget) => { + widgetRef.current = saved; + setWidget(saved); + isDirtyRef.current = false; + setDirty(false); + clearCachedWidget(saved.id); + }; + + useEffect(() => { + if (!enabled) return; + let cancelled = false; + void getWidget(widgetId) + .then((serverWidget) => { + if (cancelled) return; + const cachedWidget = readCachedWidget(widgetId); + const normalized = normalizeWidget(cachedWidget ?? serverWidget); + setWidget(normalized.widget); + widgetRef.current = normalized.widget; + setSelectedBlockId(normalized.widget.blocks[0]?.id ?? null); + const nextIsDirty = Boolean(cachedWidget) || normalized.changed; + editVersionRef.current = 0; + isDirtyRef.current = nextIsDirty; + setDirty(nextIsDirty); + }) + .catch((loadError) => { + if (!cancelled) setError(errorMessage(loadError, messages.unavailable)); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [enabled, messages.unavailable, widgetId]); + + useEffect(() => { + widgetRef.current = widget; + isDirtyRef.current = isDirty; + if (widget && isDirty) writeCachedWidget(widget); + }, [isDirty, widget]); + + const updateLocalWidget = (updater: (current: Widget) => Widget) => { + const current = widgetRef.current; + if (!current) return; + const nextWidget = updater(current); + const sizedWidget = { ...nextWidget, ...getWidgetDimensions(nextWidget.blocks) }; + widgetRef.current = sizedWidget; + setWidget(sizedWidget); + editVersionRef.current += 1; + isDirtyRef.current = true; + setDirty(true); + }; + + const save = async (publish = false): Promise => { + if (savePromiseRef.current) { + await savePromiseRef.current; + if (publish || isDirtyRef.current) return save(publish); + } + const run = (async () => { + const current = widgetRef.current; + if (!current) return; + const saveVersion = editVersionRef.current; + setSaving(true); + setError(null); + try { + await updateBlockLayouts(current.id, layoutsFor(current), current.config.grid.columns); + await Promise.all(current.blocks.map((block) => updateBlock(block.id, block.config))); + const saved = await updateWidget(current.id, { + title: current.title, + width: current.width, + height: current.height, + public: publish || current.public, + config: current.config, + }); + const normalized = normalizeWidget(saved).widget; + if (editVersionRef.current === saveVersion) { + markSaved(normalized); + } else { + isDirtyRef.current = true; + setDirty(true); + } + onSave?.(normalized); + } catch (saveError) { + setError(errorMessage(saveError, messages.unavailable)); + } finally { + setSaving(false); + } + })(); + savePromiseRef.current = run.finally(() => { + savePromiseRef.current = null; + }); + return savePromiseRef.current; + }; + + const triggerAutosave = useEffectEvent(() => { + void save(); + }); + + const flushPendingSave = useEffectEvent(() => { + if (isDirtyRef.current) void save(); + }); + + useEffect(() => { + if (!widget || !isDirty) return; + const timeout = window.setTimeout(triggerAutosave, AUTOSAVE_DELAY_MS); + return () => window.clearTimeout(timeout); + }, [isDirty, widget]); + + useEffect(() => { + if (!widget) return; + window.addEventListener('pagehide', flushPendingSave); + return () => window.removeEventListener('pagehide', flushPendingSave); + }, [widget]); + + useEffect(() => () => flushPendingSave(), []); + + const unpublish = async () => { + if (!widgetRef.current) return; + setSaving(true); + try { + await save(); + const current = widgetRef.current; + if (!current) return; + markSaved(normalizeWidget(await updateWidget(current.id, { public: false })).widget); + } catch (saveError) { + setError(errorMessage(saveError, messages.unavailable)); + } finally { + setSaving(false); + } + }; + + const selectBlock = (blockId: string) => { + setSelectedBlockId(blockId); + setActivePanel('block'); + }; + + const addBlockOfType = async (type: BlockType) => { + const current = widgetRef.current; + if (!current) return; + if (current.blocks.length >= MAX_BLOCKS) { + setError(messages.blocksLimit); + return; + } + try { + const block = await addBlock(current.id, type, defaultBlockConfig(type)); + updateLocalWidget((latest) => ({ + ...latest, + config: { ...latest.config, grid: { columns: MAX_COLUMNS } }, + blocks: [...latest.blocks, block], + })); + selectBlock(block.id); + } catch (addError) { + setError(errorMessage(addError, messages.unavailable)); + } + }; + + const removeBlock = async (blockId: string) => { + if (!widgetRef.current) return; + try { + await deleteBlock(blockId); + updateLocalWidget((latest) => ({ + ...latest, + config: { ...latest.config, grid: { columns: MAX_COLUMNS } }, + blocks: latest.blocks.filter((block) => block.id !== blockId), + })); + setSelectedBlockId((selected) => (selected === blockId ? null : selected)); + setActivePanel('widget'); + } catch (removeError) { + setError(errorMessage(removeError, messages.unavailable)); + } + }; + + const updateBlockConfig = (blockId: string, patch: Record) => { + updateLocalWidget((current) => ({ + ...current, + blocks: current.blocks.map((block) => + block.id === blockId ? { ...block, config: { ...block.config, ...patch } } : block, + ), + })); + }; + + return { + widget, + widgetRef, + selectedBlock: widget?.blocks.find((block) => block.id === selectedBlockId) ?? null, + activePanel, + setActivePanel, + selectBlock, + isLoading, + isSaving, + isDirty, + isDirtyRef, + error, + updateLocalWidget, + save, + unpublish, + addBlock: addBlockOfType, + removeBlock, + updateBlockConfig, + }; +}; diff --git a/client/src/pages/widget-editor/ui/BlockConfigPanel.tsx b/client/src/pages/widget-editor/ui/BlockConfigPanel.tsx new file mode 100644 index 0000000..b417578 --- /dev/null +++ b/client/src/pages/widget-editor/ui/BlockConfigPanel.tsx @@ -0,0 +1,102 @@ +import { Checkbox, Select, TextArea, TextInput } from '@gravity-ui/uikit'; + +import { blockDefinitions, type WidgetBlock } from '@/entities/widget/model'; +import { sourceForBlock } from '@/pages/widget-editor/model/layout'; +import { messages, type Locale } from '@/shared/locale/content'; +import styles from '@/pages/widget-editor/ui/WidgetEditorPage.module.css'; + +export const BlockConfigPanel = ({ + block, + locale, + onChange, +}: { + block: WidgetBlock; + locale: Locale; + onChange: (patch: Record) => void; +}) => { + const t = messages[locale]; + const source = sourceForBlock(block.type); + const options = + block.type === 'github-stats' + ? [ + ['showRepositories', locale === 'ru' ? 'Репозитории' : 'Repositories'], + ['showFollowers', locale === 'ru' ? 'Подписчики' : 'Followers'], + ['showFollowing', locale === 'ru' ? 'Подписки' : 'Following'], + ] + : block.type === 'leetcode-stats' + ? [ + ['showRanking', locale === 'ru' ? 'Рейтинг' : 'Ranking'], + ['showContestRating', locale === 'ru' ? 'Рейтинг соревнований' : 'Contest rating'], + ] + : []; + return ( +
+
+
+

{t.blockSettings}

+

{blockDefinitions.find((definition) => definition.type === block.type)?.label}

+
+
+ {source && ( + + )} + {block.type === 'text' && ( + <> +