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
13 changes: 12 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
# Database
DATABASE_URL="postgresql://widget_user:widget_pass@localhost:5432/widget_db?schema=public"

# JWT
JWT_SECRET="change-me"
JWT_REFRESH_SECRET="change-me-too"
JWT_ACCESS_EXPIRES_IN="15m"
JWT_REFRESH_EXPIRES_IN="30d"

# Auth
AUTH_REFRESH_DAYS="30"

# Local URL
CLIENT_URL="http://localhost:5173"

# Yandex OAuth
YANDEX_CLIENT_ID=""
YANDEX_CLIENT_SECRET=""
YANDEX_REDIRECT_URI="http://localhost:4000/api/auth/yandex/callback"
GITHUB_TOKEN=""

# GitHub API
GITHUB_TOKEN=
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ jobs:

- name: Generate Prisma client
run: npm run prisma:generate -w server
env:
DATABASE_URL: postgresql://ci:ci@localhost:5432/github_stats?schema=public

- name: Check formatting
run: npm run format:check
Expand Down
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Changelog

All notable changes to this project are documented here.

## [0.2.0] - 2026-08-02

### Added

- Added a widget builder with reusable GitHub, LeetCode, and text blocks.
- Added preset-based widget creation with configurable palettes, dimensions, and grid layouts.
- Added drag-and-drop block placement and resize controls in the desktop editor.
- Added live GitHub and LeetCode statistics previews with loading and fallback states.
- Added public widget pages and embeddable widget routes.
- Added GitHub API token support and rate-limit handling for statistics rendering.
- Added implementation prompts for infrastructure, auth, builder, and render phases.

### Changed

- Made public widget grids use square cells and preserve the configured widget width.
- Simplified public widget pages to render only the widget canvas.
- Changed generated slugs to describe the actual block types instead of the selected preset.
- Restricted the widget editor to desktop-sized screens and improved grid interactions.

### Fixed

- Restored reliable block placement, resizing, and layout persistence.
- Fixed LeetCode queries using unsupported GraphQL fields.
- Improved empty username, loading, and API error handling in widget previews.
3 changes: 3 additions & 0 deletions api/blocks/[id].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { createApp } from '../../server/dist/src/app.js';

export default createApp();
3 changes: 3 additions & 0 deletions api/public/widgets/[slug].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { createApp } from '../../../server/dist/src/app.js';

export default createApp();
3 changes: 3 additions & 0 deletions api/widgets/[id].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { createApp } from '../../server/dist/src/app.js';

export default createApp();
3 changes: 3 additions & 0 deletions api/widgets/[widgetId]/blocks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { createApp } from '../../../../server/dist/src/app.js';

export default createApp();
3 changes: 3 additions & 0 deletions api/widgets/[widgetId]/blocks/reorder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { createApp } from '../../../../server/dist/src/app.js';

export default createApp();
3 changes: 3 additions & 0 deletions api/widgets/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { createApp } from '../../server/dist/src/app.js';

export default createApp();
2 changes: 1 addition & 1 deletion client/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "0.1.0",
"version": "0.2.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
14 changes: 13 additions & 1 deletion client/src/app/App.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
align-items: center;
position: relative;
min-height: 100vh;
overflow: hidden;
overflow-x: hidden;
padding: 24px;
background:
radial-gradient(
Expand Down Expand Up @@ -72,6 +72,11 @@
gap: 1rem;
}

.authorizedContent {
flex: 1;
min-width: 0;
}

.landingLayout {
width: 100%;
position: relative;
Expand All @@ -92,6 +97,13 @@
max-width: 1180px;
}

.publicRoute {
width: 100%;
height: 100%;
position: relative;
z-index: 1;
}

