diff --git a/.claude/skills/add-block-type/SKILL.md b/.claude/skills/add-block-type/SKILL.md index d8f85a3..ad97e33 100644 --- a/.claude/skills/add-block-type/SKILL.md +++ b/.claude/skills/add-block-type/SKILL.md @@ -25,13 +25,23 @@ the `/image.svg` export use the same SVG components. - Branch on the new type in `getBlockData`. Throw `AppError` for "not found"; `renderWidgetStats` turns errors into `block.error`. - Mind the budget: public SVG/iframe views call this per request (15 min cache per instance). Prefer one API call per block. -## 3. Rendering — `server/src/shared/widget/BlockContent.tsx` (used everywhere) - -- Add a block renderer in `BlockContent` next to the existing ones. Use the `frame` (content width/height + `blockTypography`), `baseline()`, `fitText`, `StatsRow`/`TitleRow` helpers — no hard-coded font sizes. -- Primitives live in `svgPrimitives.tsx`; the block shell and canvas pieces in `canvasParts.tsx`. -- Put new labels in `server/src/shared/widget/theme.ts` (`widgetLabels`, ru + en). -- Loading (`BlockSkeleton`), `error` and missing-username states are handled before the type switch; make sure the new block doesn't bypass them. -- Images: the export needs data URIs (see `buildAvatarDataUris` in `widgetController.ts`) because external `href`s don't load when the SVG is used as `` on GitHub; in the browser the URL is used directly. +## 3. Sizes and rendering — `server/src/shared/widget/` (used everywhere) + +- `blockSizes.ts`: add the type to `BLOCK_SIZES` (always include the legacy sizes 2×2, 2×4, 4×2, + 4×4 plus the compact sizes you design for) and, if not 2×2, to `DEFAULT_SIZES`. +- Create `blocks/.tsx` exporting a `BlockRenderer` that switches on `context.variant` + (`tiny` 1×1, `strip` 2–3×1, `wide` 4×1, `card`, `large`); register it in `blocks/index.ts`. + Unhandled variants should fall back to `card`. +- Reuse `blocks/parts.tsx` (`BigStat`, `SegmentBar`, `Avatar`, `fitFontSize`, `wrapText`) and + `svgPrimitives.tsx` (`TitleRow`, `StatsRow`, `fitText`, `baseline`). Size text from the frame + (`context.t`, `frame.width/height`), never hard-code positions for a single size. +- Loading, `error` and missing-username states are handled in `BlockContent.tsx` before your + renderer runs. +- Put new labels in `theme.ts` (`widgetLabels`, ru + en). +- Images: the export needs data URIs (see `buildAvatarDataUris` in `widgetController.ts`) because + external `href`s don't load when the SVG is used as `` on GitHub; the browser uses URLs. +- Add sample data and an expected string for the type in `blockVariants.test.tsx`; it renders every + allowed size. ## 4. Client — `client/src/entities/widget/` diff --git a/AGENTS.md b/AGENTS.md index c7b06c4..8f9ef0b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,10 +73,10 @@ Server imports must use `.js` extensions (NodeNext). ## Invariants and gotchas -- **One renderer.** Every surface (editor, public page, iframe, `/image.svg`) draws widgets with the same SVG components in `server/src/shared/widget/` (`BlockContent`, `canvasParts`, `svgPrimitives`), using numbers from `geometry.ts`/`theme.ts`. The export composes them into one `` (`WidgetCanvas.tsx`); the browser places the background and each block as separate ``s (`client/src/entities/widget/ui/WidgetSurface.tsx`) so the editor can attach controls, and scales the whole thing with `ScaledWidgetFrame`. Text widths are estimated (`estimateTextWidth`), so truncate/wrap in SVG code, not CSS. See the `widget-render-parity` skill. +- **One renderer.** Every surface (editor, public page, iframe, `/image.svg`) draws widgets with the same SVG components in `server/src/shared/widget/` (`BlockContent` → per-type renderers in `blocks/` with size variants, `canvasParts`, `svgPrimitives`), using numbers from `geometry.ts`/`theme.ts`. The export composes them into one `` (`WidgetCanvas.tsx`); the browser places the background and each block as separate ``s (`client/src/entities/widget/ui/WidgetSurface.tsx`) so the editor can attach controls, and scales the whole thing with `ScaledWidgetFrame`. Text widths are estimated (`estimateTextWidth`), so truncate/wrap in SVG code, not CSS. See the `widget-render-parity` skill. - **Block types are defined twice.** Server `widgets/registry.ts` (zod schemas, presets) and client `entities/widget/model/{types,registry}.ts` (labels, ru/en descriptions). Rendering is written once in `shared/widget/BlockContent.tsx`. Use the `add-block-type` skill. -- Limits: `MAX_WIDGET_BLOCKS = 5` in the registry; `MAX_GRID_COLUMNS = 2` and block height ≤ 2 in shared `geometry.ts` (re-exported by the registry). Layout validation (bounds + no overlap) runs in `widgetService.validateLayouts`. -- Widget width is currently fixed at 600 and height is recomputed from rows by `widgetDimensions` on every update — client-supplied `width`/`height` are ignored. +- **Grid:** 4 columns × up to 5 rows of 129px cells (12px gap, 24px padding, width 600; 4×4 = 600×600). Block sizes are declared per type in `shared/widget/blockSizes.ts` (`BLOCK_SIZES`, `snapToAllowedSize`); the editor resizes by dragging the corner and the server (`widgetService.validateLayouts`) rejects other sizes, overlaps, and layouts taller than 5 rows — unless a widget migrated from the old 2-column grid is already taller and the change doesn't grow it. `MAX_WIDGET_BLOCKS = 8` (free tier; planned tiers in `docs/design/grid-and-blocks.md`). Design reference: `docs/design/grid-and-blocks.md`. +- Widget width is fixed at 600 and height is recomputed from rows by `widgetDimensions` on every update — client-supplied `width`/`height` are ignored. - GitHub data: profile, repositories and languages use REST (a token only raises limits; languages switch to exact GraphQL byte counts with a token). Activity, pull request and status blocks use GraphQL only and show an error without `GITHUB_TOKEN`. - `statsService` and the avatar cache are per-process in-memory caches; on Vercel each cold instance starts empty. Successful lookups are cached 15 min, failed ones 60 s; the public SVG `Cache-Control` follows the same TTL. Rate limits (`express-rate-limit`, memory store) are also per instance; `trust proxy` is enabled on Vercel so limits are per client IP. - `api/*.ts` import from `server/dist/src/app.js`. `server/tsconfig.build.json` pins `rootDir: "."` to keep that path stable, and CI runs `typecheck:api` to catch breakage. diff --git a/CHANGELOG.md b/CHANGELOG.md index 20b5890..7c57774 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes to this project are documented here. ### Added +- Blocks adapt to their size: every block has compact 1×1 tile, 2×1 strip and 4×1 wide-strip layouts (e.g. avatar + followers, top language %, a compact heatmap, merged %, status emoji, solved count), plus roomier 4×2 layouts for the GitHub profile and LeetCode. Text fits itself to any size. +- When there's no room for a new block at its default size, it's added in the largest smaller size that fits. - Added three GitHub blocks: Activity (commits this year, current and best streak, and a contribution heatmap that shows as many weeks as fit the block), Pull Requests (total, merged, open with a status breakdown) and Status (emoji, message, busy flag), plus a "GitHub Activity" preset. They use the GitHub GraphQL API and need `GITHUB_TOKEN` on the server. - Added password reset by email ("Forgot password?" on sign-in) and email confirmation after sign-up, with a resend option on the account page. Emails are sent through Resend once `RESEND_API_KEY` and `EMAIL_FROM` are configured; until then the features stay hidden in production and emails are printed to the server log in development. - Added an account page with sign-in methods: connect Yandex ID to an existing account, or disconnect it when a password remains. @@ -17,6 +19,9 @@ All notable changes to this project are documented here. ### Changed +- The widget grid is now 4 columns × up to 5 rows of half-size cells (a 600×600 widget fits 16 cells instead of 4). Existing widgets were converted automatically and look the same; ones that end up taller than 5 rows can be fitted from the editor. +- Blocks are resized by dragging their corner, snapping to the sizes each block supports; the size buttons are gone. +- A widget can hold up to 8 blocks (was 5). - The editor, public page and iframe now render widgets with the same SVG components as the image export (one renderer instead of an HTML and an SVG copy), so every surface shows exactly the same widget. - SVG text truncation and stat number formatting now use a per-character width estimate calibrated to the widget font (Cyrillic, bold and `%` are wider), so the SVG export cuts text in the same places as the HTML widget. - 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. @@ -29,6 +34,8 @@ All notable changes to this project are documented here. ### Fixed +- The page could shift sideways when the browser scrolled an element into view (decorative background overflow). +- Restored the move animation for blocks pushed aside while dragging or resizing in the editor. - Starting Yandex sign-in when it isn't configured now returns to the app with an error message instead of a raw JSON response. - Fixed editor blocks jumping to another column or far down the grid when resized: the edited block stays in place, neighbours move down and gaps close; dropping a block onto one of the same size swaps them, and every moved block animates. - Fixed saving failing with a raw validation error after clearing a text block or entering an out-of-range language count; the count is now a 3–8 select and text/username fields have length limits. diff --git a/client/src/app/App.module.css b/client/src/app/App.module.css index c966c74..ef9f2d8 100644 --- a/client/src/app/App.module.css +++ b/client/src/app/App.module.css @@ -5,7 +5,9 @@ align-items: center; position: relative; min-height: 100vh; - overflow-x: hidden; + /* clip, not hidden: a hidden overflow is still programmatically scrollable, so focus or + scrollIntoView could shift the page sideways by the decorative orbs' overflow. */ + overflow-x: clip; padding: 24px; background: radial-gradient( diff --git a/client/src/entities/widget/ui/WidgetCanvas.test.tsx b/client/src/entities/widget/ui/WidgetCanvas.test.tsx index da7609c..be29bd9 100644 --- a/client/src/entities/widget/ui/WidgetCanvas.test.tsx +++ b/client/src/entities/widget/ui/WidgetCanvas.test.tsx @@ -7,7 +7,7 @@ const githubBlock: WidgetBlock = { id: 'gh', type: 'github-stats', position: 0, - config: { username: 'octocat', layout: { x: 0, y: 0, width: 1, height: 1 } }, + config: { username: 'octocat', layout: { x: 0, y: 0, width: 2, height: 2 } }, }; const renderedGithub = { @@ -89,11 +89,11 @@ it('shows the empty state without blocks', () => { }); describe('GitHub activity, pull request and status blocks', () => { - const block = (id: string, type: WidgetBlock['type'], config = {}, width = 1): WidgetBlock => ({ + const block = (id: string, type: WidgetBlock['type'], config = {}, width = 2): WidgetBlock => ({ id, type, position: 0, - config: { username: 'octo', layout: { x: 0, y: 0, width, height: 1 }, ...config }, + config: { username: 'octo', layout: { x: 0, y: 0, width, height: 2 }, ...config }, }); it('draws as many heatmap weeks as fit the block width', () => { @@ -111,17 +111,18 @@ describe('GitHub activity, pull request and status blocks', () => { renderedBlocks={[rendered('narrow')]} />, ); - expect(container.querySelectorAll('rect[width="10"][height="10"]')).toHaveLength(17 * 7); + // 2-column block: (244 + 3) / 13 → 19 weeks. + expect(container.querySelectorAll('rect[width="10"][height="10"]')).toHaveLength(19 * 7); rerender( , ); - // Full-width block: (510 + 3) / 13 → 39 weeks. - expect(container.querySelectorAll('rect[width="10"][height="10"]')).toHaveLength(39 * 7); + // Full-width block: (526 + 3) / 13 → 40 weeks. + expect(container.querySelectorAll('rect[width="10"][height="10"]')).toHaveLength(40 * 7); }); it('hides streaks and the heatmap when turned off', () => { diff --git a/client/src/pages/widget-editor/model/editorCache.ts b/client/src/pages/widget-editor/model/editorCache.ts index 69f5504..2ac3464 100644 --- a/client/src/pages/widget-editor/model/editorCache.ts +++ b/client/src/pages/widget-editor/model/editorCache.ts @@ -3,7 +3,8 @@ 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}`; +// v5: layouts use the 4-column grid; older drafts hold 2-column layouts and are ignored. +const cacheKey = (widgetId: string) => `widget-editor:v5:${widgetId}`; export const readCachedWidget = (widgetId: string): Widget | null => { try { diff --git a/client/src/pages/widget-editor/model/layout.test.ts b/client/src/pages/widget-editor/model/layout.test.ts index 087690e..1731c7f 100644 --- a/client/src/pages/widget-editor/model/layout.test.ts +++ b/client/src/pages/widget-editor/model/layout.test.ts @@ -1,9 +1,11 @@ import type { Widget, WidgetBlock } from '@/entities/widget/model'; import { dragGridRows, + fitsRowLimit, getLayout, moveBlock, normalizeWidget, + packIntoRows, placeBlock, } from '@/pages/widget-editor/model/layout'; @@ -20,14 +22,14 @@ const widgetWith = (blocks: WidgetBlock[], config: Partial = { title: 'Widget', slug: 'widget', width: 600, - height: 315, + height: 318, public: false, createdAt: '', updatedAt: '', config: { palette: 'lavender', paletteMode: 'light', - grid: { columns: 2 }, + grid: { columns: 4 }, renderFormat: 'iframe', ...config, }, @@ -65,9 +67,9 @@ describe('placeBlock', () => { }); }); - it('shifts a block in the right column left when it grows wider than the grid allows', () => { - const next = layouts(placeBlock(grid, 'b', { x: 1, y: 0, width: 2, height: 1 })); - expect(next.b).toEqual({ x: 0, y: 0, width: 2, height: 1 }); + it('shifts a block left when it grows wider than the grid allows', () => { + const next = layouts(placeBlock(grid, 'b', { x: 1, y: 0, width: 4, height: 1 })); + expect(next.b).toEqual({ x: 0, y: 0, width: 4, height: 1 }); expect(next.a).toEqual({ x: 0, y: 1, width: 1, height: 1 }); }); @@ -129,9 +131,49 @@ describe('moveBlock', () => { }); }); -it('offers one spare drop row below the lowest block', () => { +it('offers one spare drop row below the lowest block, within the row limit', () => { expect(dragGridRows(widgetWith([]))).toBe(2); expect(dragGridRows(widgetWith([block('a', { x: 0, y: 1, width: 1, height: 2 })]))).toBe(4); + expect(dragGridRows(widgetWith([block('a', { x: 0, y: 3, width: 2, height: 2 })]))).toBe(5); + // Migrated widgets taller than the limit get no extra row. + expect(dragGridRows(widgetWith([block('a', { x: 0, y: 4, width: 2, height: 2 })]))).toBe(6); +}); + +describe('row limit', () => { + const short = widgetWith([block('a', { x: 0, y: 0, width: 2, height: 2 })]); + const fiveRows = widgetWith([block('a', { x: 0, y: 3, width: 2, height: 2 })]); + const sixRows = widgetWith([block('a', { x: 0, y: 4, width: 2, height: 2 })]); + const eightRows = widgetWith([block('a', { x: 0, y: 6, width: 2, height: 2 })]); + + it('allows changes up to 5 rows, or within the height a migrated widget already has', () => { + expect(fitsRowLimit(short, fiveRows)).toBe(true); + expect(fitsRowLimit(short, sixRows)).toBe(false); + expect(fitsRowLimit(sixRows, sixRows)).toBe(true); + expect(fitsRowLimit(sixRows, eightRows)).toBe(false); + }); + + it('packs a tall migrated widget into 5 rows keeping block sizes', () => { + // An old 2-column widget whose blocks were stacked in one column: 2×2 blocks at y 0, 2, 4. + const stacked = widgetWith([ + block('a', { x: 0, y: 0, width: 2, height: 2 }), + block('b', { x: 0, y: 2, width: 2, height: 2 }), + block('c', { x: 0, y: 4, width: 4, height: 2 }), + ]); + + expect(layouts(packIntoRows(stacked)!)).toEqual({ + a: { x: 0, y: 0, width: 2, height: 2 }, + b: { x: 2, y: 0, width: 2, height: 2 }, + c: { x: 0, y: 2, width: 4, height: 2 }, + }); + }); + + it('reports widgets whose blocks cannot fit', () => { + const big = widgetWith([ + block('a', { x: 0, y: 0, width: 4, height: 4 }), + block('b', { x: 0, y: 4, width: 4, height: 2 }), + ]); + expect(packIntoRows(big)).toBeNull(); + }); }); describe('normalizeWidget', () => { @@ -140,13 +182,13 @@ describe('normalizeWidget', () => { widgetWith([ block( 'a', - { x: 0, y: 0, width: 1, height: 1 }, - { config: { username: 'octocat', layout: { x: 0, y: 0, width: 1, height: 1 } } }, + { x: 0, y: 0, width: 2, height: 2 }, + { config: { username: 'octocat', layout: { x: 0, y: 0, width: 2, height: 2 } } }, ), ]), ); expect(changed).toBe(false); - expect(widget.height).toBe(315); + expect(widget.height).toBe(318); }); it('migrates legacy widgets and marks them for saving', () => { @@ -166,13 +208,13 @@ describe('normalizeWidget', () => { expect(changed).toBe(true); expect(widget.config.paletteMode).toBe('light'); - expect(widget.config.grid.columns).toBe(2); + expect(widget.config.grid.columns).toBe(4); 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 }, + layout: { x: 3, y: 0, width: 1, height: 4 }, }); - expect(widget.blocks[1].config.layout).toEqual({ x: 0, y: 1, width: 1, height: 1 }); + expect(widget.blocks[1].config.layout).toEqual({ x: 0, y: 1, width: 2, height: 2 }); 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 index 1c3ed39..020dd95 100644 --- a/client/src/pages/widget-editor/model/layout.ts +++ b/client/src/pages/widget-editor/model/layout.ts @@ -1,4 +1,13 @@ -import { MAX_GRID_COLUMNS, widgetDimensions } from '@shared/widget/geometry'; +import { DEFAULT_BLOCK_SIZE } from '@shared/widget/blockSizes'; +import { + MAX_BLOCK_HEIGHT, + MAX_GRID_COLUMNS, + MAX_GRID_ROWS, + findFreeSpot, + gridRows, + layoutsOverlap, + widgetDimensions, +} from '@shared/widget/geometry'; import type { BlockLayout, BlockType, @@ -8,17 +17,12 @@ import type { WidgetBlock, } from '@/entities/widget/model'; -export const MAX_BLOCKS = 5; +// Free plan limit (see docs/design/grid-and-blocks.md for planned tiers); mirrors the server. +export const MAX_BLOCKS = 8; export const MAX_COLUMNS = MAX_GRID_COLUMNS; +export const MAX_ROWS = MAX_GRID_ROWS; 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; +const DEFAULT_LAYOUT: BlockLayout = { x: 0, y: 0, ...DEFAULT_BLOCK_SIZE }; export const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); @@ -42,12 +46,20 @@ const layoutFromBlock = (block: WidgetBlock, index: number): BlockLayout => { const value: Partial = input && typeof input === 'object' ? (input as Partial) : { y: index }; 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); + const width = clamp( + typeof value.width === 'number' ? value.width : DEFAULT_LAYOUT.width, + 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), + height: clamp( + typeof value.height === 'number' ? value.height : DEFAULT_LAYOUT.height, + 1, + MAX_BLOCK_HEIGHT, + ), }; }; @@ -57,12 +69,6 @@ export const getWidgetDimensions = (blocks: WidgetBlock[]) => 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; - // Highest row where the layout fits without overlapping `obstacles`. const firstFreeRow = (layout: BlockLayout, obstacles: BlockLayout[]) => { for (let y = 0; y <= MAX_ROW; y += 1) { @@ -84,7 +90,7 @@ export const placeBlock = (widget: Widget, blockId: string, layout: BlockLayout) x: clamp(layout.x, 0, MAX_COLUMNS - width), y: clamp(layout.y, 0, MAX_ROW), width, - height: clamp(layout.height, 1, 2), + height: clamp(layout.height, 1, MAX_BLOCK_HEIGHT), }; const others = widget.blocks .filter((block) => block.id !== blockId) @@ -148,18 +154,50 @@ export const moveBlock = (widget: Widget, blockId: string, cell: { x: number; y: }; }; -/** 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; - }), - ), +/** + * Rows shown while dragging: occupied rows plus one spare drop row, unless that would go past the + * row limit (widgets migrated from the old grid can already be taller). + */ +export const dragGridRows = (widget: Widget) => { + const rows = occupiedRows(widget); + return Math.max(2, rows < MAX_ROWS ? rows + 1 : rows); +}; + +const rowsOf = (widget: Widget) => gridRows(widget.blocks.map(getLayout)); + +/** A change may not make a widget taller than the limit (or than it already is, if it's over). */ +export const fitsRowLimit = (before: Widget, after: Widget) => + rowsOf(after) <= Math.max(MAX_ROWS, rowsOf(before)); + +/** + * Re-places every block, top to bottom and keeping its size, at the first free spot within the + * row limit. Used to fit widgets migrated from the 2-column grid; null if they can't fit. + */ +export const packIntoRows = (widget: Widget): Widget | null => { + const placed: { id: string; layout: BlockLayout }[] = []; + const ordered = [...widget.blocks].sort( + (left, right) => + getLayout(left).y - getLayout(right).y || getLayout(left).x - getLayout(right).x, ); + for (const block of ordered) { + const { width, height } = getLayout(block); + const spot = findFreeSpot( + placed.map((item) => item.layout), + { width, height }, + { columns: MAX_COLUMNS, rows: MAX_ROWS }, + ); + if (!spot) return null; + placed.push({ id: block.id, layout: spot }); + } + const layouts = new Map(placed.map((item) => [item.id, item.layout])); + return { + ...widget, + blocks: widget.blocks.map((block) => ({ + ...block, + config: { ...block.config, layout: layouts.get(block.id)! }, + })), + }; +}; export const occupiedRows = (widget: Widget) => Math.max(1, ...widget.blocks.map((block) => getLayout(block).y + getLayout(block).height)); diff --git a/client/src/pages/widget-editor/model/useGridDrag.ts b/client/src/pages/widget-editor/model/useGridDrag.ts index f178cab..71a7684 100644 --- a/client/src/pages/widget-editor/model/useGridDrag.ts +++ b/client/src/pages/widget-editor/model/useGridDrag.ts @@ -1,21 +1,27 @@ 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 type { BlockLayout, Widget } from '@/entities/widget/model'; +import { snapToAllowedSize } from '@shared/widget/blockSizes'; +import { GRID_GAP, MAX_BLOCK_HEIGHT } from '@shared/widget/geometry'; import { MAX_COLUMNS, + MAX_ROWS, clamp, + fitsRowLimit, getLayout, moveBlock, + occupiedRows, placeBlock, } from '@/pages/widget-editor/model/layout'; type Cell = { x: number; y: number }; +export type ResizePreview = { blockId: string; layout: BlockLayout }; /** - * 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. + * Pointer drag between grid cells and corner resizing (snapped to the block type's allowed sizes) + * for editor blocks. Works on a scaled canvas: pointer math converts screen pixels to grid cells + * using the grid area's rendered size. Changes that would exceed the row limit are rejected. */ export const useGridDrag = ({ widgetRef, @@ -30,6 +36,24 @@ export const useGridDrag = ({ const dragRef = useRef<{ blockId: string; pointerId: number; preview: Cell } | null>(null); const [draggingBlockId, setDraggingBlockId] = useState(null); const [dropCell, setDropCell] = useState(null); + const resizeRef = useRef<{ blockId: string; pointerId: number; preview: BlockLayout } | null>( + null, + ); + const [resizePreview, setResizePreview] = useState(null); + const [isRowLimitHit, setRowLimitHit] = useState(false); + + const rawCellFromPoint = (clientX: number, clientY: number): Cell | null => { + const grid = gridRef.current; + if (!grid) return null; + const bounds = grid.getBoundingClientRect(); + 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: Math.floor((clientX - bounds.left) / (cell + gap)), + y: Math.floor((clientY - bounds.top) / (cell + gap)), + }; + }; const cellFromPoint = (clientX: number, clientY: number, width: number): Cell | null => { const grid = gridRef.current; @@ -80,8 +104,9 @@ export const useGridDrag = ({ // FLIP: every block that moves (the edited one and those pushed aside) animates from its old // rect to the new one instead of jumping. const animateLayoutChange = (applyChange: () => void) => { + // Blocks are siblings of the grid area layer inside the widget surface. const elements = Array.from( - gridRef.current?.querySelectorAll('[data-block-id]') ?? [], + gridRef.current?.parentElement?.querySelectorAll('[data-block-id]') ?? [], ); const before = new Map(elements.map((element) => [element, element.getBoundingClientRect()])); flushSync(applyChange); @@ -122,24 +147,92 @@ export const useGridDrag = ({ const layout = block ? getLayout(block) : null; endDrag(); if (!layout || (layout.x === drag.preview.x && layout.y === drag.preview.y)) return; - const cell = drag.preview; - animateLayoutChange(() => updateLocalWidget((widget) => moveBlock(widget, drag.blockId, cell))); + applyIfFits(current, moveBlock(current, drag.blockId, drag.preview)); + }; + + const applyIfFits = (current: Widget, next: Widget) => { + if (!fitsRowLimit(current, next)) { + setRowLimitHit(true); + return; + } + setRowLimitHit(false); + animateLayoutChange(() => updateLocalWidget(() => next)); + }; + + const endResize = () => { + resizeRef.current = null; + setResizePreview(null); }; - const resizeBlock = (blockId: string, width: number, height: number) => { + const onResizePointerDown = (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 nextLayout = { ...getLayout(block), width, height }; - animateLayoutChange(() => - updateLocalWidget((widget) => placeBlock(widget, blockId, nextLayout)), + const layout = getLayout(block); + resizeRef.current = { blockId, pointerId: event.pointerId, preview: layout }; + setResizePreview({ blockId, layout }); + onDragStart(blockId); + }; + + const onResizePointerMove = (event: PointerEvent) => { + const resize = resizeRef.current; + const current = widgetRef.current; + if (resize?.pointerId !== event.pointerId || !current) return; + event.preventDefault(); + const block = current.blocks.find((item) => item.id === resize.blockId); + const cell = rawCellFromPoint(event.clientX, event.clientY); + if (!block || !cell) return; + const origin = getLayout(block); + const rowLimit = Math.max(MAX_ROWS, occupiedRows(current)); + const maxWidth = MAX_COLUMNS - origin.x; + const maxHeight = Math.min(MAX_BLOCK_HEIGHT, rowLimit - origin.y); + const size = snapToAllowedSize( + block.type, + clamp(cell.x - origin.x + 1, 1, maxWidth), + clamp(cell.y - origin.y + 1, 1, maxHeight), + { maxWidth, maxHeight }, ); + // A size wider than the space to the right shifts the block left (as placeBlock would). + const preview = { x: Math.min(origin.x, MAX_COLUMNS - size.width), y: origin.y, ...size }; + resize.preview = preview; + setResizePreview({ blockId: resize.blockId, layout: preview }); + }; + + const onResizePointerUp = (event: PointerEvent) => { + const resize = resizeRef.current; + const current = widgetRef.current; + if (!resize || resize.pointerId !== event.pointerId || !current) return; + event.currentTarget.releasePointerCapture(event.pointerId); + endResize(); + const block = current.blocks.find((item) => item.id === resize.blockId); + if (!block) return; + const layout = getLayout(block); + const { preview } = resize; + if ( + layout.width === preview.width && + layout.height === preview.height && + layout.x === preview.x + ) { + return; + } + applyIfFits(current, placeBlock(current, resize.blockId, preview)); }; return { gridRef, draggingBlockId, dropCell, + resizePreview, + isRowLimitHit, pointerHandlers: { onPointerDown, onPointerMove, onPointerUp, onPointerCancel: endDrag }, - resizeBlock, + resizeHandlers: { + onPointerDown: onResizePointerDown, + onPointerMove: onResizePointerMove, + onPointerUp: onResizePointerUp, + onPointerCancel: endResize, + }, }; }; diff --git a/client/src/pages/widget-editor/model/useWidgetEditor.test.ts b/client/src/pages/widget-editor/model/useWidgetEditor.test.ts index 36e4e8f..103cacd 100644 --- a/client/src/pages/widget-editor/model/useWidgetEditor.test.ts +++ b/client/src/pages/widget-editor/model/useWidgetEditor.test.ts @@ -19,14 +19,14 @@ const serverWidget: Widget = { title: 'Widget', slug: 'widget', width: 600, - height: 315, + height: 318, public: false, createdAt: '', updatedAt: '', config: { palette: 'lavender', paletteMode: 'light', - grid: { columns: 2 }, + grid: { columns: 4 }, renderFormat: 'iframe', }, blocks: [ @@ -34,7 +34,7 @@ const serverWidget: Widget = { id: 'block-1', type: 'text', position: 0, - config: { text: 'Hello', layout: { x: 0, y: 0, width: 1, height: 1 } }, + config: { text: 'Hello', layout: { x: 0, y: 0, width: 2, height: 2 } }, }, ], }; @@ -117,7 +117,7 @@ it('keeps edits made while a save is in flight', async () => { it('restores unsaved edits from the local cache', async () => { localStorage.setItem( - 'widget-editor:v4:widget-1', + 'widget-editor:v5:widget-1', JSON.stringify({ savedAt: 1, widget: { ...serverWidget, title: 'Unsaved' } }), ); @@ -130,11 +130,14 @@ it('restores unsaved edits from the local cache', async () => { it('refuses to add blocks past the limit', async () => { api.getWidget.mockResolvedValue({ ...serverWidget, - blocks: Array.from({ length: 5 }, (_, index) => ({ + blocks: Array.from({ length: 8 }, (_, index) => ({ ...serverWidget.blocks[0], id: `block-${index}`, position: index, - config: { text: 'x', layout: { x: 0, y: index, width: 1, height: 1 } }, + config: { + text: 'x', + layout: { x: index % 4, y: Math.floor(index / 4), width: 1, height: 1 }, + }, })), }); const { result } = await renderEditor(); diff --git a/client/src/pages/widget-editor/ui/EditorBlock.tsx b/client/src/pages/widget-editor/ui/EditorBlock.tsx index b5b4883..8389e2a 100644 --- a/client/src/pages/widget-editor/ui/EditorBlock.tsx +++ b/client/src/pages/widget-editor/ui/EditorBlock.tsx @@ -4,7 +4,6 @@ import type { PointerEvent, ReactNode } from 'react'; import type { WidgetBlock } from '@/entities/widget/model'; import type { BlockBox } from '@shared/widget/canvasParts'; -import { blockSizes, getLayout } from '@/pages/widget-editor/model/layout'; import styles from '@/pages/widget-editor/ui/WidgetEditorPage.module.css'; export type BlockPointerHandlers = { @@ -24,9 +23,11 @@ type EditorBlockProps = { dragging: boolean; removeLabel: string; pointerHandlers: BlockPointerHandlers; + /** Corner handle: drag to resize, snapping to the block's allowed sizes. */ + resizeHandlers: BlockPointerHandlers; + resizeLabel: string; onSelect: () => void; onRemove: () => void; - onResize: (width: number, height: number) => void; }; export const EditorBlock = ({ @@ -37,58 +38,49 @@ export const EditorBlock = ({ dragging, removeLabel, pointerHandlers, + resizeHandlers, + resizeLabel, onSelect, onRemove, - onResize, -}: EditorBlockProps) => { - const layout = getLayout(block); - return ( -
( +
+
event.stopPropagation()}> - {blockSizes.map((size) => ( - - ))} -
- {children} - - - ); -}; + + + {children} + + + + )} + {drag.isRowLimitHit && ( +

+ {t.gridRowLimit} +

+ )} {editor.error && (

{editor.error} diff --git a/client/src/shared/locale/content.ts b/client/src/shared/locale/content.ts index 46b0236..86c0c33 100644 --- a/client/src/shared/locale/content.ts +++ b/client/src/shared/locale/content.ts @@ -114,8 +114,8 @@ export const messages = { layout: 'Размер и позиция', light: 'Светлая', dark: 'Темная', - maxBlocks: 'Максимум 5 блоков', - blocksLimit: 'В виджете может быть не больше 5 блоков.', + maxBlocks: 'Максимум 8 блоков', + blocksLimit: 'В виджете может быть не больше 8 блоков.', moveResize: 'Перетаскивайте блоки и меняйте их размер за угол.', resize: 'Изменить размер блока', saved: 'Сохранено', @@ -133,6 +133,12 @@ export const messages = { iframeCode: 'Iframe-код', copied: 'Скопировано', removeBlock: 'Удалить блок', + resizeBlock: 'Изменить размер блока', + gridRowLimit: 'Виджет может быть не выше 5 рядов — освободите место или уменьшите блок.', + tallWidget: + 'Виджет выше 5 рядов: так было в старой сетке. Уместите его, чтобы добавлять блоки.', + fitWidget: 'Уместить в 5 рядов', + fitWidgetFailed: 'Блоки не помещаются в 5 рядов — уменьшите или удалите часть из них.', selectBlock: 'Выберите блок на canvas, чтобы настроить его.', unsavedWarning: 'Есть несохранённые изменения. Покинуть редактор?', loading: 'Загрузка…', @@ -256,8 +262,8 @@ export const messages = { layout: 'Size & position', light: 'Light', dark: 'Dark', - maxBlocks: 'Maximum 5 blocks', - blocksLimit: 'A widget can contain no more than 5 blocks.', + maxBlocks: 'Maximum 8 blocks', + blocksLimit: 'A widget can contain no more than 8 blocks.', moveResize: 'Drag blocks and resize them from the corner.', resize: 'Resize block', saved: 'Saved', @@ -275,6 +281,12 @@ export const messages = { iframeCode: 'Iframe code', copied: 'Copied', removeBlock: 'Remove block', + resizeBlock: 'Resize block', + gridRowLimit: 'A widget can be at most 5 rows tall — make room or use a smaller size.', + tallWidget: + 'This widget is taller than 5 rows (from the old grid). Fit it to keep adding blocks.', + fitWidget: 'Fit into 5 rows', + fitWidgetFailed: "The blocks don't fit into 5 rows — shrink or remove some of them.", selectBlock: 'Select a block on the canvas to configure it.', unsavedWarning: 'There are unsaved changes. Leave the editor?', loading: 'Loading…', diff --git a/docs/design/grid-and-blocks.md b/docs/design/grid-and-blocks.md new file mode 100644 index 0000000..dbf6e41 --- /dev/null +++ b/docs/design/grid-and-blocks.md @@ -0,0 +1,66 @@ +# Widget grid and blocks + +Design reference for how widgets are laid out and how blocks adapt to their size. + +## Grid + +| | | +| ---------------------- | -------------------------------- | +| Widget width | 600 px (fixed) | +| Columns | 4 | +| Rows | up to 5 (editable limit) | +| Cell | 129 × 129 px | +| Gap | 12 px | +| Canvas padding | 24 px | +| Block padding / radius | 12 px / 16 px | +| Sizes | 4×4 = 600 × 600, 4×5 = 600 × 741 | + +Content width of a block by column span (cell − 2 × (padding + border)): 1 → 103 px, 2 → 244 px, +3 → 385 px, 4 → 526 px. Height works the same way per row span. + +The numbers live in `server/src/shared/widget/geometry.ts`. Widgets created on the old 2-column grid +were migrated by doubling every layout (one old cell = 2 × 2 new cells); those that end up taller +than 5 rows still render, can be edited without growing, and the editor offers "Fit into 5 rows". + +## Block sizes and variants + +A block declares the sizes it supports (`server/src/shared/widget/blockSizes.ts`); the editor's +corner resize snaps to them and the server rejects anything else. Each supported size has its own +layout ("variant") instead of scaling one design. Not every block needs every size. + +Variant families by size (`blockVariant` in `blockSizes.ts`): + +| Family | Sizes | Idea | +| ------- | ------------- | ---------------------------------------------- | +| `tiny` | 1×1 | one headline number or symbol | +| `strip` | 2×1, 3×1 | title + 2–3 facts in one line | +| `wide` | 4×1 | title + a full row of facts or a compact chart | +| `card` | 2×2, 3×2, 2×4 | the classic block | +| `large` | 4×2, 4×3, 4×4 | roomy layout; blocks without one use `card` | + +Implemented layouts (renderers in `server/src/shared/widget/blocks/`): + +| Block | 1×1 | 2×1 | 4×1 | 2×2 | 4×2 | +| -------------- | ------------------------------------------------------------------------------ | --------------------------------- | ------------------------------------- | ---------------------- | -------------------------------- | +| text | text fitted to the box, centred vertically, in every size (also 3×1, 3×2, 4×3) | | | | | +| github-stats | avatar + followers | avatar, name, @user, summary line | identity left, 3 stats right | card | big avatar, name, bio, stats row | +| github-langs | top language % + mix bar | bar + 2-language legend | bar + 4-language legend | card | card | +| github-commits | commits + 🔥 streak | commits + current streak | caption + compact heatmap (~48 weeks) | card (19-week heatmap) | card (40-week heatmap) | +| github-prs | merged % + breakdown bar | total / merged / open | + closed, breakdown bar | card | card | +| github-status | emoji (+ busy dot) | emoji + 2-line message | emoji + 1-line message | card | card | +| leetcode-stats | solved + difficulty bar | solved, ranking + difficulty bar | + contest rating | card | stats + one bar per difficulty | + +Every block also keeps the doubled legacy sizes (2×2, 2×4, 4×2, 4×4) so migrated widgets stay +valid. Defaults: new blocks are 2×2 (text 2×1); when that doesn't fit, the next smaller allowed +size that does is used (`placementSizes`). + +## Block limit and plan tiers + +| Plan | Blocks per widget | +| ----------------- | ----------------- | +| Free | 8 | +| Paid subscription | 16 | +| Special accounts | 20 | + +Only the free limit is implemented (`MAX_WIDGET_BLOCKS` = 8 on the server, `MAX_BLOCKS` in the +editor). Paid and special tiers need billing and an account plan field first. diff --git a/server/prisma/migrations/20261003145347_grid_4_columns/migration.sql b/server/prisma/migrations/20261003145347_grid_4_columns/migration.sql new file mode 100644 index 0000000..691e460 --- /dev/null +++ b/server/prisma/migrations/20261003145347_grid_4_columns/migration.sql @@ -0,0 +1,34 @@ +-- Data-only migration: the widget grid goes from 2 to 4 columns (cells half the size). +-- Each old cell is exactly 2×2 new cells, so doubling every layout keeps widgets looking the same. + +-- 1. Double block layouts (blocks saved before layouts existed are stacked by position). +UPDATE "Block" +SET "config" = jsonb_set( + "config", + '{layout}', + jsonb_build_object( + 'x', COALESCE(("config"->'layout'->>'x')::int, 0) * 2, + 'y', COALESCE(("config"->'layout'->>'y')::int, "position") * 2, + 'width', COALESCE(("config"->'layout'->>'width')::int, 1) * 2, + 'height', COALESCE(("config"->'layout'->>'height')::int, 1) * 2 + ) +); + +-- 2. Every widget uses the 4-column grid. +UPDATE "Widget" +SET "config" = jsonb_set(COALESCE("config", '{}'::jsonb), '{grid}', '{"columns": 4}'::jsonb); + +-- 3. Recompute stored heights: 129px cells, 12px gaps, 24px padding (width 600), capped at 1200. +UPDATE "Widget" AS w +SET "height" = LEAST(1200, rows.count * 129 + (rows.count - 1) * 12 + 48) +FROM ( + SELECT "widgetId", + MAX(("config"->'layout'->>'y')::int + ("config"->'layout'->>'height')::int) AS count + FROM "Block" + GROUP BY "widgetId" +) AS rows +WHERE rows."widgetId" = w."id"; + +UPDATE "Widget" +SET "height" = 129 + 48 +WHERE NOT EXISTS (SELECT 1 FROM "Block" WHERE "Block"."widgetId" = "Widget"."id"); diff --git a/server/src/controllers/widgetController.ts b/server/src/controllers/widgetController.ts index 465a179..8689fc5 100644 --- a/server/src/controllers/widgetController.ts +++ b/server/src/controllers/widgetController.ts @@ -15,6 +15,7 @@ import { presetDefinitions, widgetConfigSchema, MAX_GRID_COLUMNS, + MAX_WIDGET_BLOCKS, } from '@server/widgets/registry.js'; const widgetIdSchema = z.string().trim().min(1).max(100); @@ -80,7 +81,9 @@ const previewBlockSchema = z.object({ const updateBlockSchema = z.object({ config: z.unknown().optional() }); const layoutUpdateSchema = z.object({ - layouts: z.array(z.object({ blockId: widgetIdSchema, layout: blockLayoutSchema })).max(5), + layouts: z + .array(z.object({ blockId: widgetIdSchema, layout: blockLayoutSchema })) + .max(MAX_WIDGET_BLOCKS), columns: z.number().int().min(1).max(MAX_GRID_COLUMNS).optional(), }); diff --git a/server/src/services/widgetCanvas.test.ts b/server/src/services/widgetCanvas.test.ts index 7d04dc8..9ef4e17 100644 --- a/server/src/services/widgetCanvas.test.ts +++ b/server/src/services/widgetCanvas.test.ts @@ -34,8 +34,8 @@ it('renders the shared widget canvas as SVG', () => { expect(svg).toContain('widget-surface'); // No drop shadow: it would tint the transparent corners around the rounded canvas. expect(svg).not.toContain(' { @@ -96,17 +96,17 @@ it('inlines avatars as data URIs instead of external URLs', () => { it('renders the new GitHub blocks with the same geometry as the HTML canvas', async () => { const { heatmapWeeks } = await import('@shared/widget/geometry.js'); - const layout = (x: number, y: number, width = 1) => ({ layout: { x, y, width, height: 1 } }); + const layout = (x: number, y: number, width = 2) => ({ layout: { x, y, width, height: 2 } }); const svg = renderToStaticMarkup( createElement(WidgetCanvas, { blocks: [ { id: 'commits', type: 'github-commits', position: 0, config: layout(0, 0) }, - { id: 'prs', type: 'github-prs', position: 1, config: layout(1, 0) }, - { id: 'status', type: 'github-status', position: 2, config: layout(0, 1, 2) }, + { id: 'prs', type: 'github-prs', position: 1, config: layout(2, 0) }, + { id: 'status', type: 'github-status', position: 2, config: layout(0, 2, 4) }, ], width: 600, height: 600, - columns: 2, + columns: 4, renderedBlocks: [ { id: 'commits', @@ -137,9 +137,9 @@ it('renders the new GitHub blocks with the same geometry as the HTML canvas', as }), ); - // A 1-column block (225px content) fits 17 weeks × 7 days of heatmap cells. - expect(heatmapWeeks(225)).toBe(17); - expect(svg.match(/width="10" height="10" rx="2.5"/g)).toHaveLength(17 * 7); + // A 2-column block (244px content) fits 19 weeks × 7 days of heatmap cells. + expect(heatmapWeeks(244)).toBe(19); + expect(svg.match(/width="10" height="10" rx="2.5"/g)).toHaveLength(19 * 7); // "1,200" at 22.5px/800 with -0.06em spacing (~57px) fits the 63px stat column. expect(svg).toContain('>1,200<'); expect(svg).toMatch(/>Merged<\/text>.*?>70%<\/text>/); diff --git a/server/src/services/widgetService.ts b/server/src/services/widgetService.ts index d4484b4..2d4152e 100644 --- a/server/src/services/widgetService.ts +++ b/server/src/services/widgetService.ts @@ -2,7 +2,18 @@ import { randomBytes } from 'node:crypto'; import type { Prisma } from '@prisma/client'; -import { widgetDimensions as sharedWidgetDimensions } from '@shared/widget/geometry.js'; +import { + DEFAULT_BLOCK_SIZE, + isAllowedBlockSize, + placementSizes, +} from '@shared/widget/blockSizes.js'; +import { + MAX_GRID_ROWS, + findFreeSpot, + gridRows, + layoutsOverlap, + widgetDimensions as sharedWidgetDimensions, +} from '@shared/widget/geometry.js'; import { prisma } from '@server/lib/prisma.js'; import { AppError } from '@server/lib/errors.js'; @@ -81,7 +92,7 @@ const layoutFromBlock = (block: { position: number; config: unknown }): BlockLay : {}; const layout = value.layout; const result = blockLayoutSchema.safeParse(layout); - return result.success ? result.data : { x: 0, y: block.position, width: 1, height: 1 }; + return result.success ? result.data : { x: 0, y: block.position, ...DEFAULT_BLOCK_SIZE }; }; const widgetDimensions = (blocks: { position: number; config: unknown }[]) => @@ -95,16 +106,22 @@ const normalizeBlockConfig = (type: BlockType, config: unknown, layout?: BlockLa : (getDefaultBlockConfig(type) as Record); const result = parseBlockConfig(type, { ...defaults, - layout: layout ?? input.layout ?? { x: 0, y: 0, width: 1, height: 1 }, + layout: layout ?? input.layout ?? { x: 0, y: 0, ...DEFAULT_BLOCK_SIZE }, }); if (!result.success) throw new AppError(400, result.error.issues[0]?.message ?? 'Invalid block config'); return result.data; }; +/** + * Layout rules: block count, sizes the block type supports, grid bounds, no overlaps, and at most + * MAX_GRID_ROWS rows. Widgets migrated from the 2-column grid can be taller; they stay editable + * as long as a change doesn't make them taller still (`previousBlocks`). + */ const validateLayouts = ( blocks: { id: string; position: number; type: string; config: unknown }[], config: WidgetConfig, + previousBlocks: { position: number; config: unknown }[] = blocks, ) => { if (blocks.length > MAX_WIDGET_BLOCKS) { throw new AppError(400, `A widget can contain at most ${MAX_WIDGET_BLOCKS} blocks`); @@ -112,22 +129,26 @@ const validateLayouts = ( const columns = config.grid.columns; const layouts = blocks.map((block) => ({ block, layout: layoutFromBlock(block) })); - for (const { layout } of layouts) { + for (const { block, layout } of layouts) { if (layout.x + layout.width > columns) { throw new AppError(400, 'Block layout exceeds the widget grid'); } + if (!isAllowedBlockSize(block.type, layout.width, layout.height)) { + throw new AppError(400, `This block can't be ${layout.width}×${layout.height}`); + } + } + + const rows = gridRows(layouts.map((item) => item.layout)); + const previousRows = gridRows(previousBlocks.map(layoutFromBlock)); + if (rows > Math.max(MAX_GRID_ROWS, previousRows)) { + throw new AppError(400, `A widget can be at most ${MAX_GRID_ROWS} rows tall`); } for (let index = 0; index < layouts.length; index += 1) { for (let otherIndex = index + 1; otherIndex < layouts.length; otherIndex += 1) { - const left = layouts[index].layout; - const right = layouts[otherIndex].layout; - const overlaps = - left.x < right.x + right.width && - left.x + left.width > right.x && - left.y < right.y + right.height && - left.y + left.height > right.y; - if (overlaps) throw new AppError(400, 'Block layouts cannot overlap'); + if (layoutsOverlap(layouts[index].layout, layouts[otherIndex].layout)) { + throw new AppError(400, 'Block layouts cannot overlap'); + } } } }; @@ -189,7 +210,12 @@ export class WidgetService { ...block.config, ...(getSourceForBlock(block.type) === source && username ? { username } : {}), }, - { x: 0, y: position, width: 1, height: 1 }, + // Default-size blocks, two per row. + { + x: (position % 2) * DEFAULT_BLOCK_SIZE.width, + y: Math.floor(position / 2) * DEFAULT_BLOCK_SIZE.height, + ...DEFAULT_BLOCK_SIZE, + }, ), })); const dimensions = widgetDimensions(blockInputs); @@ -261,16 +287,12 @@ export class WidgetService { throw new AppError(400, `A widget can contain at most ${MAX_WIDGET_BLOCKS} blocks`); } const position = widget.blocks.reduce((max, block) => Math.max(max, block.position), -1) + 1; - const lastRow = widget.blocks.reduce( - (max, block) => Math.max(max, layoutFromBlock(block).y + layoutFromBlock(block).height), - 0, - ); - const normalizedConfig = normalizeBlockConfig(input.type, input.config, { - x: 0, - y: lastRow, - width: 1, - height: 1, - }); + const taken = widget.blocks.map(layoutFromBlock); + const spot = placementSizes(input.type) + .map((size) => findFreeSpot(taken, size, { columns: MAX_GRID_COLUMNS })) + .find((candidate) => candidate !== null); + if (!spot) throw new AppError(400, 'There is no free space for a new block in this widget'); + const normalizedConfig = normalizeBlockConfig(input.type, input.config, spot); const [created] = await prisma.$transaction([ prisma.block.create({ data: { widgetId, type: input.type, position, config: toJson(normalizedConfig) }, @@ -301,7 +323,7 @@ export class WidgetService { block.widget.config, block.widget.config as Partial, ); - validateLayouts(nextBlocks, widgetConfig); + validateLayouts(nextBlocks, widgetConfig, block.widget.blocks); return prisma.block.update({ where: { id: blockId }, data: { config: toJson(config) } }); }; @@ -347,7 +369,7 @@ export class WidgetService { config: normalizeBlockConfig(block.type as BlockType, block.config, nextLayout), }; }); - validateLayouts(nextBlocks, config); + validateLayouts(nextBlocks, config, widget.blocks); await prisma.$transaction([ ...nextBlocks.map((block) => prisma.block.update({ where: { id: block.id }, data: { config: toJson(block.config) } }), diff --git a/server/src/shared/widget/BlockContent.tsx b/server/src/shared/widget/BlockContent.tsx index 6af77a2..6dc321c 100644 --- a/server/src/shared/widget/BlockContent.tsx +++ b/server/src/shared/widget/BlockContent.tsx @@ -1,42 +1,21 @@ // Content of one widget block, drawn in the block's content box (origin at its top-left). -import { - BLOCK_LINE_HEIGHT, - HEATMAP_CELL, - HEATMAP_GAP, - TEXT_BLOCK_LINE_HEIGHT, - estimateTextWidth, - heatmapColumns, - heatmapWeeks, -} from './geometry.js'; -import { - difficultyColors, - errorColor, - heatmapOpacity, - languageColor, - widgetLabels, - type PaletteTokens, - type WidgetLocale, -} from './theme.js'; +// Shared states live here; each block type renders its size variants in ./blocks/. +import { blockVariant } from './blockSizes.js'; +import { BLOCK_LINE_HEIGHT } from './geometry.js'; +import { errorColor, widgetLabels, type PaletteTokens, type WidgetLocale } from './theme.js'; import type { WidgetCanvasBlock, WidgetCanvasRenderedBlock } from './types.js'; +import { blockRenderers } from './blocks/index.js'; +import { wrapText } from './blocks/parts.js'; import { MultilineText, PreviewState, - StatsRow, SvgText, - TitleRow, - asNumber, asRecord, asString, baseline, - clamp, - fitText, - formatNumber, - imageUrl, + layoutOf, linesOf, - safeId, - statsRowHeight, type BlockFrame, - type StatItem, } from './svgPrimitives.js'; // Placeholder while live data loads (editor and public page; exports always have data). @@ -67,6 +46,59 @@ const BlockSkeleton = ({ frame, tokens }: { frame: BlockFrame; tokens: PaletteTo ); }; +// "Add a username" for blocks too small for the full PreviewState. +const CompactPreview = ({ + frame, + tokens, + locale, +}: { + frame: BlockFrame; + tokens: PaletteTokens; + locale: WidgetLocale; +}) => { + const lines = wrapText(widgetLabels(locale).addUsername, frame.width, 11, { + weight: 700, + maxLines: 2, + }); + const markSize = 26; + const height = markSize + 8 + lines.length * 11 * BLOCK_LINE_HEIGHT; + const top = Math.max((frame.height - height) / 2, 0); + return ( + + + + + + ); +}; + export const BlockContent = ({ block, rendered, @@ -82,16 +114,27 @@ export const BlockContent = ({ frame: BlockFrame; avatarDataUri?: string; }) => { - const labels = widgetLabels(locale); - const { typography: t } = frame; const config = asRecord(block.config); - const data = asRecord(rendered?.data); + const username = asString(config.username); const source = block.type.startsWith('github') ? 'GitHub' : block.type.startsWith('leetcode') ? 'LeetCode' : null; - const username = asString(config.username); + const layout = layoutOf(block); + const context = { + block, + variant: blockVariant(layout.width, layout.height), + config, + data: asRecord(rendered?.data), + labels: widgetLabels(locale), + t: frame.typography, + frame, + tokens, + locale, + username, + avatarDataUri, + }; if (rendered?.error) { return ( @@ -99,471 +142,28 @@ export const BlockContent = ({ x={0} top={0} lineHeight={1.5} - lines={linesOf(rendered.error, Math.floor(frame.width / (13 * 0.52)), 4)} + lines={linesOf( + rendered.error, + Math.floor(frame.width / (13 * 0.52)), + Math.max(Math.floor(frame.height / 19.5), 1), + )} fill={errorColor} size={13} /> ); } - if (block.type === 'text') { - const align = config.align === 'center' || config.align === 'right' ? config.align : 'left'; - const anchor = align === 'center' ? 'middle' : align === 'right' ? 'end' : 'start'; - const x = align === 'center' ? frame.width / 2 : align === 'right' ? frame.width : 0; - const maxLines = Math.max(Math.floor(frame.height / (t.text * TEXT_BLOCK_LINE_HEIGHT)), 1); - return ( - - ); - } + if (block.type === 'text') return blockRenderers.text(context); if (source && !username && !rendered?.data) { - return ; - } - - if (!rendered?.data) return ; - - if (block.type === 'github-stats') { - const avatarHref = avatarDataUri ?? imageUrl(data.avatarUrl); - const avatarId = `avatar-${safeId(block.id)}`; - const tightGap = clamp(frame.width / 100, 2, 3); - const textColumnHeight = t.heading * BLOCK_LINE_HEIGHT + tightGap + t.meta * BLOCK_LINE_HEIGHT; - const headingHeight = Math.max(t.avatar, textColumnHeight); - const avatarY = (headingHeight - t.avatar) / 2; - const textTop = (headingHeight - textColumnHeight) / 2; - const textX = t.avatar + t.headingGap; - const textWidth = frame.width - textX; - const stats = [ - config.showRepositories !== false - ? { label: labels.repositories, value: data.publicRepositories } - : null, - config.showFollowers !== false ? { label: labels.followers, value: data.followers } : null, - config.showFollowing !== false ? { label: labels.following, value: data.following } : null, - ].filter((item): item is StatItem => item !== null); - return ( - <> - - {avatarHref && ( - <> - - - - - - )} - - - {stats.length > 0 && ( - - )} - - ); - } - - if (block.type === 'github-langs') { - const languages = Array.isArray(data.languages) - ? data.languages - .filter((item): item is { name: string; percentage: number } => { - const value = asRecord(item); - return typeof value.name === 'string' && typeof value.percentage === 'number'; - }) - .slice(0, 8) - : []; - const barY = t.heading * BLOCK_LINE_HEIGHT + t.sectionGap; - const listTop = barY + 10 + t.sectionGap; - const columnWidth = (frame.width - t.columnGap) / 2; - const rowHeight = t.meta * BLOCK_LINE_HEIGHT; - const barClipId = `bar-${safeId(block.id)}`; - let barOffset = 0; - return ( - <> - - - - - - - {languages.map((language) => { - const segmentWidth = (Math.max(language.percentage, 0) / 100) * frame.width; - const x = barOffset; - barOffset += segmentWidth; - return ( - - ); - })} - - {languages.map((language, index) => { - const x = (index % 2) * (columnWidth + t.columnGap); - const top = listTop + Math.floor(index / 2) * (rowHeight + t.rowGap); - const percent = `${language.percentage}%`; - return ( - - - - - - ); - })} - - ); - } - - if (block.type === 'github-commits') { - const stats: StatItem[] = [ - { label: labels.commits, value: data.commitsYear }, - ...(config.showStreak !== false - ? [ - { label: labels.currentStreak, value: data.currentStreak }, - { label: labels.longestStreak, value: data.longestStreak }, - ] - : []), - ]; - const statsTop = t.heading * BLOCK_LINE_HEIGHT + t.sectionGap; - const heatmapTop = statsTop + statsRowHeight(t) + t.sectionGap; - const levels = Array.isArray(data.levels) - ? data.levels.filter((level): level is number => typeof level === 'number') - : []; - const columns = heatmapColumns( - levels, - heatmapWeeks(frame.width), - asNumber(data.firstDayOfWeek), - ); - return ( - <> - - - {config.showHeatmap !== false && - columns.map((column, week) => - column.map((level, weekday) => - level === null ? null : ( - - ), - ), - )} - - ); - } - - if (block.type === 'github-prs') { - const total = asNumber(data.total); - const parts = [ - { label: labels.merged, value: asNumber(data.merged), fill: tokens.accent, opacity: 1 }, - { label: labels.open, value: asNumber(data.open), fill: tokens.accent, opacity: 0.45 }, - { label: labels.closed, value: asNumber(data.closed), fill: tokens.ink, opacity: 0.2 }, - ]; - const statsTop = t.heading * BLOCK_LINE_HEIGHT + t.sectionGap; - const barY = statsTop + statsRowHeight(t) + t.sectionGap; - const legendTop = barY + 10 + t.sectionGap; - const legendWidth = (frame.width - t.columnGap) / 2; - const legendRowHeight = t.meta * BLOCK_LINE_HEIGHT; - const barClipId = `prs-${safeId(block.id)}`; - let barOffset = 0; - return ( - <> - - - {config.showBreakdown !== false && ( - <> - - - - - - {total > 0 && - parts.map((part) => { - const width = (part.value / total) * frame.width; - const x = barOffset; - barOffset += width; - return ( - - ); - })} - - {parts.map((part, index) => { - // Same two-column list as the languages block: label left, percent right. - const x = (index % 2) * (legendWidth + t.columnGap); - const top = legendTop + Math.floor(index / 2) * (legendRowHeight + t.rowGap); - const percent = `${total > 0 ? Math.round((part.value / total) * 100) : 0}%`; - return ( - - - - - - ); - })} - - )} - + return frame.width < 200 || frame.height < 90 ? ( + + ) : ( + ); } - if (block.type === 'github-status') { - const emoji = asString(data.emoji); - const message = asString(data.message); - const top = t.heading * BLOCK_LINE_HEIGHT + t.sectionGap; - const messageTop = emoji ? top + t.text * BLOCK_LINE_HEIGHT + t.headingGap : top; - const messageLines = message - ? linesOf(message, Math.floor(frame.width / (t.heading * 0.55)), 3) - : []; - const chipTop = messageTop + messageLines.length * t.heading * BLOCK_LINE_HEIGHT + t.headingGap; - const chipHeight = t.meta * BLOCK_LINE_HEIGHT + 8; - return ( - <> - - {!emoji && !message ? ( - - ) : ( - <> - {emoji && ( - - )} - - {data.busy === true && ( - - - - - )} - - )} - - ); - } + if (!rendered?.data) return ; - const solved = asRecord(data.solved); - const stats = [ - { label: labels.solved, value: solved.all }, - config.showRanking !== false ? { label: labels.ranking, value: data.ranking } : null, - config.showContestRating !== false - ? { label: labels.contestRating, value: data.contestRating } - : null, - ].filter((item): item is StatItem => item !== null); - const statsTop = t.heading * BLOCK_LINE_HEIGHT + t.sectionGap; - const difficultyTop = statsTop + statsRowHeight(t) + t.sectionGap; - const difficultyWidth = (frame.width - t.columnGap * 2) / 3; - const difficulty = [ - { label: labels.easy, value: solved.easy, color: difficultyColors.easy }, - { label: labels.medium, value: solved.medium, color: difficultyColors.medium }, - { label: labels.hard, value: solved.hard, color: difficultyColors.hard }, - ]; - return ( - <> - - - {difficulty.map((item, index) => { - const x = index * (difficultyWidth + t.columnGap); - return ( - - - - - ); - })} - - ); + return blockRenderers[block.type]?.(context) ?? null; }; diff --git a/server/src/shared/widget/blockSizes.test.ts b/server/src/shared/widget/blockSizes.test.ts new file mode 100644 index 0000000..5a60599 --- /dev/null +++ b/server/src/shared/widget/blockSizes.test.ts @@ -0,0 +1,52 @@ +import { + DEFAULT_BLOCK_SIZE, + isAllowedBlockSize, + snapToAllowedSize, +} from '@shared/widget/blockSizes.js'; + +it('allows only the sizes a block declares', () => { + expect(DEFAULT_BLOCK_SIZE).toEqual({ width: 2, height: 2 }); + expect(isAllowedBlockSize('github-stats', 2, 2)).toBe(true); + expect(isAllowedBlockSize('github-stats', 3, 3)).toBe(false); + // Unknown types fall back to the legacy set. + expect(isAllowedBlockSize('future-block', 4, 2)).toBe(true); +}); + +it('snaps a dragged size to the nearest allowed one', () => { + expect(snapToAllowedSize('github-stats', 3, 2)).toEqual({ width: 2, height: 2 }); + expect(snapToAllowedSize('github-stats', 4, 3)).toEqual({ width: 4, height: 2 }); + expect(snapToAllowedSize('github-stats', 4, 4)).toEqual({ width: 4, height: 4 }); + expect(snapToAllowedSize('github-stats', 1, 1)).toEqual({ width: 1, height: 1 }); + expect(snapToAllowedSize('github-stats', 3, 1)).toEqual({ width: 2, height: 1 }); + // Text supports more sizes. + expect(snapToAllowedSize('text', 3, 2)).toEqual({ width: 3, height: 2 }); +}); + +it('prefers sizes that fit the space available from the block position', () => { + // Dragging to 4 wide from column 2 only has room for 2 columns. + expect(snapToAllowedSize('github-prs', 4, 2, { maxWidth: 2 })).toEqual({ width: 2, height: 2 }); + expect(snapToAllowedSize('github-prs', 2, 4, { maxHeight: 3 })).toEqual({ width: 2, height: 2 }); +}); + +it('maps sizes to layout variants', async () => { + const { blockVariant, defaultBlockSize } = await import('@shared/widget/blockSizes.js'); + expect(blockVariant(1, 1)).toBe('tiny'); + expect(blockVariant(2, 1)).toBe('strip'); + expect(blockVariant(3, 1)).toBe('strip'); + expect(blockVariant(4, 1)).toBe('wide'); + expect(blockVariant(2, 2)).toBe('card'); + expect(blockVariant(2, 4)).toBe('card'); + expect(blockVariant(4, 2)).toBe('large'); + expect(defaultBlockSize('text')).toEqual({ width: 2, height: 1 }); + expect(defaultBlockSize('github-stats')).toEqual({ width: 2, height: 2 }); +}); + +it('falls back to smaller sizes when placing a new block', async () => { + const { placementSizes } = await import('@shared/widget/blockSizes.js'); + expect(placementSizes('github-stats')).toEqual([ + { width: 2, height: 2 }, + { width: 4, height: 1 }, + { width: 2, height: 1 }, + { width: 1, height: 1 }, + ]); +}); diff --git a/server/src/shared/widget/blockSizes.ts b/server/src/shared/widget/blockSizes.ts new file mode 100644 index 0000000..cf0428f --- /dev/null +++ b/server/src/shared/widget/blockSizes.ts @@ -0,0 +1,85 @@ +// Which sizes (in grid cells) each block type supports. A block only offers sizes it has a +// design for; the editor snaps corner-resizing to these and the server rejects anything else. +import { MAX_BLOCK_HEIGHT, MAX_BLOCK_WIDTH } from './geometry.js'; + +export type BlockSize = { width: number; height: number }; + +const size = (width: number, height: number): BlockSize => ({ width, height }); + +// The 2-column grid's sizes, doubled. Every block keeps them so migrated widgets stay valid; they +// render with the "card" / "large" variants. +const LEGACY_SIZES = [size(2, 2), size(2, 4), size(4, 2), size(4, 4)]; +// Compact sizes with dedicated layouts: tile, strip and wide strip. +const COMPACT_SIZES = [size(1, 1), size(2, 1), size(4, 1)]; + +export const BLOCK_SIZES: Record = { + text: [...COMPACT_SIZES, size(3, 1), size(3, 2), size(4, 3), ...LEGACY_SIZES], + 'github-stats': [...COMPACT_SIZES, ...LEGACY_SIZES], + 'github-langs': [...COMPACT_SIZES, ...LEGACY_SIZES], + 'github-commits': [...COMPACT_SIZES, ...LEGACY_SIZES], + 'github-prs': [...COMPACT_SIZES, ...LEGACY_SIZES], + 'github-status': [...COMPACT_SIZES, ...LEGACY_SIZES], + 'leetcode-stats': [...COMPACT_SIZES, ...LEGACY_SIZES], +}; + +export const DEFAULT_BLOCK_SIZE: BlockSize = size(2, 2); + +const DEFAULT_SIZES: Record = { text: size(2, 1) }; + +export const defaultBlockSize = (type: string) => DEFAULT_SIZES[type] ?? DEFAULT_BLOCK_SIZE; + +/** + * Sizes to try when placing a new block: the default first, then the other allowed sizes no + * larger than it, biggest first (so a nearly full widget still takes a compact block). + */ +export const placementSizes = (type: string): BlockSize[] => { + const preferred = defaultBlockSize(type); + const area = (item: BlockSize) => item.width * item.height; + const smaller = allowedBlockSizes(type) + .filter( + (item) => + area(item) <= area(preferred) && + !(item.width === preferred.width && item.height === preferred.height), + ) + .sort((left, right) => area(right) - area(left) || right.width - left.width); + return [preferred, ...smaller]; +}; + +/** + * Layout family for a block size: `tiny` 1×1 tile, `strip` 2–3×1, `wide` 4×1, `card` 2–3 columns + * × 2+ rows (the classic block look), `large` 4 columns × 2+ rows. A block without a dedicated + * `large` layout renders its `card` layout there. + */ +export type BlockVariant = 'tiny' | 'strip' | 'wide' | 'card' | 'large'; + +export const blockVariant = (width: number, height: number): BlockVariant => { + if (width === 1) return 'tiny'; + if (height === 1) return width >= 4 ? 'wide' : 'strip'; + return width >= 4 ? 'large' : 'card'; +}; + +export const allowedBlockSizes = (type: string) => BLOCK_SIZES[type] ?? LEGACY_SIZES; + +export const isAllowedBlockSize = (type: string, width: number, height: number) => + allowedBlockSizes(type).some((item) => item.width === width && item.height === height); + +/** + * The allowed size closest to what the user dragged to (in cells), preferring sizes that fit the + * space available from the block's position. Ties go to the smaller block. + */ +export const snapToAllowedSize = ( + type: string, + width: number, + height: number, + { maxWidth = MAX_BLOCK_WIDTH, maxHeight = MAX_BLOCK_HEIGHT } = {}, +): BlockSize => { + const candidates = allowedBlockSizes(type); + const fitting = candidates.filter((item) => item.width <= maxWidth && item.height <= maxHeight); + const pool = fitting.length ? fitting : candidates; + return pool.reduce((best, item) => { + const distance = Math.abs(item.width - width) + Math.abs(item.height - height); + const bestDistance = Math.abs(best.width - width) + Math.abs(best.height - height); + if (distance !== bestDistance) return distance < bestDistance ? item : best; + return item.width * item.height < best.width * best.height ? item : best; + }); +}; diff --git a/server/src/shared/widget/blockVariants.test.tsx b/server/src/shared/widget/blockVariants.test.tsx new file mode 100644 index 0000000..13a6dc1 --- /dev/null +++ b/server/src/shared/widget/blockVariants.test.tsx @@ -0,0 +1,79 @@ +import { renderToStaticMarkup } from 'react-dom/server'; + +import { BLOCK_SIZES, blockVariant } from '@shared/widget/blockSizes.js'; +import { WidgetCanvas } from '@shared/widget/WidgetCanvas.js'; + +const sampleData: Record> = { + 'github-stats': { + username: 'octocat', + name: 'The Octocat', + bio: 'Mascot', + publicRepositories: 8, + followers: 4321, + following: 9, + }, + 'github-langs': { + username: 'octocat', + languages: [ + { name: 'TypeScript', percentage: 60 }, + { name: 'Go', percentage: 40 }, + ], + }, + 'github-commits': { + username: 'octocat', + commitsYear: 1234, + currentStreak: 5, + longestStreak: 20, + levels: Array(371).fill(2), + firstDayOfWeek: 0, + }, + 'github-prs': { username: 'octocat', total: 10, merged: 7, open: 1, closed: 2 }, + 'github-status': { username: 'octocat', emoji: '🚀', message: 'Shipping', busy: true }, + 'leetcode-stats': { + username: 'tourist', + ranking: 42, + contestRating: 3000, + solved: { all: 600, easy: 300, medium: 200, hard: 100 }, + }, +}; + +// Something each variant must show, proving the right data reached it. +const expectedText: Record string> = { + text: () => 'Hello', + 'github-stats': (variant) => (variant === 'tiny' ? '4,321' : 'The Octocat'), + 'github-langs': (variant) => (variant === 'tiny' ? '60%' : 'TypeScript'), + 'github-commits': () => '1,234', + 'github-prs': (variant) => (variant === 'tiny' ? '70%' : '10'), + 'github-status': () => '🚀', + 'leetcode-stats': () => '600', +}; + +const render = (type: string, width: number, height: number) => + renderToStaticMarkup( + , + ); + +describe.each(Object.entries(BLOCK_SIZES))('%s', (type, sizes) => { + it.each(sizes.map((size) => [`${size.width}×${size.height}`, size] as const))( + 'renders the %s variant with its data', + (_label, size) => { + const svg = render(type, size.width, size.height); + expect(svg).toContain(expectedText[type](blockVariant(size.width, size.height))); + expect(svg).not.toContain('NaN'); + expect(svg).not.toContain('undefined'); + }, + ); +}); diff --git a/server/src/shared/widget/blocks/context.ts b/server/src/shared/widget/blocks/context.ts new file mode 100644 index 0000000..e2670dc --- /dev/null +++ b/server/src/shared/widget/blocks/context.ts @@ -0,0 +1,24 @@ +import type { ReactNode } from 'react'; + +import type { BlockVariant } from '../blockSizes.js'; +import type { BlockTypography } from '../geometry.js'; +import type { PaletteTokens, WidgetLocale, widgetLabels } from '../theme.js'; +import type { WidgetCanvasBlock } from '../types.js'; +import type { BlockFrame } from '../svgPrimitives.js'; + +/** Everything a block renderer needs; coordinates are relative to the block's content box. */ +export type BlockRenderContext = { + block: WidgetCanvasBlock; + variant: BlockVariant; + config: Record; + data: Record; + labels: ReturnType; + t: BlockTypography; + frame: BlockFrame; + tokens: PaletteTokens; + locale: WidgetLocale; + username: string; + avatarDataUri?: string; +}; + +export type BlockRenderer = (context: BlockRenderContext) => ReactNode; diff --git a/server/src/shared/widget/blocks/githubCommits.tsx b/server/src/shared/widget/blocks/githubCommits.tsx new file mode 100644 index 0000000..b91c402 --- /dev/null +++ b/server/src/shared/widget/blocks/githubCommits.tsx @@ -0,0 +1,163 @@ +import type { BlockRenderer } from './context.js'; +import { + BLOCK_LINE_HEIGHT, + HEATMAP_CELL, + HEATMAP_GAP, + heatmapColumns, + heatmapWeeks, +} from '../geometry.js'; +import { formatStatValue, heatmapOpacity } from '../theme.js'; +import { + StatsRow, + SvgText, + TitleRow, + asNumber, + asString, + baseline, + clamp, + statsRowHeight, + type StatItem, +} from '../svgPrimitives.js'; +import { BigStat, bigStatHeight } from './parts.js'; + +// 1×1: commits this year and the current streak. +const tiny: BlockRenderer = ({ data, labels, frame, tokens }) => { + const statHeight = bigStatHeight(26, 10); + const top = Math.max((frame.height - statHeight - 18) / 2, 0); + return ( + <> + + + + ); +}; + +// 2×1: title with commits and current streak. +const strip: BlockRenderer = ({ data, labels, t, frame, tokens, username }) => ( + <> + + + +); + +// 4×1: caption and a compact heatmap filling the rest of the strip. +const wide: BlockRenderer = ({ data, labels, t, frame, tokens, username }) => { + const gap = 2; + const top = t.heading * BLOCK_LINE_HEIGHT + 8; + const cell = Math.max(Math.floor((frame.height - top - gap * 6) / 7), 4); + const levels = Array.isArray(data.levels) + ? data.levels.filter((level): level is number => typeof level === 'number') + : []; + const weeks = clamp(Math.floor((frame.width + gap) / (cell + gap)), 4, 53); + const columns = heatmapColumns(levels, weeks, asNumber(data.firstDayOfWeek)); + return ( + <> + + {columns.map((column, week) => + column.map((level, weekday) => + level === null ? null : ( + + ), + ), + )} + + ); +}; + +const card: BlockRenderer = ({ config, data, labels, t, frame, tokens, username }) => { + const stats: StatItem[] = [ + { label: labels.commits, value: data.commitsYear }, + ...(config.showStreak !== false + ? [ + { label: labels.currentStreak, value: data.currentStreak }, + { label: labels.longestStreak, value: data.longestStreak }, + ] + : []), + ]; + const statsTop = t.heading * BLOCK_LINE_HEIGHT + t.sectionGap; + const heatmapTop = statsTop + statsRowHeight(t) + t.sectionGap; + const levels = Array.isArray(data.levels) + ? data.levels.filter((level): level is number => typeof level === 'number') + : []; + const columns = heatmapColumns(levels, heatmapWeeks(frame.width), asNumber(data.firstDayOfWeek)); + return ( + <> + + + {config.showHeatmap !== false && + columns.map((column, week) => + column.map((level, weekday) => + level === null ? null : ( + + ), + ), + )} + + ); +}; + +export const renderGithubCommits: BlockRenderer = (context) => { + if (context.variant === 'tiny') return tiny(context); + if (context.variant === 'strip') return strip(context); + if (context.variant === 'wide') return wide(context); + return card(context); +}; diff --git a/server/src/shared/widget/blocks/githubLangs.tsx b/server/src/shared/widget/blocks/githubLangs.tsx new file mode 100644 index 0000000..354b451 --- /dev/null +++ b/server/src/shared/widget/blocks/githubLangs.tsx @@ -0,0 +1,226 @@ +import type { BlockRenderer } from './context.js'; +import { BLOCK_LINE_HEIGHT, estimateTextWidth } from '../geometry.js'; +import { languageColor } from '../theme.js'; +import { SvgText, TitleRow, asRecord, baseline, fitText, safeId } from '../svgPrimitives.js'; +import { SegmentBar, fitFontSize } from './parts.js'; + +type Language = { name: string; percentage: number }; + +const languagesOf = (data: Record): Language[] => + Array.isArray(data.languages) + ? data.languages.filter((item): item is Language => { + const value = asRecord(item); + return typeof value.name === 'string' && typeof value.percentage === 'number'; + }) + : []; + +const languageSegments = (languages: Language[]) => + languages.map((language) => ({ + key: language.name, + value: language.percentage, + fill: languageColor(language.name), + })); + +// 1×1: the top language and its share, with the full mix as a thin bar. +const tiny: BlockRenderer = ({ block, data, labels, frame, tokens }) => { + const languages = languagesOf(data); + const top = languages[0]; + const percent = `${top?.percentage ?? 0}%`; + const size = fitFontSize(percent, frame.width, { max: 30, min: 14, letterSpacing: -0.06 }); + return ( + <> + + + {top && ( + <> + + + + )} + + + ); +}; + +// 2×1 and 4×1: title, bar and one row of legend (2 or 4 languages). +const legendStrip: BlockRenderer = ({ block, data, labels, t, frame, tokens, variant }) => { + const languages = languagesOf(data); + const shown = languages.slice(0, variant === 'wide' ? 4 : 2); + const barY = t.heading * BLOCK_LINE_HEIGHT + 10; + const legendTop = barY + 10 + 10; + const columnWidth = (frame.width - t.columnGap * (shown.length - 1)) / Math.max(shown.length, 1); + return ( + <> + + + {shown.map((language, index) => { + const x = index * (columnWidth + t.columnGap); + const percent = `${language.percentage}%`; + const percentWidth = estimateTextWidth(percent, t.meta, { weight: 700 }); + return ( + + + + + + ); + })} + + ); +}; + +const card: BlockRenderer = ({ block, data, labels, t, frame, tokens }) => { + const languages = Array.isArray(data.languages) + ? data.languages + .filter((item): item is { name: string; percentage: number } => { + const value = asRecord(item); + return typeof value.name === 'string' && typeof value.percentage === 'number'; + }) + .slice(0, 8) + : []; + const barY = t.heading * BLOCK_LINE_HEIGHT + t.sectionGap; + const listTop = barY + 10 + t.sectionGap; + const columnWidth = (frame.width - t.columnGap) / 2; + const rowHeight = t.meta * BLOCK_LINE_HEIGHT; + const barClipId = `bar-${safeId(block.id)}`; + let barOffset = 0; + return ( + <> + + + + + + + {languages.map((language) => { + const segmentWidth = (Math.max(language.percentage, 0) / 100) * frame.width; + const x = barOffset; + barOffset += segmentWidth; + return ( + + ); + })} + + {languages.map((language, index) => { + const x = (index % 2) * (columnWidth + t.columnGap); + const top = listTop + Math.floor(index / 2) * (rowHeight + t.rowGap); + const percent = `${language.percentage}%`; + return ( + + + + + + ); + })} + + ); +}; + +export const renderGithubLangs: BlockRenderer = (context) => { + if (context.variant === 'tiny') return tiny(context); + if (context.variant === 'strip' || context.variant === 'wide') return legendStrip(context); + return card(context); +}; diff --git a/server/src/shared/widget/blocks/githubPrs.tsx b/server/src/shared/widget/blocks/githubPrs.tsx new file mode 100644 index 0000000..7cc2740 --- /dev/null +++ b/server/src/shared/widget/blocks/githubPrs.tsx @@ -0,0 +1,205 @@ +import type { BlockRenderer } from './context.js'; +import { BLOCK_LINE_HEIGHT, estimateTextWidth } from '../geometry.js'; +import { + StatsRow, + SvgText, + TitleRow, + asNumber, + asString, + baseline, + fitText, + safeId, + statsRowHeight, + type StatItem, +} from '../svgPrimitives.js'; +import { SegmentBar, fitFontSize } from './parts.js'; + +const prSegments = (data: Record, tokens: { accent: string; ink: string }) => [ + { key: 'merged', value: asNumber(data.merged), fill: tokens.accent }, + { key: 'open', value: asNumber(data.open), fill: tokens.accent, opacity: 0.45 }, + { key: 'closed', value: asNumber(data.closed), fill: tokens.ink, opacity: 0.2 }, +]; + +// 1×1: share of merged pull requests. +const tiny: BlockRenderer = ({ block, data, labels, frame, tokens }) => { + const total = asNumber(data.total); + const percent = `${total > 0 ? Math.round((asNumber(data.merged) / total) * 100) : 0}%`; + const size = fitFontSize(percent, frame.width, { max: 30, min: 14, letterSpacing: -0.06 }); + return ( + <> + + + + + + ); +}; + +// 2×1 and 4×1: title and stats (4×1 adds closed and the breakdown bar). +const strips: BlockRenderer = ({ block, data, labels, t, frame, tokens, username, variant }) => { + const statsTop = t.heading * BLOCK_LINE_HEIGHT + 8; + const items: StatItem[] = [ + { label: labels.total, value: data.total }, + { label: labels.merged, value: data.merged }, + { label: labels.open, value: data.open }, + ...(variant === 'wide' ? [{ label: labels.closed, value: data.closed }] : []), + ]; + return ( + <> + + + {variant === 'wide' && ( + + )} + + ); +}; + +const card: BlockRenderer = ({ block, config, data, labels, t, frame, tokens, username }) => { + const total = asNumber(data.total); + const parts = [ + { label: labels.merged, value: asNumber(data.merged), fill: tokens.accent, opacity: 1 }, + { label: labels.open, value: asNumber(data.open), fill: tokens.accent, opacity: 0.45 }, + { label: labels.closed, value: asNumber(data.closed), fill: tokens.ink, opacity: 0.2 }, + ]; + const statsTop = t.heading * BLOCK_LINE_HEIGHT + t.sectionGap; + const barY = statsTop + statsRowHeight(t) + t.sectionGap; + const legendTop = barY + 10 + t.sectionGap; + const legendWidth = (frame.width - t.columnGap) / 2; + const legendRowHeight = t.meta * BLOCK_LINE_HEIGHT; + const barClipId = `prs-${safeId(block.id)}`; + let barOffset = 0; + return ( + <> + + + {config.showBreakdown !== false && ( + <> + + + + + + {total > 0 && + parts.map((part) => { + const width = (part.value / total) * frame.width; + const x = barOffset; + barOffset += width; + return ( + + ); + })} + + {parts.map((part, index) => { + // Same two-column list as the languages block: label left, percent right. + const x = (index % 2) * (legendWidth + t.columnGap); + const top = legendTop + Math.floor(index / 2) * (legendRowHeight + t.rowGap); + const percent = `${total > 0 ? Math.round((part.value / total) * 100) : 0}%`; + return ( + + + + + + ); + })} + + )} + + ); +}; + +export const renderGithubPrs: BlockRenderer = (context) => { + if (context.variant === 'tiny') return tiny(context); + if (context.variant === 'strip' || context.variant === 'wide') return strips(context); + return card(context); +}; diff --git a/server/src/shared/widget/blocks/githubStats.tsx b/server/src/shared/widget/blocks/githubStats.tsx new file mode 100644 index 0000000..d13699b --- /dev/null +++ b/server/src/shared/widget/blocks/githubStats.tsx @@ -0,0 +1,337 @@ +import type { BlockRenderer } from './context.js'; +import { BLOCK_LINE_HEIGHT } from '../geometry.js'; +import { formatStatValue } from '../theme.js'; +import { + MultilineText, + StatsRow, + SvgText, + asString, + baseline, + clamp, + fitText, + imageUrl, + safeId, + statsRowHeight, + type StatItem, +} from '../svgPrimitives.js'; +import { Avatar, BigStat, fitFontSize, wrapText } from './parts.js'; + +const avatarOf = (data: Record, avatarDataUri?: string) => + avatarDataUri ?? imageUrl(data.avatarUrl); + +// 1×1: avatar and followers. +const tiny: BlockRenderer = ({ block, data, labels, frame, tokens, avatarDataUri }) => { + const avatar = Math.round(Math.min(44, frame.height * 0.42)); + return ( + <> + + + + ); +}; + +// 2×1: avatar, name, handle and a one-line summary. +const strip: BlockRenderer = ({ + block, + data, + labels, + t, + frame, + tokens, + username, + avatarDataUri, +}) => { + const avatar = Math.round(Math.min(56, frame.height)); + const x = avatar + 12; + const width = frame.width - x; + const summary = `${formatStatValue(data.publicRepositories)} ${labels.repositories.toLowerCase()} · ${formatStatValue(data.followers)} ${labels.followers.toLowerCase()}`; + const columnHeight = (t.heading + t.meta * 2) * BLOCK_LINE_HEIGHT + 8; + const top = (frame.height - columnHeight) / 2; + return ( + <> + + + + + + ); +}; + +const profileStats = ( + config: Record, + data: Record, + labels: { repositories: string; followers: string; following: string }, +): StatItem[] => + [ + config.showRepositories !== false + ? { label: labels.repositories, value: data.publicRepositories } + : null, + config.showFollowers !== false ? { label: labels.followers, value: data.followers } : null, + config.showFollowing !== false ? { label: labels.following, value: data.following } : null, + ].filter((item) => item !== null); + +// 4×1: identity on the left, stats on the right. +const wide: BlockRenderer = ({ + block, + config, + data, + labels, + t, + frame, + tokens, + username, + avatarDataUri, +}) => { + const avatar = Math.round(Math.min(64, frame.height)); + const x = avatar + 12; + const identityWidth = frame.width * 0.38 - x; + const stats = profileStats(config, data, labels); + const statsX = frame.width * 0.4; + const statsWidth = frame.width - statsX; + const columnWidth = (statsWidth - t.columnGap * (stats.length - 1)) / Math.max(stats.length, 1); + const identityTop = (frame.height - (t.heading + t.meta) * BLOCK_LINE_HEIGHT - 2) / 2; + const statsTop = (frame.height - statsRowHeight(t)) / 2; + return ( + <> + + + + {stats.map((item, index) => ( + + ))} + + ); +}; + +// 4×2: big avatar, name, handle, bio and stats along the bottom. +const large: BlockRenderer = ({ + block, + config, + data, + labels, + t, + frame, + tokens, + username, + avatarDataUri, +}) => { + const avatar = Math.round(Math.min(88, frame.height * 0.45)); + const x = avatar + 16; + const width = frame.width - x; + const name = asString(data.name, labels.githubProfile); + const nameSize = fitFontSize(name, width, { max: 26, min: t.heading }); + const bio = asString(data.bio); + const bioSize = 13; + const bioLines = bio ? wrapText(bio, width, bioSize, { maxLines: 2 }) : []; + const stats = profileStats(config, data, labels); + return ( + <> + + + + + {stats.length > 0 && ( + + )} + + ); +}; + +const card: BlockRenderer = ({ + block, + config, + data, + labels, + t, + frame, + tokens, + username, + avatarDataUri, +}) => { + const avatarHref = avatarDataUri ?? imageUrl(data.avatarUrl); + const avatarId = `avatar-${safeId(block.id)}`; + const tightGap = clamp(frame.width / 100, 2, 3); + const textColumnHeight = t.heading * BLOCK_LINE_HEIGHT + tightGap + t.meta * BLOCK_LINE_HEIGHT; + const headingHeight = Math.max(t.avatar, textColumnHeight); + const avatarY = (headingHeight - t.avatar) / 2; + const textTop = (headingHeight - textColumnHeight) / 2; + const textX = t.avatar + t.headingGap; + const textWidth = frame.width - textX; + const stats = [ + config.showRepositories !== false + ? { label: labels.repositories, value: data.publicRepositories } + : null, + config.showFollowers !== false ? { label: labels.followers, value: data.followers } : null, + config.showFollowing !== false ? { label: labels.following, value: data.following } : null, + ].filter((item): item is StatItem => item !== null); + return ( + <> + + {avatarHref && ( + <> + + + + + + )} + + + {stats.length > 0 && ( + + )} + + ); +}; + +export const renderGithubStats: BlockRenderer = (context) => { + if (context.variant === 'tiny') return tiny(context); + if (context.variant === 'strip') return strip(context); + if (context.variant === 'wide') return wide(context); + if (context.variant === 'large') return large(context); + return card(context); +}; diff --git a/server/src/shared/widget/blocks/githubStatus.tsx b/server/src/shared/widget/blocks/githubStatus.tsx new file mode 100644 index 0000000..d668579 --- /dev/null +++ b/server/src/shared/widget/blocks/githubStatus.tsx @@ -0,0 +1,172 @@ +import type { BlockRenderer } from './context.js'; +import { BLOCK_LINE_HEIGHT, estimateTextWidth } from '../geometry.js'; +import { MultilineText, SvgText, TitleRow, asString, baseline, linesOf } from '../svgPrimitives.js'; +import { wrapText } from './parts.js'; + +// 1×1: the emoji, with a dot when busy. +const tiny: BlockRenderer = ({ data, labels, frame, tokens }) => { + const emoji = asString(data.emoji); + const message = asString(data.message); + if (!emoji) { + const lines = wrapText(message || labels.noStatus, frame.width, 12, { + weight: 700, + maxLines: 4, + }); + return ( + + ); + } + const size = Math.round(Math.min(frame.width, frame.height) * 0.5); + return ( + <> + + {data.busy === true && } + + ); +}; + +// 2×1 and 4×1: emoji on the left, message (2 lines / 1 line) and busy flag on the right. +const strips: BlockRenderer = ({ data, labels, t, frame, tokens, variant }) => { + const emoji = asString(data.emoji); + const message = asString(data.message) || labels.noStatus; + const emojiSize = Math.round(Math.min(frame.height * 0.5, variant === 'wide' ? 40 : 34)); + const x = emoji ? emojiSize * 1.35 : 0; + const width = frame.width - x; + const lines = wrapText(message, width, t.heading, { + weight: 700, + maxLines: variant === 'wide' ? 1 : 2, + }); + const busy = data.busy === true; + const textHeight = + lines.length * t.heading * BLOCK_LINE_HEIGHT + (busy ? t.meta * BLOCK_LINE_HEIGHT + 6 : 0); + const top = (frame.height - textHeight) / 2; + return ( + <> + {emoji && ( + + )} + + {busy && ( + + )} + + ); +}; + +const card: BlockRenderer = ({ data, labels, t, frame, tokens, username }) => { + const emoji = asString(data.emoji); + const message = asString(data.message); + const top = t.heading * BLOCK_LINE_HEIGHT + t.sectionGap; + const messageTop = emoji ? top + t.text * BLOCK_LINE_HEIGHT + t.headingGap : top; + const messageLines = message + ? linesOf(message, Math.floor(frame.width / (t.heading * 0.55)), 3) + : []; + const chipTop = messageTop + messageLines.length * t.heading * BLOCK_LINE_HEIGHT + t.headingGap; + const chipHeight = t.meta * BLOCK_LINE_HEIGHT + 8; + return ( + <> + + {!emoji && !message ? ( + + ) : ( + <> + {emoji && ( + + )} + + {data.busy === true && ( + + + + + )} + + )} + + ); +}; + +export const renderGithubStatus: BlockRenderer = (context) => { + if (context.variant === 'tiny') return tiny(context); + if (context.variant === 'strip' || context.variant === 'wide') return strips(context); + return card(context); +}; diff --git a/server/src/shared/widget/blocks/index.ts b/server/src/shared/widget/blocks/index.ts new file mode 100644 index 0000000..1463749 --- /dev/null +++ b/server/src/shared/widget/blocks/index.ts @@ -0,0 +1,20 @@ +import type { BlockRenderer } from './context.js'; +import { renderGithubCommits } from './githubCommits.js'; +import { renderGithubLangs } from './githubLangs.js'; +import { renderGithubPrs } from './githubPrs.js'; +import { renderGithubStats } from './githubStats.js'; +import { renderGithubStatus } from './githubStatus.js'; +import { renderLeetcode } from './leetcode.js'; +import { renderText } from './text.js'; + +export type { BlockRenderContext, BlockRenderer } from './context.js'; + +export const blockRenderers: Record = { + text: renderText, + 'github-stats': renderGithubStats, + 'github-langs': renderGithubLangs, + 'github-commits': renderGithubCommits, + 'github-prs': renderGithubPrs, + 'github-status': renderGithubStatus, + 'leetcode-stats': renderLeetcode, +}; diff --git a/server/src/shared/widget/blocks/leetcode.tsx b/server/src/shared/widget/blocks/leetcode.tsx new file mode 100644 index 0000000..47ff68f --- /dev/null +++ b/server/src/shared/widget/blocks/leetcode.tsx @@ -0,0 +1,235 @@ +import type { BlockRenderer } from './context.js'; +import { BLOCK_LINE_HEIGHT } from '../geometry.js'; +import { difficultyColors } from '../theme.js'; +import { + StatsRow, + SvgText, + TitleRow, + asNumber, + asRecord, + asString, + baseline, + fitText, + formatNumber, + statsRowHeight, + type StatItem, +} from '../svgPrimitives.js'; +import { BigStat, SegmentBar, bigStatHeight } from './parts.js'; + +const solvedOf = (data: Record) => asRecord(data.solved); + +const difficultySegments = (solved: Record) => [ + { key: 'easy', value: asNumber(solved.easy), fill: difficultyColors.easy }, + { key: 'medium', value: asNumber(solved.medium), fill: difficultyColors.medium }, + { key: 'hard', value: asNumber(solved.hard), fill: difficultyColors.hard }, +]; + +// 1×1: solved count with the difficulty mix. +const tiny: BlockRenderer = ({ block, data, labels, frame, tokens }) => { + const solved = solvedOf(data); + const statHeight = bigStatHeight(26, 10); + return ( + <> + + + + ); +}; + +// 2×1 and 4×1: title, stats and the difficulty bar. +const strips: BlockRenderer = ({ + block, + config, + data, + labels, + t, + frame, + tokens, + username, + variant, +}) => { + const solved = solvedOf(data); + const items: StatItem[] = [ + { label: labels.solved, value: solved.all }, + ...(config.showRanking !== false ? [{ label: labels.ranking, value: data.ranking }] : []), + ...(variant === 'wide' && config.showContestRating !== false + ? [{ label: labels.contestRating, value: data.contestRating }] + : []), + ]; + return ( + <> + + + + + ); +}; + +// 4×2: title, stats and one bar per difficulty. +const large: BlockRenderer = ({ block, config, data, labels, t, frame, tokens, username }) => { + const solved = solvedOf(data); + const total = Math.max(asNumber(solved.all), 1); + const items: StatItem[] = [ + { label: labels.solved, value: solved.all }, + ...(config.showRanking !== false ? [{ label: labels.ranking, value: data.ranking }] : []), + ...(config.showContestRating !== false + ? [{ label: labels.contestRating, value: data.contestRating }] + : []), + ]; + const statsTop = t.heading * BLOCK_LINE_HEIGHT + t.sectionGap; + const rowsTop = statsTop + statsRowHeight(t) + t.sectionGap; + const rowHeight = t.meta * BLOCK_LINE_HEIGHT + 4 + 6; + const rowGap = Math.max((frame.height - rowsTop - rowHeight * 3) / 2, 4); + const rows = [ + { key: 'easy', label: labels.easy, value: asNumber(solved.easy), color: difficultyColors.easy }, + { + key: 'medium', + label: labels.medium, + value: asNumber(solved.medium), + color: difficultyColors.medium, + }, + { key: 'hard', label: labels.hard, value: asNumber(solved.hard), color: difficultyColors.hard }, + ]; + return ( + <> + + + {rows.map((row, index) => { + const top = rowsTop + index * (rowHeight + rowGap); + return ( + + + + + + ); + })} + + ); +}; + +const card: BlockRenderer = ({ config, data, labels, t, frame, tokens, username }) => { + const solved = asRecord(data.solved); + const stats = [ + { label: labels.solved, value: solved.all }, + config.showRanking !== false ? { label: labels.ranking, value: data.ranking } : null, + config.showContestRating !== false + ? { label: labels.contestRating, value: data.contestRating } + : null, + ].filter((item): item is StatItem => item !== null); + const statsTop = t.heading * BLOCK_LINE_HEIGHT + t.sectionGap; + const difficultyTop = statsTop + statsRowHeight(t) + t.sectionGap; + const difficultyWidth = (frame.width - t.columnGap * 2) / 3; + const difficulty = [ + { label: labels.easy, value: solved.easy, color: difficultyColors.easy }, + { label: labels.medium, value: solved.medium, color: difficultyColors.medium }, + { label: labels.hard, value: solved.hard, color: difficultyColors.hard }, + ]; + return ( + <> + + + {difficulty.map((item, index) => { + const x = index * (difficultyWidth + t.columnGap); + return ( + + + + + ); + })} + + ); +}; + +export const renderLeetcode: BlockRenderer = (context) => { + if (context.variant === 'tiny') return tiny(context); + if (context.variant === 'strip' || context.variant === 'wide') return strips(context); + if (context.variant === 'large') return large(context); + return card(context); +}; diff --git a/server/src/shared/widget/blocks/parts.tsx b/server/src/shared/widget/blocks/parts.tsx new file mode 100644 index 0000000..fa9269a --- /dev/null +++ b/server/src/shared/widget/blocks/parts.tsx @@ -0,0 +1,203 @@ +// Building blocks for size variants (tiles and strips) shared by several block renderers. +import { BLOCK_LINE_HEIGHT, estimateTextWidth } from '../geometry.js'; +import { formatStatValue, type PaletteTokens } from '../theme.js'; +import { SvgText, baseline, fitText, safeId } from '../svgPrimitives.js'; + +/** Largest font size (within bounds) at which `text` fits `maxWidth`. */ +export const fitFontSize = ( + text: string, + maxWidth: number, + { + max, + min, + weight = 800, + letterSpacing = 0, + }: { + max: number; + min: number; + weight?: number; + letterSpacing?: number; + }, +) => { + for (let size = max; size > min; size -= 0.5) { + if (estimateTextWidth(text, size, { weight, letterSpacing }) <= maxWidth) return size; + } + return min; +}; + +/** A headline number with a muted caption under it, sized to `width`. */ +export const BigStat = ({ + x = 0, + top, + width, + value, + label, + tokens, + maxSize, + labelSize, + anchor = 'start', +}: { + x?: number; + top: number; + width: number; + value: unknown; + label: string; + tokens: PaletteTokens; + maxSize: number; + labelSize: number; + anchor?: 'start' | 'middle'; +}) => { + const text = formatStatValue(value, width, maxSize); + const size = fitFontSize(text, width, { max: maxSize, min: 12, letterSpacing: -0.06 }); + return ( + + + + + ); +}; + +export const bigStatHeight = (size: number, labelSize: number) => + size * BLOCK_LINE_HEIGHT + 2 + labelSize * BLOCK_LINE_HEIGHT; + +export type BarSegment = { key: string; value: number; fill: string; opacity?: number }; + +/** Rounded stacked bar; segments are proportional to their values. */ +export const SegmentBar = ({ + id, + y, + width, + height = 8, + segments, + tokens, +}: { + id: string; + y: number; + width: number; + height?: number; + segments: BarSegment[]; + tokens: PaletteTokens; +}) => { + const total = segments.reduce((sum, segment) => sum + Math.max(segment.value, 0), 0); + const clipId = `bar-${safeId(id)}`; + let offset = 0; + return ( + <> + + + + + + {total > 0 && + segments.map((segment) => { + const segmentWidth = (Math.max(segment.value, 0) / total) * width; + const x = offset; + offset += segmentWidth; + return ( + + ); + })} + + + ); +}; + +export const Avatar = ({ + id, + x, + y, + size, + href, + tokens, +}: { + id: string; + x: number; + y: number; + size: number; + href: string | null; + tokens: PaletteTokens; +}) => { + const clipId = `avatar-${safeId(id)}`; + const radius = Math.round(size * 0.3); + return ( + <> + + {href && ( + <> + + + + + + )} + + ); +}; + +/** Greedy word wrap by estimated width; overflowing last line gets an ellipsis. */ +export const wrapText = ( + text: string, + width: number, + size: number, + { weight = 500, letterSpacing = 0, maxLines = Infinity } = {}, +) => { + const measure = (value: string) => estimateTextWidth(value, size, { weight, letterSpacing }); + const words = text.split(/\s+/).filter(Boolean); + const lines: string[] = []; + let current = ''; + for (const word of words) { + const next = current ? `${current} ${word}` : word; + if (current && measure(next) > width) { + lines.push(current); + current = word; + } else { + current = next; + } + } + if (current) lines.push(current); + if (lines.length <= maxLines) return lines; + const kept = lines.slice(0, maxLines); + kept[maxLines - 1] = fitText(`${kept[maxLines - 1]} ${lines[maxLines]}`, width, size, weight); + return kept; +}; diff --git a/server/src/shared/widget/blocks/text.tsx b/server/src/shared/widget/blocks/text.tsx new file mode 100644 index 0000000..2e87827 --- /dev/null +++ b/server/src/shared/widget/blocks/text.tsx @@ -0,0 +1,41 @@ +import type { BlockRenderer } from './context.js'; +import { TEXT_BLOCK_LINE_HEIGHT } from '../geometry.js'; +import { MultilineText, asString } from '../svgPrimitives.js'; +import { wrapText } from './parts.js'; + +// Text fills its box: the largest size (up to 40px) at which the wrapped text fits, centred +// vertically, in every allowed size. +export const renderText: BlockRenderer = ({ config, labels, frame, tokens }) => { + const align = config.align === 'center' || config.align === 'right' ? config.align : 'left'; + const anchor = align === 'center' ? 'middle' : align === 'right' ? 'end' : 'start'; + const x = align === 'center' ? frame.width / 2 : align === 'right' ? frame.width : 0; + const value = asString(config.text, labels.defaultText); + const style = { weight: 800, letterSpacing: -0.05 }; + let size = 12; + let lines = wrapText(value, frame.width, size, { + ...style, + maxLines: Math.max(Math.floor(frame.height / (size * TEXT_BLOCK_LINE_HEIGHT)), 1), + }); + for (let candidate = Math.min(40, Math.floor(frame.height)); candidate > 12; candidate -= 1) { + const wrapped = wrapText(value, frame.width, candidate, style); + if (wrapped.length * candidate * TEXT_BLOCK_LINE_HEIGHT <= frame.height) { + size = candidate; + lines = wrapped; + break; + } + } + const top = Math.max((frame.height - lines.length * size * TEXT_BLOCK_LINE_HEIGHT) / 2, 0); + return ( + + ); +}; diff --git a/server/src/shared/widget/geometry.test.ts b/server/src/shared/widget/geometry.test.ts index 6fd0626..72f3c43 100644 --- a/server/src/shared/widget/geometry.test.ts +++ b/server/src/shared/widget/geometry.test.ts @@ -7,43 +7,55 @@ import { widgetDimensions, } from '@shared/widget/geometry.js'; -it('uses square 267px cells for the default 600px widget', () => { +it('uses square 129px cells: a 4×4 grid is exactly 600×600', () => { expect(canvasPadding(600)).toBe(24); - expect(cellSize(600)).toBe(267); - expect(widgetDimensions([{ x: 0, y: 0, width: 1, height: 1 }])).toEqual({ + expect(cellSize(600)).toBe(129); + expect(widgetDimensions([{ x: 0, y: 0, width: 2, height: 2 }])).toEqual({ width: 600, - height: 315, + height: 318, }); - expect(widgetDimensions([{ x: 0, y: 1, width: 2, height: 1 }])).toEqual({ + expect(widgetDimensions([{ x: 0, y: 3, width: 4, height: 1 }])).toEqual({ width: 600, height: 600, }); + // The editable maximum: 5 rows. + expect(widgetDimensions([{ x: 0, y: 4, width: 1, height: 1 }]).height).toBe(741); }); it('places blocks on the same grid the stored size was computed from', () => { const layouts = [ { x: 0, y: 0, width: 1, height: 1 }, - { x: 1, y: 0, width: 1, height: 2 }, + { x: 1, y: 0, width: 3, height: 2 }, { x: 0, y: 1, width: 1, height: 1 }, ]; const canvas = { ...widgetDimensions(layouts), rows: 2 }; - expect(blockBox(layouts[0], canvas)).toEqual({ x: 24, y: 24, width: 267, height: 267 }); - expect(blockBox(layouts[1], canvas)).toEqual({ x: 309, y: 24, width: 267, height: 552 }); - expect(blockBox(layouts[2], canvas)).toEqual({ x: 24, y: 309, width: 267, height: 267 }); + expect(blockBox(layouts[0], canvas)).toEqual({ x: 24, y: 24, width: 129, height: 129 }); + expect(blockBox(layouts[1], canvas)).toEqual({ x: 165, y: 24, width: 411, height: 270 }); + expect(blockBox(layouts[2], canvas)).toEqual({ x: 24, y: 165, width: 129, height: 129 }); }); it('scales block typography with content width within fixed bounds', () => { - const half = blockTypography(blockContentWidth(267)); + const half = blockTypography(blockContentWidth(270)); const full = blockTypography(blockContentWidth(552)); - expect(blockContentWidth(267)).toBe(225); - expect(half.statValue).toBeCloseTo(22.5); + expect([129, 270, 411, 552].map(blockContentWidth)).toEqual([103, 244, 385, 526]); + expect(half.statValue).toBeCloseTo(24.4); expect(full.statValue).toBe(30); - expect(half.meta).toBeCloseTo(10.125); + expect(half.meta).toBeCloseTo(10.98); expect(full.meta).toBe(12); }); +it('finds the top-most free spot within the row limit', async () => { + const { findFreeSpot } = await import('@shared/widget/geometry.js'); + const taken = [ + { x: 0, y: 0, width: 2, height: 2 }, + { x: 2, y: 0, width: 2, height: 1 }, + ]; + expect(findFreeSpot(taken, { width: 2, height: 2 })).toEqual({ x: 2, y: 1, width: 2, height: 2 }); + expect(findFreeSpot([{ x: 0, y: 0, width: 4, height: 5 }], { width: 1, height: 1 })).toBeNull(); +}); + it('switches stats to compact notation only when they do not fit the column', async () => { const { formatStatValue } = await import('@shared/widget/theme.js'); diff --git a/server/src/shared/widget/geometry.ts b/server/src/shared/widget/geometry.ts index 0afdea2..cb3e804 100644 --- a/server/src/shared/widget/geometry.ts +++ b/server/src/shared/widget/geometry.ts @@ -2,14 +2,18 @@ // the SVG export and the stored widget size all derive their numbers from here. export const WIDGET_WIDTH = 600; +// Height cap for rendering; editing is limited to MAX_GRID_ROWS, but widgets migrated from the +// 2-column grid may be taller and must still render. export const MAX_WIDGET_HEIGHT = 1200; -export const MAX_GRID_COLUMNS = 2; -export const MAX_BLOCK_HEIGHT = 2; -export const GRID_GAP = 18; -export const BLOCK_PADDING = 20; +export const MAX_GRID_COLUMNS = 4; +export const MAX_GRID_ROWS = 5; +export const MAX_BLOCK_WIDTH = MAX_GRID_COLUMNS; +export const MAX_BLOCK_HEIGHT = 4; +export const GRID_GAP = 12; +export const BLOCK_PADDING = 12; export const BLOCK_BORDER = 1; export const CANVAS_RADIUS = 28; -export const BLOCK_RADIUS = 20; +export const BLOCK_RADIUS = 16; export const BLOCK_LINE_HEIGHT = 1.25; export const TEXT_BLOCK_LINE_HEIGHT = 1.12; @@ -142,3 +146,24 @@ export const estimateTextWidth = ( for (const character of text) width += glyphWidth(character) * boldness + letterSpacing; return width * size; }; + +export const layoutsOverlap = (left: GridLayout, right: GridLayout) => + left.x < right.x + right.width && + left.x + left.width > right.x && + left.y < right.y + right.height && + left.y + left.height > right.y; + +/** Top-most, then left-most free spot for a block of `size` within the editable rows. */ +export const findFreeSpot = ( + layouts: GridLayout[], + size: { width: number; height: number }, + { columns = MAX_GRID_COLUMNS, rows = MAX_GRID_ROWS } = {}, +): GridLayout | null => { + for (let y = 0; y + size.height <= rows; y += 1) { + for (let x = 0; x + size.width <= columns; x += 1) { + const candidate = { x, y, ...size }; + if (!layouts.some((layout) => layoutsOverlap(layout, candidate))) return candidate; + } + } + return null; +}; diff --git a/server/src/widgets.test.ts b/server/src/widgets.test.ts index bbf1d81..95ff820 100644 --- a/server/src/widgets.test.ts +++ b/server/src/widgets.test.ts @@ -94,25 +94,26 @@ it('creates a widget with preset blocks and a generated slug', async () => { }; }; expect(createCall.data.width).toBe(600); - expect(createCall.data.height).toBe(600); + // Two default 2×2 blocks side by side: 2 rows. + expect(createCall.data.height).toBe(318); expect(createCall.data.blocks.create).toEqual( expect.arrayContaining([ expect.objectContaining({ - config: expect.objectContaining({ layout: { x: 0, y: 0, width: 1, height: 1 } }), + config: expect.objectContaining({ layout: { x: 0, y: 0, width: 2, height: 2 } }), }), expect.objectContaining({ - config: expect.objectContaining({ layout: { x: 0, y: 1, width: 1, height: 1 } }), + config: expect.objectContaining({ layout: { x: 2, y: 0, width: 2, height: 2 } }), }), ]), ); }); -it('creates a new block as a single-column item', async () => { +it('places a new block at the first free spot with the default size', async () => { const { agent, token } = await authenticatedAgent(); prismaMocks.widget.findFirst.mockResolvedValue({ id: widget.id, userId: user.id, - config: { grid: { columns: 2 }, palette: 'lavender', renderFormat: 'iframe' }, + config: { grid: { columns: 4 }, palette: 'lavender', renderFormat: 'iframe' }, blocks: [], }); prismaMocks.block.create.mockResolvedValue({ @@ -120,7 +121,7 @@ it('creates a new block as a single-column item', async () => { widgetId: widget.id, position: 0, type: 'text', - config: { text: 'New block', layout: { x: 0, y: 0, width: 1, height: 1 } }, + config: { text: 'New block', layout: { x: 0, y: 0, width: 2, height: 2 } }, }); await agent @@ -132,7 +133,8 @@ it('creates a new block as a single-column item', async () => { expect(prismaMocks.block.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ - config: expect.objectContaining({ layout: { x: 0, y: 0, width: 1, height: 1 } }), + // Text blocks default to 2×1. + config: expect.objectContaining({ layout: { x: 0, y: 0, width: 2, height: 1 } }), }), }), ); @@ -223,17 +225,17 @@ it('deletes a widget through the protected route', async () => { expect(prismaMocks.widget.delete).toHaveBeenCalledWith({ where: { id: widget.id } }); }); -it('rejects adding a sixth block', async () => { +it('rejects adding a ninth block', async () => { const { agent, token } = await authenticatedAgent(); prismaMocks.widget.findFirst.mockResolvedValue({ id: widget.id, userId: user.id, - config: { grid: { columns: 1 }, palette: 'lavender', renderFormat: 'iframe' }, - blocks: Array.from({ length: 5 }, (_, index) => ({ + config: { grid: { columns: 4 }, palette: 'lavender', renderFormat: 'iframe' }, + blocks: Array.from({ length: 8 }, (_, index) => ({ id: `block-${index}`, position: index, type: 'text', - config: { text: `Block ${index}`, layout: { x: 0, y: index, width: 1, height: 1 } }, + config: { text: `Block ${index}`, layout: { x: 0, y: index, width: 2, height: 2 } }, })), }); @@ -241,7 +243,7 @@ it('rejects adding a sixth block', async () => { .post(`/api/widgets/${widget.id}/blocks`) .set('Authorization', `Bearer ${token}`) .send({ type: 'text', config: { text: 'Too many' } }) - .expect(400, { error: 'A widget can contain at most 5 blocks' }); + .expect(400, { error: 'A widget can contain at most 8 blocks' }); }); it('renders a public widget as an SVG image', async () => { @@ -313,7 +315,7 @@ it('inlines the GitHub avatar as a data URI in the public SVG image', async () = widgetId: widget.id, position: 0, type: 'github-stats', - config: { layout: { x: 0, y: 0, width: 1, height: 1 } }, + config: { layout: { x: 0, y: 0, width: 2, height: 2 } }, }, ], }; @@ -390,3 +392,128 @@ it('allows far more SVG image requests per IP than JSON widget requests', async delete process.env.VERCEL; } }); + +describe('grid layout rules', () => { + const blockAt = (id: string, layout: Record) => ({ + id, + widgetId: widget.id, + position: 0, + type: 'text', + config: { text: id, layout }, + }); + const widgetWith = (blocks: ReturnType[]) => ({ + ...widget, + config: { grid: { columns: 4 }, palette: 'lavender', renderFormat: 'iframe' }, + blocks, + }); + const reorder = async (layouts: { blockId: string; layout: Record }[]) => { + const { agent, token } = await authenticatedAgent(); + return agent + .put(`/api/widgets/${widget.id}/blocks`) + .set('Authorization', `Bearer ${token}`) + .send({ columns: 4, layouts }); + }; + + it('rejects sizes the block type does not support', async () => { + prismaMocks.widget.findFirst.mockResolvedValue( + widgetWith([blockAt('a', { x: 0, y: 0, width: 2, height: 2 })]), + ); + + const response = await reorder([{ blockId: 'a', layout: { x: 0, y: 0, width: 3, height: 3 } }]); + + expect(response.status).toBe(400); + expect(response.body.error).toBe("This block can't be 3×3"); + }); + + it('rejects layouts taller than 5 rows', async () => { + prismaMocks.widget.findFirst.mockResolvedValue( + widgetWith([blockAt('a', { x: 0, y: 0, width: 2, height: 2 })]), + ); + + const response = await reorder([{ blockId: 'a', layout: { x: 0, y: 4, width: 2, height: 2 } }]); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('A widget can be at most 5 rows tall'); + }); + + it('keeps migrated widgets that are already taller editable, but not taller still', async () => { + const tall = widgetWith([ + blockAt('a', { x: 0, y: 0, width: 2, height: 2 }), + blockAt('b', { x: 0, y: 4, width: 2, height: 2 }), + ]); + prismaMocks.widget.findFirst.mockResolvedValue(tall); + + const same = await reorder([ + { blockId: 'a', layout: { x: 2, y: 0, width: 2, height: 2 } }, + { blockId: 'b', layout: { x: 0, y: 4, width: 2, height: 2 } }, + ]); + expect(same.status).toBe(200); + + const taller = await reorder([ + { blockId: 'a', layout: { x: 0, y: 0, width: 2, height: 2 } }, + { blockId: 'b', layout: { x: 0, y: 6, width: 2, height: 2 } }, + ]); + expect(taller.status).toBe(400); + }); +}); + +it('places a new block in a smaller size when the default does not fit', async () => { + const { agent, token } = await authenticatedAgent(); + // Four 2×2 blocks fill rows 0–3; only the single 5th row is left. + prismaMocks.widget.findFirst.mockResolvedValue({ + ...widget, + config: { grid: { columns: 4 }, palette: 'lavender', renderFormat: 'iframe' }, + blocks: [0, 1, 2, 3].map((index) => ({ + id: `block-${index}`, + position: index, + type: 'text', + config: { + text: 'x', + layout: { x: (index % 2) * 2, y: Math.floor(index / 2) * 2, width: 2, height: 2 }, + }, + })), + }); + prismaMocks.block.create.mockImplementation(async ({ data }: { data: unknown }) => ({ + id: 'new', + ...(data as object), + })); + + await agent + .post(`/api/widgets/${widget.id}/blocks`) + .set('Authorization', `Bearer ${token}`) + .send({ type: 'github-stats', config: {} }) + .expect(201); + + expect(prismaMocks.block.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + config: expect.objectContaining({ layout: { x: 0, y: 4, width: 4, height: 1 } }), + }), + }), + ); +}); + +it('saves layouts for up to 8 blocks', async () => { + const { agent, token } = await authenticatedAgent(); + const blocks = Array.from({ length: 8 }, (_, index) => ({ + id: `block-${index}`, + widgetId: widget.id, + position: index, + type: 'text', + config: { text: 'x', layout: { x: index % 4, y: Math.floor(index / 4), width: 1, height: 1 } }, + })); + prismaMocks.widget.findFirst.mockResolvedValue({ + ...widget, + config: { grid: { columns: 4 }, palette: 'lavender', renderFormat: 'iframe' }, + blocks, + }); + + await agent + .put(`/api/widgets/${widget.id}/blocks`) + .set('Authorization', `Bearer ${token}`) + .send({ + columns: 4, + layouts: blocks.map((block) => ({ blockId: block.id, layout: block.config.layout })), + }) + .expect(200); +}); diff --git a/server/src/widgets/registry.ts b/server/src/widgets/registry.ts index 3c7638d..07a37d4 100644 --- a/server/src/widgets/registry.ts +++ b/server/src/widgets/registry.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; +import { DEFAULT_BLOCK_SIZE } from '@shared/widget/blockSizes.js'; import { MAX_BLOCK_HEIGHT, MAX_GRID_COLUMNS } from '@shared/widget/geometry.js'; export const BLOCK_TYPES = [ @@ -21,7 +22,9 @@ export type PaletteId = (typeof PALETTE_IDS)[number]; export const PALETTE_MODES = ['light', 'dark'] as const; export type PaletteMode = (typeof PALETTE_MODES)[number]; -export const MAX_WIDGET_BLOCKS = 5; +// Free plan limit. Planned tiers (not implemented): paid 16, special accounts 20 — see +// docs/design/grid-and-blocks.md. +export const MAX_WIDGET_BLOCKS = 8; export { MAX_GRID_COLUMNS }; export const blockTypeSchema = z.enum(BLOCK_TYPES); @@ -49,7 +52,7 @@ export type BlockLayout = z.infer; const usernameSchema = z.string().trim().max(100).optional(); const sharedBlockFields = { - layout: blockLayoutSchema.default({ x: 0, y: 0, width: 1, height: 1 }), + layout: blockLayoutSchema.default({ x: 0, y: 0, ...DEFAULT_BLOCK_SIZE }), }; const blockSchemas: Record = {