Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ Server imports must use `.js` extensions (NodeNext).
- **Block types are defined twice.** Server `widgets/registry.ts` (zod schemas, presets) and client `entities/widget/model/{types,registry}.ts` (labels, ru/en descriptions). Adding a block type touches both plus `statsService` and both canvases — 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.
- 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.
- Auth: short-lived access token in memory (Authorization: Bearer), rotating refresh token in an httpOnly cookie scoped to `/api/auth`, sessions stored hashed in `AuthSession`. Yandex OAuth returns the access token in the URL hash of `/auth/callback`. Accounts are keyed by provider id (`yandexId`), never linked by email; linking happens from `/account` (`/api/auth/yandex?intent=link`, the intent is stored with the OAuth state and the callback identifies the user by the refresh cookie).
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ All notable changes to this project are documented here.

### Added

- 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.
- Added 404 and 500 pages for unknown addresses, missing or unpublished widgets, failed widget loads, and unexpected rendering errors.
Expand All @@ -16,6 +17,7 @@ All notable changes to this project are documented here.

### Changed

- 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.
- Added client tests for widget rendering, scaling, editor layout rules and autosave, the API client token refresh, and the auth store.
- Removed the automatic palette mode; widgets are either light or dark, and existing auto widgets open as light.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Personal live widgets for GitHub, LeetCode, and other sources. Build a widget, c
## Features

- Personal widget gallery.
- GitHub, language, and LeetCode statistics blocks.
- GitHub stats, languages, activity heatmap, pull requests and status, and LeetCode blocks.
- Grid editor with resizing and live preview.
- Public pages and iframe embeds.
- Email and Yandex ID authentication.
Expand Down
2 changes: 1 addition & 1 deletion README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
## Возможности

