diff --git a/.env.example b/.env.example index c639cf0..a0815c6 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8f6bd3..851bb72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1f0832e --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/api/blocks/[id].ts b/api/blocks/[id].ts new file mode 100644 index 0000000..4e6a060 --- /dev/null +++ b/api/blocks/[id].ts @@ -0,0 +1,3 @@ +import { createApp } from '../../server/dist/src/app.js'; + +export default createApp(); diff --git a/api/public/widgets/[slug].ts b/api/public/widgets/[slug].ts new file mode 100644 index 0000000..9207051 --- /dev/null +++ b/api/public/widgets/[slug].ts @@ -0,0 +1,3 @@ +import { createApp } from '../../../server/dist/src/app.js'; + +export default createApp(); diff --git a/api/widgets/[id].ts b/api/widgets/[id].ts new file mode 100644 index 0000000..4e6a060 --- /dev/null +++ b/api/widgets/[id].ts @@ -0,0 +1,3 @@ +import { createApp } from '../../server/dist/src/app.js'; + +export default createApp(); diff --git a/api/widgets/[widgetId]/blocks/index.ts b/api/widgets/[widgetId]/blocks/index.ts new file mode 100644 index 0000000..19f3564 --- /dev/null +++ b/api/widgets/[widgetId]/blocks/index.ts @@ -0,0 +1,3 @@ +import { createApp } from '../../../../server/dist/src/app.js'; + +export default createApp(); diff --git a/api/widgets/[widgetId]/blocks/reorder.ts b/api/widgets/[widgetId]/blocks/reorder.ts new file mode 100644 index 0000000..19f3564 --- /dev/null +++ b/api/widgets/[widgetId]/blocks/reorder.ts @@ -0,0 +1,3 @@ +import { createApp } from '../../../../server/dist/src/app.js'; + +export default createApp(); diff --git a/api/widgets/index.ts b/api/widgets/index.ts new file mode 100644 index 0000000..4e6a060 --- /dev/null +++ b/api/widgets/index.ts @@ -0,0 +1,3 @@ +import { createApp } from '../../server/dist/src/app.js'; + +export default createApp(); diff --git a/client/package.json b/client/package.json index bcb289f..ca5488e 100644 --- a/client/package.json +++ b/client/package.json @@ -1,7 +1,7 @@ { "name": "client", "private": true, - "version": "0.1.0", + "version": "0.2.0", "type": "module", "scripts": { "dev": "vite", diff --git a/client/src/app/App.module.css b/client/src/app/App.module.css index bdda328..c966c74 100644 --- a/client/src/app/App.module.css +++ b/client/src/app/App.module.css @@ -5,7 +5,7 @@ align-items: center; position: relative; min-height: 100vh; - overflow: hidden; + overflow-x: hidden; padding: 24px; background: radial-gradient( @@ -72,6 +72,11 @@ gap: 1rem; } +.authorizedContent { + flex: 1; + min-width: 0; +} + .landingLayout { width: 100%; position: relative; @@ -92,6 +97,13 @@ max-width: 1180px; } +.publicRoute { + width: 100%; + height: 100%; + position: relative; + z-index: 1; +} + @media (max-width: 920px) { .appShell { padding: 14px; diff --git a/client/src/app/App.tsx b/client/src/app/App.tsx index ca0e866..390286f 100644 --- a/client/src/app/App.tsx +++ b/client/src/app/App.tsx @@ -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, @@ -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 = () => { @@ -47,7 +84,7 @@ export const App = () => { ); const [route, setRoute] = useState(getRoute); const [authTab, setAuthTab] = useState(getAuthTab); - const [visibleWidgets, setVisibleWidgets] = useState(() => [...widgets]); + const [visibleWidgets, setVisibleWidgets] = useState([]); const [themeReveal, setThemeReveal] = useState(null); const [isLocaleTransitioning, setLocaleTransitioning] = useState(false); const [isBootstrapped, setBootstrapped] = useState(false); @@ -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); @@ -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'); @@ -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 = ``; + await navigator.clipboard?.writeText(code); + }; + useEffect(() => { localStorage.setItem(APP_THEME_STORAGE_KEY, theme); document.documentElement.dataset.theme = theme; @@ -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 ( + + + + ); const isRedirectingFromPrivateRoute = - isBootstrapped && route === 'dashboard' && authStatus === 'unauthenticated'; + isBootstrapped && + (route === 'dashboard' || route === 'editor') && + authStatus === 'unauthenticated'; const isAuthTransitioning = !isBootstrapped || isLoggingOut || route === 'callback' || isRedirectingFromPrivateRoute; const oauthError = @@ -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' ? ( + navigate(`/widgets/${id}`)} + onOpenPreview={(widget) => + navigate(widget.public ? `/w/${widget.slug}` : `/widgets/${widget.id}`) + } + onCopyWidget={handleCopyWidget} + onLogout={handleLogout} + onDeleteWidget={(id) => void handleDeleteWidget(id)} + /> + ) : ( + navigate('/dashboard')} + onOpenPublic={(slug) => navigate(`/w/${slug}`)} + /> + ); return ( @@ -239,32 +357,19 @@ export const App = () => { )}
- {route === 'dashboard' ? ( - isAuthorized ? ( - undefined} - onLogout={handleLogout} - onDeleteWidget={(title) => - setVisibleWidgets((currentWidgets) => - currentWidgets.filter((currentWidget) => currentWidget.title !== title), - ) - } - /> - ) : null + {isDashboardRoute ? ( + authorizedContent + ) : route === 'editor' && isAuthorized ? ( +
{authorizedContent}
) : route === 'auth' ? ( { onSubmit={handleAuthSubmit} onYandexAuth={() => window.location.assign(`${API_BASE_URL}/auth/yandex`)} /> + ) : route === 'public' ? ( + ) : ( => { + if (type === 'text') return { text: 'Build something worth sharing.', align: 'left' }; + if (type === 'github-stats') + return { showRepositories: true, showFollowers: true, showFollowing: true }; + if (type === 'github-langs') return { limit: 5 }; + return { showRanking: true, showContestRating: true }; +}; + +export const getPreset = (id: string | undefined) => presets.find((preset) => preset.id === id); + +export type PaletteTokens = { accent: string; soft: string; ink: string; surface: string }; + +export const paletteTokens: Record = { + lavender: { + light: { accent: '#8f71e8', soft: '#eee8ff', ink: '#27213d', surface: '#fbf9ff' }, + dark: { accent: '#bda9ff', soft: '#30274f', ink: '#f4efff', surface: '#191526' }, + }, + midnight: { + light: { accent: '#6075c9', soft: '#e4eaff', ink: '#17213d', surface: '#f7f9ff' }, + dark: { accent: '#91a4ff', soft: '#263258', ink: '#eef1ff', surface: '#11172b' }, + }, + mint: { + light: { accent: '#2caa8a', soft: '#ddf7ee', ink: '#143a31', surface: '#f7fffc' }, + dark: { accent: '#73d9b8', soft: '#183d35', ink: '#e7fff7', surface: '#11221f' }, + }, + sunset: { + light: { accent: '#dc7657', soft: '#ffeadf', ink: '#47241a', surface: '#fffaf7' }, + dark: { accent: '#ff9e7a', soft: '#4a2b25', ink: '#fff0ea', surface: '#251714' }, + }, + cobalt: { + light: { accent: '#2868d3', soft: '#e5efff', ink: '#152e59', surface: '#f8fbff' }, + dark: { accent: '#72a9ff', soft: '#1d3868', ink: '#edf4ff', surface: '#101c32' }, + }, + paper: { + light: { accent: '#635f5a', soft: '#eee9e2', ink: '#302d29', surface: '#fffdf9' }, + dark: { accent: '#c9c1b8', soft: '#3a3733', ink: '#f7f1e8', surface: '#211f1d' }, + }, +}; diff --git a/client/src/entities/widget/model/types.ts b/client/src/entities/widget/model/types.ts new file mode 100644 index 0000000..54c9ee4 --- /dev/null +++ b/client/src/entities/widget/model/types.ts @@ -0,0 +1,72 @@ +export type SourceType = 'github' | 'leetcode'; +export type PaletteId = 'lavender' | 'midnight' | 'mint' | 'sunset' | 'cobalt' | 'paper'; +export type PaletteMode = 'light' | 'dark' | 'auto'; +export type BlockType = 'text' | 'github-stats' | 'github-langs' | 'leetcode-stats'; + +export type BlockLayout = { + x: number; + y: number; + width: number; + height: number; +}; + +export type WidgetSource = { username: string }; + +export type WidgetConfig = { + sources?: Partial>; + palette: PaletteId; + paletteMode: PaletteMode; + grid: { columns: number }; + renderFormat: 'iframe'; + presetId?: string; +}; + +export type WidgetBlock = { + id: string; + type: BlockType; + position: number; + config: Record; +}; + +export type Widget = { + id: string; + title: string; + slug: string; + width: number; + height: number; + public: boolean; + config: WidgetConfig; + createdAt: string; + updatedAt: string; + blocks: WidgetBlock[]; +}; + +export type WidgetCardData = { + id: string; + title: string; + slug: string; + source: string; + metric: string; + accent: PaletteId; + paletteMode: PaletteMode; + public: boolean; + width: number; + height: number; + updatedAt: string; +}; + +export type RenderedBlock = { + id: string; + type: BlockType; + position: number; + data?: unknown; + error?: string; +}; + +export type PublicWidgetResponse = { + widget: Widget; + rendered: { + blocks: RenderedBlock[]; + cacheTtlSeconds: number; + }; +}; diff --git a/client/src/entities/widget/ui/WidgetCanvas.module.css b/client/src/entities/widget/ui/WidgetCanvas.module.css new file mode 100644 index 0000000..6822c35 --- /dev/null +++ b/client/src/entities/widget/ui/WidgetCanvas.module.css @@ -0,0 +1,327 @@ +.canvas { + --widget-accent: var(--widget-light-accent); + --widget-soft: var(--widget-light-soft); + --widget-ink: var(--widget-light-ink); + --widget-surface: var(--widget-light-surface); + display: grid; + gap: 18px; + width: min(100%, var(--widget-width, 100%)); + max-width: 100%; + height: var(--widget-height, auto); + min-height: var(--widget-height, 340px); + margin-inline: auto; + container-type: inline-size; + padding: clamp(20px, 4vw, 34px); + overflow: hidden; + color: var(--widget-ink); + border: 1px solid color-mix(in srgb, var(--widget-accent) 25%, transparent); + border-radius: 28px; + background: + radial-gradient( + circle at 92% 2%, + color-mix(in srgb, var(--widget-accent) 22%, transparent), + transparent 32% + ), + linear-gradient(145deg, var(--widget-surface), var(--widget-soft)); + box-shadow: 0 22px 60px color-mix(in srgb, var(--widget-accent) 18%, transparent); +} + +.canvas[data-palette-mode='dark'] { + --widget-accent: var(--widget-dark-accent); + --widget-soft: var(--widget-dark-soft); + --widget-ink: var(--widget-dark-ink); + --widget-surface: var(--widget-dark-surface); +} + +.canvas[data-show-chrome='true'] { + grid-template-rows: auto minmax(0, 1fr) auto; +} + +.canvas[data-show-chrome='false'] { + grid-template-rows: minmax(0, 1fr); +} + +@media (prefers-color-scheme: dark) { + .canvas[data-palette-mode='auto'] { + --widget-accent: var(--widget-dark-accent); + --widget-soft: var(--widget-dark-soft); + --widget-ink: var(--widget-dark-ink); + --widget-surface: var(--widget-dark-surface); + } +} + +.canvasHeader, +.canvasFooter, +.blockTitleRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.canvasHeader, +.canvasFooter { + color: color-mix(in srgb, var(--widget-ink) 58%, transparent); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.brandDot { + width: 8px; + height: 8px; + margin-right: -4px; + border-radius: 50%; + background: var(--widget-accent); + box-shadow: 0 0 0 5px color-mix(in srgb, var(--widget-accent) 15%, transparent); +} + +.blocks { + display: grid; + grid-template-columns: repeat(var(--widget-columns, 1), minmax(0, 1fr)); + grid-auto-rows: minmax(0, 1fr); + gap: 18px; + align-content: start; + min-height: 0; + height: 100%; +} + +.blocks[data-columns='1'] { + grid-auto-rows: minmax(0, 1fr); +} + +.block { + container-type: inline-size; + min-width: 0; + overflow: hidden; + padding: clamp(10px, 4cqi, 20px); + border: 1px solid color-mix(in srgb, var(--widget-accent) 18%, transparent); + border-radius: 20px; + background: color-mix(in srgb, var(--widget-surface) 72%, var(--widget-soft)); + transition: + border-color 160ms ease, + transform 160ms ease, + box-shadow 160ms ease; +} + +.block:hover, +.selected { + border-color: color-mix(in srgb, var(--widget-accent) 66%, transparent); + box-shadow: 0 10px 30px color-mix(in srgb, var(--widget-accent) 12%, transparent); +} + +.selected { + transform: translateY(-1px); +} + +.blockHeading { + display: flex; + align-items: center; + gap: clamp(8px, 3cqi, 12px); +} + +.blockHeading > div:last-child { + display: grid; + min-width: 0; + gap: clamp(2px, 1cqi, 3px); +} + +.blockHeading span, +.blockTitleRow span, +.stat span, +.languageList, +.difficultyRow { + color: color-mix(in srgb, var(--widget-ink) 60%, transparent); + font-size: clamp(10px, 4.5cqi, 12px); +} + +.avatar { + display: grid; + width: clamp(24px, 16cqi, 42px); + height: clamp(24px, 16cqi, 42px); + place-items: center; + overflow: hidden; + border-radius: 14px; + background: + linear-gradient(135deg, color-mix(in srgb, var(--widget-accent) 70%, transparent), transparent), + color-mix(in srgb, var(--widget-accent) 22%, var(--widget-surface)); +} + +.avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.statsBlock, +.languageBlock { + display: grid; + gap: clamp(10px, 6cqi, 18px); +} + +.statsRow { + display: flex; + flex-wrap: wrap; + gap: clamp(10px, 8cqi, 22px); +} + +.stat { + display: grid; + gap: 3px; +} + +.stat strong { + font-size: clamp(14px, 10cqi, 30px); + letter-spacing: -0.06em; +} + +.languageBar { + display: flex; + align-items: stretch; + height: 10px; + overflow: hidden; + border-radius: 999px; + background: color-mix(in srgb, var(--widget-ink) 10%, transparent); +} + +.languageBar span { + display: block; + min-width: 0; + height: 100%; + background: var(--widget-accent); +} + +.languageList { + display: flex; + flex-wrap: wrap; + gap: clamp(6px, 4cqi, 9px) clamp(10px, 8cqi, 18px); +} + +.languageList span { + white-space: nowrap; +} + +.languageList i, +.difficultyRow i { + display: inline-block; + width: 7px; + height: 7px; + margin-right: 4px; + border-radius: 50%; + background: var(--widget-accent); +} + +.languageList b { + color: var(--widget-ink); +} + +.textBlock { + margin: 0; + font-size: clamp(16px, 12cqi, 34px); + font-weight: 800; + line-height: 1.12; + letter-spacing: -0.05em; +} + +.difficultyRow { + display: flex; + flex-wrap: wrap; + gap: clamp(6px, 3cqi, 8px) clamp(10px, 7cqi, 16px); +} + +.difficultyRow .easy { + background: #22a477; +} +.difficultyRow .medium { + background: #c88724; +} +.difficultyRow .hard { + background: #d45c71; +} + +.error { + margin: 0; + color: #a54352; + font-size: 13px; + line-height: 1.5; +} + +.previewState { + display: flex; + min-height: 76px; + align-items: center; + gap: 12px; + padding: 14px; + border: 1px dashed color-mix(in srgb, var(--widget-accent) 30%, transparent); + border-radius: 14px; + background: color-mix(in srgb, var(--widget-accent) 7%, transparent); +} + +.previewStateMark { + display: grid; + width: 32px; + height: 32px; + flex: 0 0 auto; + place-items: center; + color: var(--widget-accent); + border: 1px solid color-mix(in srgb, var(--widget-accent) 34%, transparent); + border-radius: 10px; + background: color-mix(in srgb, var(--widget-accent) 12%, transparent); + font-size: 13px; + font-weight: 800; +} + +.previewStateCopy { + display: grid; + min-width: 0; + gap: 4px; +} + +.previewStateCopy strong { + font-size: 13px; +} + +.previewStateCopy span { + overflow: hidden; + color: color-mix(in srgb, var(--widget-ink) 62%, transparent); + font-size: 11px; + line-height: 1.4; + text-overflow: ellipsis; + white-space: nowrap; +} + +.previewLoading .previewStateMark { + animation: preview-state-pulse 1.4s ease-in-out infinite; +} + +@keyframes preview-state-pulse { + 0%, + 100% { + opacity: 0.55; + transform: scale(0.94); + } + 50% { + opacity: 1; + transform: scale(1); + } +} + +.empty { + margin: 0; + padding: 42px 16px; + color: color-mix(in srgb, var(--widget-ink) 60%, transparent); + text-align: center; +} + +@media (max-width: 520px) { + .canvas { + padding: 18px; + border-radius: 22px; + } + + .canvasFooter { + display: grid; + gap: 4px; + } +} diff --git a/client/src/entities/widget/ui/WidgetCanvas.tsx b/client/src/entities/widget/ui/WidgetCanvas.tsx new file mode 100644 index 0000000..eddb4bf --- /dev/null +++ b/client/src/entities/widget/ui/WidgetCanvas.tsx @@ -0,0 +1,378 @@ +import type { CSSProperties, MouseEvent } from 'react'; + +import type { + BlockLayout, + BlockType, + PaletteId, + PaletteMode, + RenderedBlock, + WidgetBlock, +} from '@/entities/widget/model'; +import { paletteTokens } from '@/entities/widget/model'; +import styles from '@/entities/widget/ui/WidgetCanvas.module.css'; + +type WidgetCanvasProps = { + blocks: WidgetBlock[]; + palette: PaletteId; + paletteMode?: PaletteMode; + columns?: number; + width?: number; + height?: number; + renderedBlocks?: RenderedBlock[]; + interactive?: boolean; + selectedBlockId?: string; + onSelectBlock?: (id: string) => void; + locale?: 'ru' | 'en'; + showChrome?: boolean; +}; + +type WidgetLocale = 'ru' | 'en'; + +const sampleData: Record> = { + text: { text: 'Build something worth sharing.', align: 'left' }, + 'github-stats': { + username: 'octocat', + name: 'The Octocat', + publicRepositories: 42, + followers: 4_321, + following: 12, + }, + 'github-langs': { + languages: [ + { name: 'TypeScript', percentage: 54 }, + { name: 'JavaScript', percentage: 24 }, + { name: 'CSS', percentage: 14 }, + { name: 'Other', percentage: 8 }, + ], + }, + 'leetcode-stats': { + username: 'your-profile', + ranking: 18_240, + contestRating: 1_726, + solved: { all: 312, easy: 148, medium: 132, hard: 32 }, + }, +}; + +const renderedData = (block: WidgetBlock, renderedBlocks?: RenderedBlock[]) => + renderedBlocks?.find((rendered) => rendered.id === block.id); + +const githubLanguageColors: Record = { + assembly: '#6e4c13', + c: '#555555', + 'c#': '#178600', + 'c++': '#f34b7d', + css: '#663399', + dart: '#00b4ab', + go: '#00add8', + html: '#e34c26', + java: '#b07219', + javascript: '#f1e05a', + kotlin: '#a97bff', + lua: '#000080', + 'objective-c': '#438eff', + perl: '#0298c3', + php: '#4f5d95', + python: '#3572a5', + r: '#198ce7', + ruby: '#701516', + rust: '#dea584', + scala: '#c22d40', + shell: '#89e051', + svelte: '#ff3e00', + swift: '#f05138', + typescript: '#3178c6', + vue: '#41b883', +}; + +const languageColor = (name: string) => + githubLanguageColors[name.trim().toLowerCase()] ?? '#8b949e'; + +const PreviewState = ({ + locale, + source, + loading, + username, +}: { + locale: WidgetLocale; + source: 'github' | 'leetcode'; + loading: boolean; + username?: string; +}) => { + const sourceLabel = source === 'github' ? 'GitHub' : 'LeetCode'; + return ( +
+ + + + {loading + ? locale === 'ru' + ? 'Обновляем статистику' + : 'Updating statistics' + : locale === 'ru' + ? 'Добавьте username' + : 'Add a username'} + + + {loading + ? locale === 'ru' + ? `Проверяем профиль ${username}` + : `Checking ${username}'s profile` + : locale === 'ru' + ? `Укажите ${sourceLabel} username в настройках блока` + : `Add a ${sourceLabel} username in block settings`} + + +
+ ); +}; + +const getBlockLayout = (block: WidgetBlock): BlockLayout => { + const value = block.config.layout; + if (!value || typeof value !== 'object') return { x: 0, y: 0, width: 1, height: 1 }; + const layout = value as Partial; + return { + x: typeof layout.x === 'number' ? layout.x : 0, + y: typeof layout.y === 'number' ? layout.y : 0, + width: typeof layout.width === 'number' ? layout.width : 1, + height: typeof layout.height === 'number' ? layout.height : 1, + }; +}; + +const formatNumber = (value: number | undefined) => + value === undefined ? '—' : value.toLocaleString(); + +type BlockData = { + username?: string; + name?: string; + avatarUrl?: string; + publicRepositories?: number; + followers?: number; + following?: number; + ranking?: number | null; + contestRating?: number | null; + solved?: { all?: number; easy?: number; medium?: number; hard?: number }; + languages?: { name: string; percentage: number }[]; +}; + +const Stat = ({ label, value }: { label: string; value: string | number }) => ( +
+ {typeof value === 'number' ? formatNumber(value) : value} + {label} +
+); + +export const WidgetBlockContent = ({ + block, + rendered, + locale = 'en', +}: { + block: WidgetBlock; + rendered?: RenderedBlock; + locale?: WidgetLocale; +}) => { + if (rendered?.error) return

{rendered.error}

; + const source = block.type.startsWith('github') + ? 'github' + : block.type.startsWith('leetcode') + ? 'leetcode' + : null; + const username = typeof block.config.username === 'string' ? block.config.username.trim() : ''; + if (source && rendered?.data === undefined && !username) { + return ; + } + if (source && rendered?.data === undefined && !rendered) { + return ; + } + const data = (rendered?.data as BlockData | undefined) ?? (sampleData[block.type] as BlockData); + + if (block.type === 'text') { + const align = + block.config.align === 'center' || block.config.align === 'right' + ? block.config.align + : 'left'; + return ( +

+ {String(block.config.text || sampleData.text.text)} +

+ ); + } + + if (block.type === 'github-stats') { + return ( +
+
+ +
+ {String(data.name || 'GitHub profile')} + @{String(data.username || 'username')} +
+
+
+ {block.config.showRepositories !== false && ( + + )} + {block.config.showFollowers !== false && ( + + )} + {block.config.showFollowing !== false && ( + + )} +
+
+ ); + } + + if (block.type === 'github-langs') { + const languages = data.languages ?? []; + return ( +
+
+ Languages + top {languages.length || 4} +
+
+ {languages.map((language) => { + const color = languageColor(language.name); + return ( + + ); + })} +
+
+ {languages.map((language) => { + const color = languageColor(language.name); + return ( + + {language.name} {language.percentage}% + + ); + })} +
+
+ ); + } + + const solved = data.solved; + const hasLiveData = rendered?.data !== undefined; + return ( +
+
+ LeetCode profile + @{String(data.username || 'username')} +
+
+ + {block.config.showRanking !== false && ( + + )} + {block.config.showContestRating !== false && ( + + )} +
+
+ + Easy {solved?.easy ?? 148} + + + Medium {solved?.medium ?? 132} + + + Hard {solved?.hard ?? 32} + +
+
+ ); +}; + +export const WidgetCanvas = ({ + blocks, + palette, + paletteMode = 'auto', + columns = 1, + width, + height, + renderedBlocks, + interactive = false, + selectedBlockId, + onSelectBlock, + locale = 'en', + showChrome = true, +}: WidgetCanvasProps) => { + const tokens = paletteTokens[palette]; + const gridColumns = Math.max(1, Math.min(columns, 2)); + const style = { + '--widget-light-accent': tokens.light.accent, + '--widget-light-soft': tokens.light.soft, + '--widget-light-ink': tokens.light.ink, + '--widget-light-surface': tokens.light.surface, + '--widget-dark-accent': tokens.dark.accent, + '--widget-dark-soft': tokens.dark.soft, + '--widget-dark-ink': tokens.dark.ink, + '--widget-dark-surface': tokens.dark.surface, + '--widget-columns': gridColumns, + '--widget-width': width ? `${width}px` : undefined, + '--widget-height': height ? `${height}px` : undefined, + } as CSSProperties; + + const handleSelect = (event: MouseEvent, id: string) => { + if (!interactive || !onSelectBlock) return; + event.stopPropagation(); + onSelectBlock(id); + }; + + return ( +
+ {showChrome && ( +
+ + live widget preview +
+ )} +
+ {blocks.length === 0 && ( +

Add a block to start shaping your widget.

+ )} + {blocks.map((block) => ( +
handleSelect(event, block.id)} + > + +
+ ))} +
+ {showChrome && ( +
+ + {blocks.length} block{blocks.length === 1 ? '' : 's'} + + updates every 15 min +
+ )} +
+ ); +}; diff --git a/client/src/entities/widget/ui/WidgetCard.module.css b/client/src/entities/widget/ui/WidgetCard.module.css index 53e26cf..e689895 100644 --- a/client/src/entities/widget/ui/WidgetCard.module.css +++ b/client/src/entities/widget/ui/WidgetCard.module.css @@ -1,10 +1,13 @@ .card { - display: grid; - gap: 18px; + display: flex; + flex-direction: column; + height: 100%; + gap: 12px; min-width: 0; min-height: 0; + overflow: hidden; border-radius: 34px; - padding: 18px; + padding: 14px; border: 1px solid color-mix(in srgb, var(--line) 62%, transparent); background: linear-gradient(145deg, var(--glass-strong), var(--glass)); backdrop-filter: blur(24px) saturate(135%); @@ -12,8 +15,15 @@ } .preview { + position: relative; + --accent-color: var(--lavender); + --accent-soft: var(--lavender-soft); display: grid; - min-height: clamp(110px, 18vh, 160px); + flex: 1 1 auto; + width: 100%; + min-height: 0; + height: auto; + overflow: hidden; place-items: center; border: 1px solid var(--line); border-radius: 26px; @@ -22,19 +32,103 @@ rgba(255, 255, 255, 0.12); } -.preview span { +.previewFrameViewport { + position: absolute; + inset: 0; + display: flex; + justify-content: center; + align-items: center; + overflow: hidden; + place-items: center; + padding-block: 14px; + background: linear-gradient( + 100deg, + color-mix(in srgb, var(--muted) 12%, transparent) 20%, + color-mix(in srgb, var(--action) 28%, transparent) 45%, + color-mix(in srgb, var(--muted) 12%, transparent) 70% + ); + background-size: 220% 100%; + animation: card-skeleton-shimmer 1s linear infinite; +} + +.previewFrameViewportLoaded { + background: transparent; + animation: none; +} + +.previewFrame { + display: block; + flex: 0 0 auto; + opacity: 0; + border: 0; + pointer-events: none; + transform-origin: center; + transition: opacity 180ms ease; +} + +.previewFrameLoaded { + opacity: 1; +} + +.preview > span { font-size: 34px; font-weight: 850; letter-spacing: -0.04em; } +.preview > small { + align-self: end; + color: color-mix(in srgb, var(--accent-color) 72%, var(--text)); + font-size: 11px; + font-weight: 750; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.previewOverlay { + position: absolute; + inset: 0; + display: grid; + place-items: center; + padding: 16px; + color: var(--text); + border: 0; + background: linear-gradient( + 180deg, + transparent 20%, + color-mix(in srgb, #11101a 68%, transparent) + ); + cursor: pointer; + opacity: 0; + transition: opacity 160ms ease; +} + +.preview:hover .previewOverlay, +.preview:focus-within .previewOverlay { + opacity: 1; +} + +.previewOverlay span { + padding: 10px 16px; + border: 1px solid color-mix(in srgb, var(--text) 30%, transparent); + border-radius: 999px; + background: color-mix(in srgb, var(--glass-strong) 88%, transparent); + box-shadow: 0 10px 30px color-mix(in srgb, #000 24%, transparent); + font-size: 12px; + font-weight: 800; + letter-spacing: 0.04em; +} + .info { display: flex; + flex: 0 0 auto; + align-items: flex-start; justify-content: space-between; - margin-bottom: auto; + min-width: 0; } .meta { + height: 100%; display: flex; flex-direction: column; } @@ -100,18 +194,46 @@ .actions { display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: repeat(2, max-content); + grid-auto-rows: max-content; + flex: 0 0 auto; min-width: 0; - flex-wrap: wrap; justify-self: center; gap: 0.5rem; } +.actions button:last-child { + grid-column: 2 / 3; + grid-row: 2 / 3; +} + .actions :global(.g-button) { - min-width: 42px; + --action-button-size: var(--g-button-height, var(--_--height)); + aspect-ratio: 1; + width: var(--action-button-size); + min-width: var(--action-button-size); + max-width: var(--action-button-size); + height: var(--action-button-size); + min-height: var(--action-button-size); + max-height: var(--action-button-size); + flex: 0 0 var(--action-button-size); padding: 0; } +@media (max-width: 760px) { + .configureAction { + display: none; + } + + .actions { + grid-template-columns: max-content; + } + + .actions button:last-child { + grid-column: 1 / 2; + } +} + .modalContent { display: grid; gap: 16px; @@ -135,19 +257,3 @@ gap: 10px; margin-top: 8px; } - -.accentLavender { - --accent-color: var(--lavender); -} - -.accentMint { - --accent-color: var(--mint); -} - -.accentBlue { - --accent-color: var(--blue); -} - -.accentViolet { - --accent-color: #8f7cf6; -} diff --git a/client/src/entities/widget/ui/WidgetCard.tsx b/client/src/entities/widget/ui/WidgetCard.tsx index 79bb013..0aa834b 100644 --- a/client/src/entities/widget/ui/WidgetCard.tsx +++ b/client/src/entities/widget/ui/WidgetCard.tsx @@ -1,20 +1,18 @@ -import { Gear, ArrowRight, TrashBin, Copy } from '@gravity-ui/icons'; +import { Copy, Gear, TrashBin } from '@gravity-ui/icons'; import { Button, Card, Icon, Modal } from '@gravity-ui/uikit'; -import { useState } from 'react'; +import { useEffect, useRef, useState, type CSSProperties } from 'react'; +import { paletteTokens, type WidgetCardData } from '@/entities/widget/model'; +import { getPublicWidgetUrl } from '@/shared/api'; import styles from '@/entities/widget/ui/WidgetCard.module.css'; -export type WidgetCardData = { - title: string; - source: string; - metric: string; - accent: 'lavender' | 'mint' | 'blue' | 'violet'; -}; - export type WidgetCardLabels = { updated: string; open: string; configure: string; + copy: string; + published: string; + draft: string; remove: string; removeTitle: string; removeDescription: string; @@ -25,30 +23,108 @@ export type WidgetCardLabels = { type WidgetCardProps = { widget: WidgetCardData; labels: WidgetCardLabels; - onDelete: (title: string) => void; + onDelete: (id: string) => void; + onConfigure: (id: string) => void; + onOpenPreview: (widget: WidgetCardData) => void; + onCopy: (widget: WidgetCardData) => void; isLanguageLoading: boolean; }; -const accentClass = { - lavender: styles.accentLavender, - mint: styles.accentMint, - blue: styles.accentBlue, - violet: styles.accentViolet, -} as const; +const WidgetPreviewFrame = ({ widget }: { widget: WidgetCardData }) => { + const viewportRef = useRef(null); + const [isLoaded, setLoaded] = useState(false); + const [scale, setScale] = useState(1); + + useEffect(() => { + const viewport = viewportRef.current; + if (!viewport) return; + + const updateScale = () => { + const { paddingLeft, paddingRight, paddingTop, paddingBottom } = + window.getComputedStyle(viewport); + const width = + viewport.clientWidth - Number.parseFloat(paddingLeft) - Number.parseFloat(paddingRight); + const height = + viewport.clientHeight - Number.parseFloat(paddingTop) - Number.parseFloat(paddingBottom); + if (!width || !height) return; + setScale(Math.min(1, width / widget.width, height / widget.height)); + }; -export const WidgetCard = ({ widget, labels, onDelete, isLanguageLoading }: WidgetCardProps) => { + updateScale(); + const observer = new ResizeObserver(updateScale); + observer.observe(viewport); + return () => observer.disconnect(); + }, [widget.height, widget.width]); + + return ( +
+ `; + await navigator.clipboard?.writeText(code); + setCopied(true); + window.setTimeout(() => setCopied(false), 1600); + }; + + const guardLeave = () => { + onBack(); + }; + + if (isMobile) + return ( +
+
+ W +

{locale === 'ru' ? 'Конструктор доступен на desktop' : 'Desktop editor only'}

+

+ {locale === 'ru' + ? 'Откройте этот экран на компьютере' + : 'Open this editor on a desktop device'} +

+ + {locale === 'ru' + ? 'На большом экране удобнее настраивать сетку, размеры и данные блоков.' + : 'The grid, block sizes, and live data controls are optimized for a larger screen.'} + +
+
+ ); + if (isLoading) return
{t.loading}
; + if (!widget) + return ( +
+ {error || t.unavailable} +
+ ); + + const columns = widget.config.grid.columns; + const gridRows = gridRowsFor(widget); + const gridStyle = { + '--grid-cell-size': gridWidth + ? `${Math.max((gridWidth - GRID_GAP) / MAX_COLUMNS, 0)}px` + : undefined, + } as CSSProperties; + const palette = paletteTokens[widget.config.palette]; + const canvasStyle = { + '--widget-light-accent': palette.light.accent, + '--widget-light-soft': palette.light.soft, + '--widget-light-ink': palette.light.ink, + '--widget-light-surface': palette.light.surface, + '--widget-dark-accent': palette.dark.accent, + '--widget-dark-soft': palette.dark.soft, + '--widget-dark-ink': palette.dark.ink, + '--widget-dark-surface': palette.dark.surface, + '--widget-columns': MAX_COLUMNS, + } as CSSProperties; + + return ( +
+
+
+ +
+

{t.widgetSettings}

+

{widget.title}

+
+
+
+ + {isDirty ? t.unsaved : t.saved} + + {widget.public && ( + + )} + {widget.public && ( + + )} + {!widget.public && ( + + )} +
+
+ +
+ + +
+
+ canvas / {widget.slug} + + {columns} {t.columns.toLowerCase()} · {widget.width} × {widget.height} + +
+
+
+ + live widget preview +
+ {widget.blocks.length === 0 ? ( +

+ {locale === 'ru' + ? 'Добавьте первый блок слева.' + : 'Add your first block from the library.'} +

+ ) : ( +
+
+ {draggingBlockId && + Array.from({ length: gridRows * MAX_COLUMNS }, (_, index) => { + const x = index % MAX_COLUMNS; + const y = Math.floor(index / MAX_COLUMNS); + const isActive = dropCell?.x === x && dropCell.y === y; + return ( +
+ ); + })} + {widget.blocks.map((block) => ( + { + setSelectedBlockId(block.id); + setActivePanel('block'); + }} + onRemove={() => void handleRemoveBlock(block.id)} + removeLabel={t.removeBlock} + onPointerDown={handleBlockPointerDown} + onPointerMove={handleBlockPointerMove} + onPointerUp={handleBlockPointerUp} + onPointerCancel={handleBlockPointerCancel} + onResize={(width, height) => handleResizeBlock(block.id, width, height)} + dragging={draggingBlockId === block.id} + rendered={previewBlocks[block.id]} + locale={locale} + /> + ))} +
+
+ )} +
+ + {widget.blocks.length} {t.blocks.toLowerCase()} + + updates every 15 min +
+
+ {error && ( +

+ {error} +

+ )} +
+ + +
+
+ ); +}; + +const WidgetConfigPanel = ({ + widget, + locale, + onChange, +}: { + widget: Widget; + locale: Locale; + onChange: (updater: (current: Widget) => Widget) => void; +}) => { + const t = messages[locale]; + return ( +
+
+
+

{t.widgetSettings}

+

{t.settings}

+
+
+ +
+ + +
+
+ {t.columns} + {widget.config.grid.columns} / 2 + + {locale === 'ru' + ? 'Сетка всегда состоит из двух колонок.' + : 'The grid always uses two columns.'} + +
+
+ {t.palette} +
+ {(['light', 'dark', 'auto'] as PaletteMode[]).map((mode) => ( + + ))} +
+
+
+ {palettes.map((palette) => ( + + ))} +
+
+ ); +}; + +const BlockConfigPanel = ({ + block, + locale, + onChange, +}: { + block: WidgetBlock; + locale: Locale; + onChange: (patch: Record) => void; +}) => { + const t = messages[locale]; + const source = sourceForBlock(block.type); + const options = + block.type === 'github-stats' + ? [ + ['showRepositories', locale === 'ru' ? 'Репозитории' : 'Repositories'], + ['showFollowers', locale === 'ru' ? 'Подписчики' : 'Followers'], + ['showFollowing', locale === 'ru' ? 'Подписки' : 'Following'], + ] + : block.type === 'leetcode-stats' + ? [ + ['showRanking', locale === 'ru' ? 'Рейтинг' : 'Ranking'], + ['showContestRating', 'Contest rating'], + ] + : []; + return ( +
+
+
+

{t.blockSettings}

+

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

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