@media (max-width: 920px) {
.appShell {
padding: 14px;
Expand Down
171 changes: 141 additions & 30 deletions client/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,20 @@ import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
import { useCallback, useEffect, useState, type MouseEvent } from 'react';

import { useAuthStore } from '@/features/auth';
import type { WidgetCardData } from '@/entities/widget';
import type { WidgetCardData, Widget } from '@/entities/widget';
import { AuthPage, type AuthTab } from '@/pages/auth';
import { LandingHeader, LandingPage } from '@/pages/landing';
import { WidgetsGalleryPage } from '@/pages/widgets-gallery';
import { API_BASE_URL } from '@/shared/api';
import { widgets } from '@/shared/locale/content';
import { PublicWidgetPage } from '@/pages/public-widget';
import { WidgetEditorPage } from '@/pages/widget-editor';
import {
createWidget,
deleteWidget,
getPublicWidgetUrl,
listWidgets,
API_BASE_URL,
type CreateWidgetInput,
} from '@/shared/api';
import { AuthTransitionLoader } from '@/shared/ui/auth-transition-loader/AuthTransitionLoader';
import {
APP_LOCALE_STORAGE_KEY,
Expand All @@ -22,15 +30,44 @@ import { ThemeReveal, type ThemeRevealState } from '@/shared/ui/theme-reveal';
import styles from '@/app/App.module.css';
import '@/app/Theme.css';

type AppRoute = 'landing' | 'auth' | 'dashboard' | 'callback';
type AppRoute = 'landing' | 'auth' | 'dashboard' | 'editor' | 'public' | 'callback';

const getRoute = (): AppRoute => {
if (window.location.pathname === '/dashboard') return 'dashboard';
if (/^\/widgets\/[^/]+$/.test(window.location.pathname)) return 'editor';
if (/^\/w\/[^/]+$/.test(window.location.pathname)) return 'public';
if (window.location.pathname === '/auth/callback') return 'callback';
if (['/auth', '/login', '/register'].includes(window.location.pathname)) return 'auth';
return 'landing';
};

const getRouteParam = (prefix: string) => window.location.pathname.slice(prefix.length) || null;

const toCardData = (widget: Widget): WidgetCardData => {
const hasGithub = widget.blocks.some((block) => block.type.startsWith('github'));
const hasLeetcode = widget.blocks.some((block) => block.type.startsWith('leetcode'));
const source =
hasGithub && hasLeetcode ? 'GitHub + LeetCode' : hasLeetcode ? 'LeetCode' : 'GitHub';
const metric = widget.blocks.some((block) => block.type === 'leetcode-stats')
? 'LC'
: widget.blocks.some((block) => block.type === 'github-langs')
? 'TS'
: 'GH';
return {
id: widget.id,
title: widget.title,
slug: widget.slug,
source,
metric,
accent: widget.config?.palette ?? 'lavender',
paletteMode: widget.config?.paletteMode ?? 'auto',
public: widget.public,
width: widget.width,
height: widget.height,
updatedAt: widget.updatedAt,
};
};

const getAuthTab = (): AuthTab => (window.location.pathname === '/register' ? 'signup' : 'signin');

const getOAuthToken = () => {
Expand All @@ -47,7 +84,7 @@ export const App = () => {
);
const [route, setRoute] = useState<AppRoute>(getRoute);
const [authTab, setAuthTab] = useState<AuthTab>(getAuthTab);
const [visibleWidgets, setVisibleWidgets] = useState<WidgetCardData[]>(() => [...widgets]);
const [visibleWidgets, setVisibleWidgets] = useState<WidgetCardData[]>([]);
const [themeReveal, setThemeReveal] = useState<ThemeRevealState | null>(null);
const [isLocaleTransitioning, setLocaleTransitioning] = useState(false);
const [isBootstrapped, setBootstrapped] = useState(false);
Expand Down Expand Up @@ -78,6 +115,10 @@ export const App = () => {

const bootstrap = async () => {
const oauthToken = getOAuthToken();
if (getRoute() === 'public') {
setBootstrapped(true);
return;
}
try {
if (oauthToken) {
await useAuthStore.getState().completeOAuth(oauthToken);
Expand All @@ -104,11 +145,26 @@ export const App = () => {
};
}, [navigate]);

useEffect(() => {
if (authStatus !== 'authenticated' || (route !== 'dashboard' && route !== 'editor')) return;
let cancelled = false;
void listWidgets()
.then((nextWidgets) => {
if (!cancelled) setVisibleWidgets(nextWidgets.map(toCardData));
})
.catch(() => {
if (!cancelled) setVisibleWidgets([]);
});
return () => {
cancelled = true;
};
}, [authStatus, route]);

useEffect(() => {
if (
isBootstrapped &&
!isLoggingOut &&
route === 'dashboard' &&
(route === 'dashboard' || route === 'editor') &&
authStatus === 'unauthenticated'
) {
window.location.replace('/login');
Expand Down Expand Up @@ -186,6 +242,23 @@ export const App = () => {
}
};

const handleCreateWidget = async (input: CreateWidgetInput) => {
const widget = await createWidget(input);
setVisibleWidgets((currentWidgets) => [toCardData(widget), ...currentWidgets]);
navigate(`/widgets/${widget.id}`);
};

const handleDeleteWidget = async (id: string) => {
await deleteWidget(id);
setVisibleWidgets((currentWidgets) => currentWidgets.filter((widget) => widget.id !== id));
};

const handleCopyWidget = async (widget: WidgetCardData) => {
const src = getPublicWidgetUrl(widget.slug, true);
const code = `<iframe src="${src}" width="${widget.width}" height="${widget.height}" frameborder="0" style="display:block;border:0" loading="lazy"></iframe>`;
await navigator.clipboard?.writeText(code);
};

useEffect(() => {
localStorage.setItem(APP_THEME_STORAGE_KEY, theme);
document.documentElement.dataset.theme = theme;
Expand All @@ -196,8 +269,24 @@ export const App = () => {
}, [locale]);

const isAuthorized = authStatus === 'authenticated';
const isEmbedRoute =
route === 'public' && new URLSearchParams(window.location.search).get('embed') === '1';

useEffect(() => {
document.documentElement.dataset.embed = isEmbedRoute ? 'true' : 'false';
document.body.dataset.embed = isEmbedRoute ? 'true' : 'false';
}, [isEmbedRoute]);

if (isEmbedRoute)
return (
<ThemeProvider theme={theme}>
<PublicWidgetPage slug={getRouteParam('/w/') ?? ''} locale={locale} embed />
</ThemeProvider>
);
const isRedirectingFromPrivateRoute =
isBootstrapped && route === 'dashboard' && authStatus === 'unauthenticated';
isBootstrapped &&
(route === 'dashboard' || route === 'editor') &&
authStatus === 'unauthenticated';
const isAuthTransitioning =
!isBootstrapped || isLoggingOut || route === 'callback' || isRedirectingFromPrivateRoute;
const oauthError =
Expand All @@ -206,7 +295,36 @@ export const App = () => {
? 'Не удалось войти через Яндекс. Попробуйте ещё раз.'
: 'Yandex sign-in failed. Please try again.'
: null;
const username = authUser?.name || authUser?.email || (locale === 'ru' ? 'Профиль' : 'Profile');
const username =
authUser?.name || authUser?.email?.split('@')[0] || (locale === 'ru' ? 'Профиль' : 'Profile');
const isDashboardRoute = isAuthorized && route === 'dashboard';
const authorizedContent =
route === 'dashboard' ? (
<WidgetsGalleryPage
locale={locale}
theme={theme}
username={username}
widgets={visibleWidgets}
isLanguageLoading={isLocaleTransitioning}
onLocaleToggle={handleLocaleToggle}
onThemeToggle={handleThemeToggle}
onCreateWidget={handleCreateWidget}
onOpenWidget={(id) => navigate(`/widgets/${id}`)}
onOpenPreview={(widget) =>
navigate(widget.public ? `/w/${widget.slug}` : `/widgets/${widget.id}`)
}
onCopyWidget={handleCopyWidget}
onLogout={handleLogout}
onDeleteWidget={(id) => void handleDeleteWidget(id)}
/>
) : (
<WidgetEditorPage
widgetId={getRouteParam('/widgets/') ?? ''}
locale={locale}
onBack={() => navigate('/dashboard')}
onOpenPublic={(slug) => navigate(`/w/${slug}`)}
/>
);

return (
<ThemeProvider theme={theme}>
Expand Down Expand Up @@ -239,32 +357,19 @@ export const App = () => {
)}
<main
className={
route === 'dashboard' && isAuthorized
isDashboardRoute
? styles.authorizedShell
: route === 'auth' || route === 'dashboard'
: route === 'auth' || route === 'dashboard' || route === 'editor'
? styles.authRoute
: styles.landingLayout
: route === 'public'
? styles.publicRoute
: styles.landingLayout
}
>
{route === 'dashboard' ? (
isAuthorized ? (
<WidgetsGalleryPage
locale={locale}
theme={theme}
username={username}
widgets={visibleWidgets}
isLanguageLoading={isLocaleTransitioning}
onLocaleToggle={handleLocaleToggle}
onThemeToggle={handleThemeToggle}
onCreateWidget={() => undefined}
onLogout={handleLogout}
onDeleteWidget={(title) =>
setVisibleWidgets((currentWidgets) =>
currentWidgets.filter((currentWidget) => currentWidget.title !== title),
)
}
/>
) : null
{isDashboardRoute ? (
authorizedContent
) : route === 'editor' && isAuthorized ? (
<div className={styles.authorizedContent}>{authorizedContent}</div>
) : route === 'auth' ? (
<AuthPage
authTab={authTab}
Expand All @@ -275,6 +380,12 @@ export const App = () => {
onSubmit={handleAuthSubmit}
onYandexAuth={() => window.location.assign(`${API_BASE_URL}/auth/yandex`)}
/>
) : route === 'public' ? (
<PublicWidgetPage
slug={getRouteParam('/w/') ?? ''}
locale={locale}
embed={isEmbedRoute}
/>
) : (
<LandingPage
locale={locale}
Expand Down
Loading
Loading