- Галерея персональных виджетов.
- Блоки статистики GitHub, языков и LeetCode.
- Блоки GitHub: статистика, языки, карта активности, pull requests и статус; блок LeetCode.
- Редактор с сеткой, изменением размеров и live-preview.
- Публичные страницы и iframe-встраивание.
- Авторизация по email и через Yandex ID.
Expand Down
4 changes: 4 additions & 0 deletions client/src/entities/widget/lib/canvasStyle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
BLOCK_RADIUS,
CANVAS_RADIUS,
GRID_GAP,
HEATMAP_CELL,
HEATMAP_GAP,
MAX_GRID_COLUMNS,
TEXT_BLOCK_LINE_HEIGHT,
WIDGET_WIDTH,
Expand Down Expand Up @@ -56,6 +58,8 @@ export const canvasStyleVars = (
'--block-padding': `${BLOCK_PADDING}px`,
'--block-border': `${BLOCK_BORDER}px`,
'--block-radius': `${BLOCK_RADIUS}px`,
'--heatmap-cell': `${HEATMAP_CELL}px`,
'--heatmap-gap': `${HEATMAP_GAP}px`,
'--block-line-height': BLOCK_LINE_HEIGHT,
'--text-line-height': TEXT_BLOCK_LINE_HEIGHT,
'--color-easy': difficultyColors.easy,
Expand Down
30 changes: 30 additions & 0 deletions client/src/entities/widget/model/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ export const presets: PresetDefinition[] = [
source: 'github',
blockTypes: ['github-langs'],
},
{
id: 'github-activity',
label: 'GitHub Activity',
labelRu: 'Активность GitHub',
description: 'Commit streaks, a contribution heatmap and pull requests.',
descriptionRu: 'Серии коммитов, карта активности и pull requests.',
source: 'github',
blockTypes: ['github-commits', 'github-prs'],
},
{
id: 'leetcode-profile',
label: 'LeetCode Profile',
Expand Down Expand Up @@ -67,6 +76,24 @@ export const blockDefinitions: {
description: 'Language distribution by repository size.',
source: 'github',
},
{
type: 'github-commits',
label: 'GitHub Activity',
description: 'Commits this year, streaks and a contribution heatmap.',
source: 'github',
},
{
type: 'github-prs',
label: 'Pull Requests',
description: 'Opened, merged and open pull requests.',
source: 'github',
},
{
type: 'github-status',
label: 'GitHub Status',
description: 'Your current GitHub status emoji and message.',
source: 'github',
},
{
type: 'leetcode-stats',
label: 'LeetCode Profile',
Expand Down Expand Up @@ -95,6 +122,9 @@ export const defaultBlockConfig = (type: BlockType): Record<string, unknown> =>
if (type === 'github-stats')
return { showRepositories: true, showFollowers: true, showFollowing: true };
if (type === 'github-langs') return { limit: 5 };
if (type === 'github-commits') return { showStreak: true, showHeatmap: true };
if (type === 'github-prs') return { showBreakdown: true };
if (type === 'github-status') return {};
return { showRanking: true, showContestRating: true };
};

Expand Down
9 changes: 8 additions & 1 deletion client/src/entities/widget/model/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
export type SourceType = 'github' | 'leetcode';
export type PaletteId = 'lavender' | 'midnight' | 'mint' | 'sunset' | 'cobalt' | 'paper';
export type PaletteMode = 'light' | 'dark';
export type BlockType = 'text' | 'github-stats' | 'github-langs' | 'leetcode-stats';
export type BlockType =
| 'text'
| 'github-stats'
| 'github-langs'
| 'github-commits'
| 'github-prs'
| 'github-status'
| 'leetcode-stats';

export type BlockLayout = {
x: number;
Expand Down
73 changes: 73 additions & 0 deletions client/src/entities/widget/ui/WidgetCanvas.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,79 @@
background: var(--color-hard);
}

.heatmap {
display: flex;
gap: var(--heatmap-gap);
}

.heatmap > span {
display: grid;
grid-template-rows: repeat(7, var(--heatmap-cell));
gap: var(--heatmap-gap);
}

.heatmap i {
display: block;
width: var(--heatmap-cell);
height: var(--heatmap-cell);
border-radius: 2.5px;
background: var(--widget-accent);
}

.heatmap i[data-level='0'] {
background: var(--widget-ink);
}

.pr-merged {
background: var(--widget-accent) !important;
}

.pr-open {
background: color-mix(in srgb, var(--widget-accent) 45%, transparent) !important;
}

.pr-closed {
background: color-mix(in srgb, var(--widget-ink) 20%, transparent) !important;
}

/* Mirrors the SVG: emoji line, message (up to 3 lines), busy chip. */
.status {
display: grid;
justify-items: start;
gap: var(--block-heading-gap);
}

.statusEmoji {
font-size: var(--block-text);
line-height: var(--block-line-height);
}

.statusMessage {
display: -webkit-box;
margin: 0;
overflow: hidden;
font-size: var(--block-heading);
font-weight: 700;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
line-clamp: 3;
overflow-wrap: anywhere;
}

.statusBusy {
padding: 4px 10px;
color: var(--widget-accent);
border-radius: 999px;
background: color-mix(in srgb, var(--widget-accent) 14%, transparent);
font-size: var(--block-meta);
font-weight: 700;
}

.statusEmpty {
color: color-mix(in srgb, var(--widget-ink) 60%, transparent);
font-size: var(--block-meta);
}

.error {
margin: 0;
color: var(--color-error);
Expand Down
113 changes: 113 additions & 0 deletions client/src/entities/widget/ui/WidgetCanvas.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,116 @@ it('shows the empty state without blocks', () => {
render(<WidgetCanvas blocks={[]} palette="lavender" locale="ru" />);
expect(screen.getByText('Добавьте блок, чтобы начать.')).toBeInTheDocument();
});

describe('GitHub activity, pull request and status blocks', () => {
const block = (id: string, type: WidgetBlock['type'], config = {}, width = 1): WidgetBlock => ({
id,
type,
position: 0,
config: { username: 'octo', layout: { x: 0, y: 0, width, height: 1 }, ...config },
});

it('draws as many heatmap weeks as fit the block width', () => {
const levels = Array(371).fill(1);
const rendered = (id: string) => ({
id,
type: 'github-commits' as const,
position: 0,
data: { username: 'octo', commitsYear: 5, currentStreak: 1, longestStreak: 2, levels },
});
const { container, rerender } = render(
<WidgetCanvas
blocks={[block('narrow', 'github-commits')]}
palette="lavender"
renderedBlocks={[rendered('narrow')]}
/>,
);
expect(container.querySelectorAll('[data-level]')).toHaveLength(17 * 7);

rerender(
<WidgetCanvas
blocks={[block('wide', 'github-commits', {}, 2)]}
palette="lavender"
renderedBlocks={[rendered('wide')]}
/>,
);
// Full-width block: (510 + 3) / 13 → 39 weeks.
expect(container.querySelectorAll('[data-level]')).toHaveLength(39 * 7);
});

it('hides streaks and the heatmap when turned off', () => {
const { container } = render(
<WidgetCanvas
blocks={[block('c', 'github-commits', { showStreak: false, showHeatmap: false })]}
palette="lavender"
locale="ru"
renderedBlocks={[
{
id: 'c',
type: 'github-commits',
position: 0,
data: { username: 'octo', commitsYear: 5, levels: [1, 2, 3] },
},
]}
/>,
);
expect(screen.getByText('Коммиты')).toBeInTheDocument();
expect(screen.queryByText('Дней подряд')).not.toBeInTheDocument();
expect(container.querySelector('[data-level]')).toBeNull();
});

it('shows the pull request breakdown in percent', () => {
render(
<WidgetCanvas
blocks={[block('p', 'github-prs')]}
palette="lavender"
renderedBlocks={[
{
id: 'p',
type: 'github-prs',
position: 0,
data: { username: 'octo', total: 10, merged: 7, open: 1, closed: 2 },
},
]}
/>,
);
expect(screen.getByText('70%')).toBeInTheDocument();
expect(screen.getByText('Closed')).toBeInTheDocument();
});

it('shows the status or an empty state', () => {
const { rerender } = render(
<WidgetCanvas
blocks={[block('s', 'github-status')]}
palette="lavender"
renderedBlocks={[
{
id: 's',
type: 'github-status',
position: 0,
data: { username: 'octo', emoji: '🌴', message: 'On vacation', busy: true },
},
]}
/>,
);
expect(screen.getByText('🌴')).toBeInTheDocument();
expect(screen.getByText('On vacation')).toBeInTheDocument();
expect(screen.getByText('Busy')).toBeInTheDocument();

rerender(
<WidgetCanvas
blocks={[block('s', 'github-status')]}
palette="lavender"
renderedBlocks={[
{
id: 's',
type: 'github-status',
position: 0,
data: { username: 'octo', emoji: null, message: null, busy: false },
},
]}
/>,
);
expect(screen.getByText('No status set')).toBeInTheDocument();
});
});
Loading
Loading