From 19e2c41e526544569e32cf745b33bab0c8e21d76 Mon Sep 17 00:00:00 2001 From: TheGeniusOfEternity Date: Sat, 1 Aug 2026 19:29:08 +0300 Subject: [PATCH 1/9] feat(widgets): add widget builder and public embeds --- .env.example | 10 +- api/blocks/[id].ts | 3 + api/public/widgets/[slug].ts | 3 + api/widgets/[id].ts | 3 + api/widgets/[widgetId]/blocks/index.ts | 3 + api/widgets/[widgetId]/blocks/reorder.ts | 3 + api/widgets/index.ts | 3 + client/package.json | 1 + client/src/app/App.module.css | 13 +- client/src/app/App.tsx | 145 ++- client/src/entities/widget/index.ts | 8 +- client/src/entities/widget/model/index.ts | 2 + client/src/entities/widget/model/registry.ts | 120 +++ client/src/entities/widget/model/types.ts | 72 ++ .../widget/ui/WidgetCanvas.module.css | 256 +++++ .../src/entities/widget/ui/WidgetCanvas.tsx | 261 +++++ .../entities/widget/ui/WidgetCard.module.css | 29 +- client/src/entities/widget/ui/WidgetCard.tsx | 80 +- client/src/pages/public-widget/index.ts | 1 + .../ui/PublicWidgetPage.module.css | 68 ++ .../public-widget/ui/PublicWidgetPage.tsx | 86 ++ client/src/pages/widget-editor/index.ts | 1 + .../ui/WidgetEditorPage.module.css | 621 +++++++++++ .../widget-editor/ui/WidgetEditorPage.tsx | 971 ++++++++++++++++++ .../ui/CreateWidgetModal.module.css | 125 +++ .../widgets-gallery/ui/CreateWidgetModal.tsx | 137 +++ .../ui/WidgetsGalleryHeader.tsx | 4 +- .../ui/WidgetsGalleryPage.module.css | 46 + .../widgets-gallery/ui/WidgetsGalleryPage.tsx | 65 +- client/src/shared/api/index.ts | 13 + client/src/shared/api/widgets.ts | 93 ++ client/src/shared/locale/content.ts | 108 ++ .../src/widgets/sidebar/ui/Sidebar.module.css | 3 +- client/src/widgets/sidebar/ui/Sidebar.tsx | 14 +- opencode.json | 14 + package-lock.json | 68 ++ server/prisma.config.ts | 15 + .../migration.sql | 9 + server/prisma/schema.prisma | 1 + server/src/app.ts | 4 + server/src/controllers/widgetController.ts | 183 ++++ server/src/index.ts | 2 +- server/src/lib/env.ts | 17 + server/src/lib/prisma.ts | 2 + server/src/routes/widgets.ts | 38 + server/src/services/statsService.ts | 242 +++++ server/src/services/widgetService.ts | 347 +++++++ server/src/widgets.test.ts | 118 +++ server/src/widgets/registry.ts | 147 +++ 49 files changed, 4467 insertions(+), 111 deletions(-) create mode 100644 api/blocks/[id].ts create mode 100644 api/public/widgets/[slug].ts create mode 100644 api/widgets/[id].ts create mode 100644 api/widgets/[widgetId]/blocks/index.ts create mode 100644 api/widgets/[widgetId]/blocks/reorder.ts create mode 100644 api/widgets/index.ts create mode 100644 client/src/entities/widget/model/index.ts create mode 100644 client/src/entities/widget/model/registry.ts create mode 100644 client/src/entities/widget/model/types.ts create mode 100644 client/src/entities/widget/ui/WidgetCanvas.module.css create mode 100644 client/src/entities/widget/ui/WidgetCanvas.tsx create mode 100644 client/src/pages/public-widget/index.ts create mode 100644 client/src/pages/public-widget/ui/PublicWidgetPage.module.css create mode 100644 client/src/pages/public-widget/ui/PublicWidgetPage.tsx create mode 100644 client/src/pages/widget-editor/index.ts create mode 100644 client/src/pages/widget-editor/ui/WidgetEditorPage.module.css create mode 100644 client/src/pages/widget-editor/ui/WidgetEditorPage.tsx create mode 100644 client/src/pages/widgets-gallery/ui/CreateWidgetModal.module.css create mode 100644 client/src/pages/widgets-gallery/ui/CreateWidgetModal.tsx create mode 100644 client/src/shared/api/widgets.ts create mode 100644 opencode.json create mode 100644 server/prisma.config.ts create mode 100644 server/prisma/migrations/20260801120000_add_widget_slug/migration.sql create mode 100644 server/src/controllers/widgetController.ts create mode 100644 server/src/lib/env.ts create mode 100644 server/src/routes/widgets.ts create mode 100644 server/src/services/statsService.ts create mode 100644 server/src/services/widgetService.ts create mode 100644 server/src/widgets.test.ts create mode 100644 server/src/widgets/registry.ts diff --git a/.env.example b/.env.example index c639cf0..25f18bc 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,19 @@ +# 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="" 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..ad74c0b 100644 --- a/client/package.json +++ b/client/package.json @@ -18,6 +18,7 @@ "framer-motion": "^12.43.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-grid-layout": "^2.2.4", "zustand": "^5.0.9" }, "devDependencies": { diff --git a/client/src/app/App.module.css b/client/src/app/App.module.css index bdda328..efde27a 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,12 @@ max-width: 1180px; } +.publicRoute { + width: 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..d48531f 100644 --- a/client/src/app/App.tsx +++ b/client/src/app/App.tsx @@ -3,12 +3,19 @@ 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, + 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 +29,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 +83,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); @@ -104,11 +140,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 +237,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 = `${window.location.origin}/w/${widget.slug}`; + const code = ``; + await navigator.clipboard?.writeText(code); + }; + useEffect(() => { localStorage.setItem(APP_THEME_STORAGE_KEY, theme); document.documentElement.dataset.theme = theme; @@ -197,7 +265,9 @@ export const App = () => { const isAuthorized = authStatus === 'authenticated'; 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 +276,33 @@ 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}`)} + onCopyWidget={handleCopyWidget} + onLogout={handleLogout} + onDeleteWidget={(id) => void handleDeleteWidget(id)} + /> + ) : ( + navigate('/dashboard')} + onOpenPublic={(slug) => navigate(`/w/${slug}`)} + /> + ); return ( @@ -239,32 +335,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..3bf218c --- /dev/null +++ b/client/src/entities/widget/ui/WidgetCanvas.module.css @@ -0,0 +1,256 @@ +.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: 100%; + min-height: var(--widget-height, 340px); + container-type: inline-size; + padding: clamp(20px, 4vw, 34px); + 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); +} + +@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(110px, auto); + gap: 13px; + align-content: start; +} + +.block { + min-width: 0; + padding: 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: 12px; +} + +.blockHeading > div:last-child { + display: grid; + gap: 3px; +} + +.blockHeading span, +.blockTitleRow span, +.stat span, +.languageList, +.difficultyRow { + color: color-mix(in srgb, var(--widget-ink) 60%, transparent); + font-size: 12px; +} + +.avatar { + display: grid; + width: 42px; + height: 42px; + place-items: center; + border-radius: 14px; + color: #fff; + font-size: 18px; + font-weight: 800; + background: var(--widget-accent); +} + +.statsBlock, +.languageBlock { + display: grid; + gap: 18px; +} + +.statsRow { + display: flex; + flex-wrap: wrap; + gap: 22px; +} + +.stat { + display: grid; + gap: 3px; +} + +.stat strong { + font-size: clamp(22px, 4vw, 30px); + letter-spacing: -0.06em; +} + +.languageBar { + display: flex; + height: 10px; + overflow: hidden; + border-radius: 999px; + background: color-mix(in srgb, var(--widget-ink) 10%, transparent); +} + +.languageBar span { + background: var(--widget-accent); +} + +.languageBar span:nth-child(2) { + opacity: 0.72; +} +.languageBar span:nth-child(3) { + opacity: 0.48; +} +.languageBar span:nth-child(4) { + opacity: 0.27; +} + +.languageList { + display: flex; + flex-wrap: wrap; + gap: 9px 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(20px, 4vw, 34px); + font-weight: 800; + line-height: 1.12; + letter-spacing: -0.05em; +} + +.difficultyRow { + display: flex; + flex-wrap: wrap; + gap: 8px 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; +} + +.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; + } + + .block { + padding: 16px; + grid-column: auto !important; + grid-row: auto !important; + } + + .blocks { + grid-template-columns: 1fr; + } + + .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..d88fb8c --- /dev/null +++ b/client/src/entities/widget/ui/WidgetCanvas.tsx @@ -0,0 +1,261 @@ +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; +}; + +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 getBlockLayout = (block: WidgetBlock): BlockLayout => { + const value = block.config.layout; + if (!value || typeof value !== 'object') return { x: 0, y: 0, width: 2, 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 : 2, + 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, +}: { + block: WidgetBlock; + rendered?: RenderedBlock; +}) => { + if (rendered?.error) return

