From f80ca379d346907bd060fc11decfcacf127dfa98 Mon Sep 17 00:00:00 2001 From: TheGeniusOfEternity Date: Sat, 3 Oct 2026 18:15:37 +0300 Subject: [PATCH] feat(widgets): size-aware block variants - Per-type renderers in shared/widget/blocks/ chosen by size family (tiny 1x1, strip 2-3x1, wide 4x1, card, large); the previous designs become the card variant - New layouts: 1x1, 2x1 and 4x1 for every data block, 4x2 for the GitHub profile and LeetCode; text fits itself to any size - Shared parts: BigStat, SegmentBar, Avatar, fitFontSize, wrapText; compact 'add a username' state for small blocks - BLOCK_SIZES gains compact sizes (legacy sizes kept for migrated widgets); text defaults to 2x1; new blocks fall back to smaller sizes when the default does not fit - Fix: layout updates were capped at 5 blocks - Tests render every block in every allowed size --- .claude/skills/add-block-type/SKILL.md | 24 +- AGENTS.md | 2 +- CHANGELOG.md | 2 + client/src/shared/locale/content.ts | 8 +- docs/design/grid-and-blocks.md | 38 +- server/src/controllers/widgetController.ts | 5 +- server/src/services/widgetService.ts | 13 +- server/src/shared/widget/BlockContent.tsx | 574 +++--------------- server/src/shared/widget/blockSizes.test.ts | 38 +- server/src/shared/widget/blockSizes.ts | 53 +- .../src/shared/widget/blockVariants.test.tsx | 79 +++ server/src/shared/widget/blocks/context.ts | 24 + .../shared/widget/blocks/githubCommits.tsx | 163 +++++ .../src/shared/widget/blocks/githubLangs.tsx | 226 +++++++ server/src/shared/widget/blocks/githubPrs.tsx | 205 +++++++ .../src/shared/widget/blocks/githubStats.tsx | 337 ++++++++++ .../src/shared/widget/blocks/githubStatus.tsx | 172 ++++++ server/src/shared/widget/blocks/index.ts | 20 + server/src/shared/widget/blocks/leetcode.tsx | 235 +++++++ server/src/shared/widget/blocks/parts.tsx | 203 +++++++ server/src/shared/widget/blocks/text.tsx | 41 ++ server/src/widgets.test.ts | 66 +- 22 files changed, 1995 insertions(+), 533 deletions(-) create mode 100644 server/src/shared/widget/blockVariants.test.tsx create mode 100644 server/src/shared/widget/blocks/context.ts create mode 100644 server/src/shared/widget/blocks/githubCommits.tsx create mode 100644 server/src/shared/widget/blocks/githubLangs.tsx create mode 100644 server/src/shared/widget/blocks/githubPrs.tsx create mode 100644 server/src/shared/widget/blocks/githubStats.tsx create mode 100644 server/src/shared/widget/blocks/githubStatus.tsx create mode 100644 server/src/shared/widget/blocks/index.ts create mode 100644 server/src/shared/widget/blocks/leetcode.tsx create mode 100644 server/src/shared/widget/blocks/parts.tsx create mode 100644 server/src/shared/widget/blocks/text.tsx 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 8f92d7d..8f9ef0b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,7 +73,7 @@ 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. - **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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 86261db..679e805 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. diff --git a/client/src/shared/locale/content.ts b/client/src/shared/locale/content.ts index ceec6d6..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: 'Сохранено', @@ -262,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', diff --git a/docs/design/grid-and-blocks.md b/docs/design/grid-and-blocks.md index 708c066..dbf6e41 100644 --- a/docs/design/grid-and-blocks.md +++ b/docs/design/grid-and-blocks.md @@ -28,19 +28,31 @@ A block declares the sizes it supports (`server/src/shared/widget/blockSizes.ts` 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. -Planned variant matrix (✓ = supported): - -| Block | 1×1 | 2×1 | 2×2 | 4×1 | 4×2 | other | -| -------------- | ------------------ | ----------------------- | --------------------------- | ----------------------------- | ---------------------------- | --------------------------------- | -| text | short text | ✓ | ✓ | ✓ | ✓ | 3×1, 3×2, 4×3 (font fits the box) | -| github-stats | avatar + followers | avatar, name, @user | card: avatar, name, 3 stats | identity left, stats right | big avatar, name, bio, stats | | -| github-langs | top language + % | bar + top-3 legend | bar + list (≤ 6) | wide bar + inline legend | bar + 2-column list (≤ 8) | | -| github-commits | commits + streak | commits + streak | stats + ~19-week heatmap | caption + 40-week heatmap | stats + 40-week heatmap | | -| github-prs | merged % | total + merged | stats + bar + legend | stats + bar inline | — | | -| github-status | emoji (+ busy dot) | emoji + 2-line message | emoji, message, busy | emoji + one-line message | — | | -| leetcode-stats | solved | solved + difficulty bar | title, 3 stats, difficulty | inline stats + difficulty bar | full + per-difficulty bars | | - -Defaults: new blocks are 2×2 (text 2×1), placed at the first free spot. +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 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/widgetService.ts b/server/src/services/widgetService.ts index fa09791..2d4152e 100644 --- a/server/src/services/widgetService.ts +++ b/server/src/services/widgetService.ts @@ -2,7 +2,11 @@ import { randomBytes } from 'node:crypto'; import type { Prisma } from '@prisma/client'; -import { DEFAULT_BLOCK_SIZE, isAllowedBlockSize } from '@shared/widget/blockSizes.js'; +import { + DEFAULT_BLOCK_SIZE, + isAllowedBlockSize, + placementSizes, +} from '@shared/widget/blockSizes.js'; import { MAX_GRID_ROWS, findFreeSpot, @@ -283,9 +287,10 @@ 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 spot = findFreeSpot(widget.blocks.map(layoutFromBlock), DEFAULT_BLOCK_SIZE, { - columns: MAX_GRID_COLUMNS, - }); + 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([ 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 index 150e143..5a60599 100644 --- a/server/src/shared/widget/blockSizes.test.ts +++ b/server/src/shared/widget/blockSizes.test.ts @@ -13,14 +13,40 @@ it('allows only the sizes a block declares', () => { }); it('snaps a dragged size to the nearest allowed one', () => { - expect(snapToAllowedSize('text', 3, 2)).toEqual({ width: 2, height: 2 }); - expect(snapToAllowedSize('text', 4, 3)).toEqual({ width: 4, height: 2 }); - expect(snapToAllowedSize('text', 4, 4)).toEqual({ width: 4, height: 4 }); - expect(snapToAllowedSize('text', 1, 1)).toEqual({ width: 2, height: 2 }); + 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('text', 4, 2, { maxWidth: 2 })).toEqual({ width: 2, height: 2 }); - expect(snapToAllowedSize('text', 2, 4, { maxHeight: 3 })).toEqual({ width: 2, height: 2 }); + 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 index 688fc6a..cf0428f 100644 --- a/server/src/shared/widget/blockSizes.ts +++ b/server/src/shared/widget/blockSizes.ts @@ -6,21 +6,58 @@ export type BlockSize = { width: number; height: number }; const size = (width: number, height: number): BlockSize => ({ width, height }); -// The 2-column grid's sizes, doubled: what every block could do before size variants. +// 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: LEGACY_SIZES, - 'github-stats': LEGACY_SIZES, - 'github-langs': LEGACY_SIZES, - 'github-commits': LEGACY_SIZES, - 'github-prs': LEGACY_SIZES, - 'github-status': LEGACY_SIZES, - 'leetcode-stats': LEGACY_SIZES, + 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) => 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/widgets.test.ts b/server/src/widgets.test.ts index 881d790..95ff820 100644 --- a/server/src/widgets.test.ts +++ b/server/src/widgets.test.ts @@ -133,7 +133,8 @@ it('places a new block at the first free spot with the default size', async () = expect(prismaMocks.block.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ - config: expect.objectContaining({ layout: { x: 0, y: 0, width: 2, height: 2 } }), + // Text blocks default to 2×1. + config: expect.objectContaining({ layout: { x: 0, y: 0, width: 2, height: 1 } }), }), }), ); @@ -314,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 } }, }, ], }; @@ -455,3 +456,64 @@ describe('grid layout rules', () => { 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); +});