{rendered.error}

; + 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 || data.username || 'G') + .slice(0, 1) + .toUpperCase()} +
+
+ {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) => ( + + ))} +
+
+ {languages.map((language) => ( + + {language.name} {language.percentage}% + + ))} +
+
+ ); + } + + const solved = data.solved; + 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, +}: WidgetCanvasProps) => { + const tokens = paletteTokens[palette]; + 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': Math.max(1, Math.min(columns, 2)), + '--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 ( +
+
+ + live widget preview +
+
+ {blocks.length === 0 && ( +

Add a block to start shaping your widget.

+ )} + {blocks.map((block) => ( +
handleSelect(event, block.id)} + > + +
+ ))} +
+
+ + {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..25e6599 100644 --- a/client/src/entities/widget/ui/WidgetCard.module.css +++ b/client/src/entities/widget/ui/WidgetCard.module.css @@ -12,6 +12,8 @@ } .preview { + --accent-color: var(--lavender); + --accent-soft: var(--lavender-soft); display: grid; min-height: clamp(110px, 18vh, 160px); place-items: center; @@ -28,6 +30,15 @@ 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; +} + .info { display: flex; justify-content: space-between; @@ -100,7 +111,7 @@ .actions { display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: 1fr; min-width: 0; flex-wrap: wrap; justify-self: center; @@ -135,19 +146,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..6985b80 100644 --- a/client/src/entities/widget/ui/WidgetCard.tsx +++ b/client/src/entities/widget/ui/WidgetCard.tsx @@ -1,20 +1,17 @@ -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 { useState, type CSSProperties } from 'react'; +import { paletteTokens, type WidgetCardData } from '@/entities/widget/model'; 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 +22,42 @@ export type WidgetCardLabels = { type WidgetCardProps = { widget: WidgetCardData; labels: WidgetCardLabels; - onDelete: (title: string) => void; + onDelete: (id: string) => void; + onConfigure: (id: string) => void; + onCopy: (widget: WidgetCardData) => void; isLanguageLoading: boolean; }; -const accentClass = { - lavender: styles.accentLavender, - mint: styles.accentMint, - blue: styles.accentBlue, - violet: styles.accentViolet, -} as const; - -export const WidgetCard = ({ widget, labels, onDelete, isLanguageLoading }: WidgetCardProps) => { +export const WidgetCard = ({ + widget, + labels, + onDelete, + onConfigure, + onCopy, + isLanguageLoading, +}: WidgetCardProps) => { const [isDeleteModalOpen, setDeleteModalOpen] = useState(false); + const palette = paletteTokens[widget.accent]; + const useDarkPalette = + widget.paletteMode === 'dark' || + (widget.paletteMode === 'auto' && document.documentElement.dataset.theme === 'dark'); + const tokens = useDarkPalette ? palette.dark : palette.light; + const previewStyle = { + '--accent-color': tokens.accent, + '--accent-soft': tokens.soft, + } as CSSProperties; const confirmDelete = () => { setDeleteModalOpen(false); - onDelete(widget.title); + onDelete(widget.id); }; return ( <> - -
+ +
{widget.metric} + {widget.public ? labels.published : labels.draft}
@@ -59,16 +68,28 @@ export const WidgetCard = ({ widget, labels, onDelete, isLanguageLoading }: Widg {isLanguageLoading ? '' : widget.source}

- {isLanguageLoading ? '' : `${labels.updated}: 30 Jul`} + {isLanguageLoading + ? '' + : `${labels.updated}: ${new Date(widget.updatedAt).toLocaleDateString()}`}

- - + {widget.public && ( + + )} -
@@ -86,10 +104,10 @@ export const WidgetCard = ({ widget, labels, onDelete, isLanguageLoading }: Widg
-

{labels.removeTitle}

+

{labels.removeTitle}

{labels.removeDescription.replace('{title}', widget.title)}

+ + + + ); +}; + +export const WidgetEditorPage = ({ + widgetId, + locale, + onBack, + onOpenPublic, +}: WidgetEditorPageProps) => { + const t = messages[locale]; + const [widget, setWidget] = useState(null); + const widgetRef = useRef(null); + const [selectedBlockId, setSelectedBlockId] = useState(null); + const [activePanel, setActivePanel] = useState('widget'); + const [isLoading, setLoading] = useState(true); + const [isSaving, setSaving] = useState(false); + const [isDirty, setDirty] = useState(false); + const [isCopied, setCopied] = useState(false); + const [isMobile, setMobile] = useState(false); + const [error, setError] = useState(null); + const savePromiseRef = useRef | null>(null); + + useEffect(() => { + let cancelled = false; + void getWidget(widgetId) + .then((serverWidget) => { + if (cancelled) return; + const cachedWidget = readCachedWidget(widgetId); + const normalized = normalizeWidget(cachedWidget ?? serverWidget); + setWidget(normalized.widget); + widgetRef.current = normalized.widget; + setSelectedBlockId(normalized.widget.blocks[0]?.id ?? null); + setDirty(Boolean(cachedWidget) || normalized.changed); + }) + .catch((loadError) => { + if (!cancelled) setError(loadError instanceof Error ? loadError.message : t.unavailable); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [t.unavailable, widgetId]); + + useEffect(() => { + widgetRef.current = widget; + if (widget && isDirty) writeCachedWidget(widget); + }, [isDirty, widget]); + + useEffect(() => { + const media = window.matchMedia('(max-width: 760px)'); + const update = () => setMobile(media.matches); + update(); + media.addEventListener('change', update); + return () => media.removeEventListener('change', update); + }, []); + + const updateLocalWidget = (updater: (current: Widget) => Widget) => { + const current = widgetRef.current; + if (!current) return; + const nextWidget = updater(current); + widgetRef.current = nextWidget; + setWidget(nextWidget); + setDirty(true); + }; + + const selectedBlock = widget?.blocks.find((block) => block.id === selectedBlockId) ?? null; + + const prepareDrag = (blockId: string) => { + const current = widgetRef.current; + if (!current) return; + const shouldSquareBlocks = + current.blocks.length > 1 && + current.blocks.some((block) => { + const layout = getLayout(block); + return layout.width === 2 && layout.height === 1; + }); + if (!shouldSquareBlocks) return; + const nextWidget = { + ...current, + config: { ...current.config, grid: { columns: 1 } }, + blocks: current.blocks.map((block) => { + const layout = getLayout(block); + return layout.width === 2 && layout.height === 1 + ? { ...block, config: { ...block.config, layout: { ...layout, width: 1 } } } + : block; + }), + }; + widgetRef.current = nextWidget; + setWidget(nextWidget); + setDirty(true); + setSelectedBlockId(blockId); + setActivePanel('block'); + }; + + const updateGridState = (nextLayout: Layout) => { + const current = widgetRef.current; + if (!current || layoutEquals(rglLayoutFor(current), nextLayout)) return; + const nextWidget = widgetWithRglLayout(current, nextLayout); + widgetRef.current = nextWidget; + setWidget(nextWidget); + setDirty(true); + }; + + const handleGridLayoutChange = (nextLayout: Layout) => { + if (isMobile) return; + updateGridState(nextLayout); + }; + + const handleGridInteractionStart: EventCallback = (_layout, _oldItem, newItem) => { + if (newItem) { + setSelectedBlockId(newItem.i); + setActivePanel('block'); + } + }; + + const handleGridInteractionStop: EventCallback = (nextLayout) => { + if (!isMobile) updateGridState(nextLayout); + }; + + const handleAddBlock = async (type: BlockType) => { + const current = widgetRef.current; + if (!current) return; + if (current.blocks.length >= MAX_BLOCKS) { + setError(t.blocksLimit); + return; + } + try { + const block = await addBlock(current.id, type, defaultBlockConfig(type)); + const nextWidget = { + ...current, + config: { + ...current.config, + grid: { + columns: Math.max( + current.config.grid.columns, + getLayout(block).x + getLayout(block).width, + ), + }, + }, + blocks: [...current.blocks, block], + }; + updateLocalWidget(() => nextWidget); + setSelectedBlockId(block.id); + setActivePanel('block'); + } catch (addError) { + setError(addError instanceof Error ? addError.message : t.unavailable); + } + }; + + const handleRemoveBlock = async (blockId: string) => { + const current = widgetRef.current; + if (!current) return; + try { + await deleteBlock(blockId); + const blocks = current.blocks.filter((block) => block.id !== blockId); + const remainingLayout = blocks.map((block) => { + const layout = getLayout(block); + return { i: block.id, x: layout.x, y: layout.y, w: layout.width, h: layout.height }; + }); + const nextWidget = { + ...current, + config: { ...current.config, grid: { columns: columnsForLayout(remainingLayout) } }, + blocks, + }; + updateLocalWidget(() => nextWidget); + setSelectedBlockId((current) => (current === blockId ? null : current)); + setActivePanel('widget'); + } catch (removeError) { + setError(removeError instanceof Error ? removeError.message : t.unavailable); + } + }; + + const handleBlockConfig = (patch: Record) => { + if (!widget || !selectedBlock) return; + const config = { ...selectedBlock.config, ...patch }; + updateLocalWidget((current) => ({ + ...current, + blocks: current.blocks.map((block) => + block.id === selectedBlock.id ? { ...block, config } : block, + ), + })); + }; + + const handleSave = (publish = false): Promise => { + if (savePromiseRef.current) { + const pending = savePromiseRef.current; + return pending.then(() => (publish ? handleSave(true) : undefined)); + } + const run = (async () => { + const current = widgetRef.current; + if (!current) return; + setSaving(true); + setError(null); + try { + await updateBlockLayouts(current.id, layoutsFor(current), current.config.grid.columns); + await Promise.all(current.blocks.map((block) => updateBlock(block.id, block.config))); + const saved = await updateWidget(current.id, { + title: current.title, + width: current.width, + height: current.height, + public: publish || current.public, + config: current.config, + }); + const normalized = normalizeWidget(saved).widget; + widgetRef.current = normalized; + setWidget(normalized); + setDirty(false); + clearCachedWidget(current.id); + } catch (saveError) { + setError(saveError instanceof Error ? saveError.message : t.unavailable); + } finally { + setSaving(false); + } + })(); + savePromiseRef.current = run.finally(() => { + savePromiseRef.current = null; + }); + return savePromiseRef.current; + }; + + const triggerAutosave = useEffectEvent(() => { + void handleSave(); + }); + + useEffect(() => { + if (!widget || !isDirty) return; + const timeout = window.setTimeout(triggerAutosave, 850); + return () => window.clearTimeout(timeout); + }, [isDirty, widget?.id, widget]); + + const handleUnpublish = async () => { + if (!widget) return; + setSaving(true); + try { + await handleSave(); + const current = widgetRef.current; + if (!current) return; + const saved = await updateWidget(current.id, { public: false }); + const normalized = normalizeWidget(saved).widget; + widgetRef.current = normalized; + setWidget(normalized); + setDirty(false); + clearCachedWidget(current.id); + } catch (saveError) { + setError(saveError instanceof Error ? saveError.message : t.unavailable); + } finally { + setSaving(false); + } + }; + + const copyEmbed = async () => { + if (!widget || !widget.public) return; + const src = `${window.location.origin}/w/${widget.slug}`; + const code = ``; + await navigator.clipboard?.writeText(code); + setCopied(true); + window.setTimeout(() => setCopied(false), 1600); + }; + + const guardLeave = () => { + onBack(); + }; + + if (isLoading) return
{t.loading}
; + if (!widget) + return ( +
+ {error || t.unavailable} +
+ ); + + const columns = widget.config.grid.columns; + const gridColumns = isMobile ? 1 : MAX_COLUMNS; + const gridLayout = isMobile + ? widget.blocks.map((block, index) => { + const layout = getLayout(block); + return { + i: block.id, + x: 0, + y: index, + w: 1, + h: layout.height, + } satisfies LayoutItem; + }) + : rglLayoutFor(widget); + 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': gridColumns, + } 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.'} +

+ ) : ( +
{ + const target = event.target; + if (target instanceof Element && target.closest('.widget-drag-handle')) { + const block = target.closest('[data-block-id]'); + const blockId = block?.dataset.blockId; + if (blockId) flushSync(() => prepareDrag(blockId)); + } + }} + > + ( + } + className={`react-resizable-handle react-resizable-handle-${axis} ${styles.resizeHandle}`} + aria-label={t.resize} + /> + )} + onLayoutChange={handleGridLayoutChange} + onDragStart={handleGridInteractionStart} + onDragStop={handleGridInteractionStop} + onResizeStart={handleGridInteractionStart} + onResizeStop={handleGridInteractionStop} + > + {widget.blocks.map((block) => ( + { + setSelectedBlockId(block.id); + setActivePanel('block'); + }} + onRemove={() => void handleRemoveBlock(block.id)} + removeLabel={t.removeBlock} + /> + ))} + +
+ )} +
+ + {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' + ? 'Колонки появляются при переносе блока вправо.' + : 'Columns appear when a block is moved to the right.'} + +
+
+ {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' && ( + <> +