diff --git a/client/src/app/App.tsx b/client/src/app/App.tsx index 6fdac00..9c2e8c7 100644 --- a/client/src/app/App.tsx +++ b/client/src/app/App.tsx @@ -99,6 +99,7 @@ export const App = () => { const [route, setRoute] = useState(getRoute); const [authTab, setAuthTab] = useState(getAuthTab); const [visibleWidgets, setVisibleWidgets] = useState([]); + const [loadedWidgetsKey, setLoadedWidgetsKey] = useState(null); const [themeReveal, setThemeReveal] = useState(null); const [isLocaleTransitioning, setLocaleTransitioning] = useState(false); const [isBootstrapped, setBootstrapped] = useState(false); @@ -107,6 +108,11 @@ export const App = () => { const authUser = useAuthStore((state) => state.user); const authError = useAuthStore((state) => state.error); const prefersReducedMotion = useReducedMotion(); + const widgetLoadKey = + authStatus === 'authenticated' && (route === 'dashboard' || route === 'editor') + ? `${authUser?.id ?? 'current'}:${route}` + : null; + const isWidgetsLoading = widgetLoadKey !== null && loadedWidgetsKey !== widgetLoadKey; const navigate = useCallback((path: string, replace = false) => { if (replace) window.history.replaceState({}, '', path); @@ -160,19 +166,23 @@ export const App = () => { }, [navigate]); useEffect(() => { - if (authStatus !== 'authenticated' || (route !== 'dashboard' && route !== 'editor')) return; + if (!widgetLoadKey) return; let cancelled = false; void listWidgets() .then((nextWidgets) => { - if (!cancelled) setVisibleWidgets(nextWidgets.map(toCardData)); + if (cancelled) return; + setVisibleWidgets(nextWidgets.map(toCardData)); + setLoadedWidgetsKey(widgetLoadKey); }) .catch(() => { - if (!cancelled) setVisibleWidgets([]); + if (cancelled) return; + setVisibleWidgets([]); + setLoadedWidgetsKey(widgetLoadKey); }); return () => { cancelled = true; }; - }, [authStatus, route]); + }, [widgetLoadKey]); useEffect(() => { if ( @@ -340,6 +350,7 @@ export const App = () => { theme={theme} username={username} widgets={visibleWidgets} + isWidgetsLoading={isWidgetsLoading} isLanguageLoading={isLocaleTransitioning} onLocaleToggle={handleLocaleToggle} onThemeToggle={handleThemeToggle} diff --git a/client/src/entities/widget/index.ts b/client/src/entities/widget/index.ts index 0da54ae..372c3db 100644 --- a/client/src/entities/widget/index.ts +++ b/client/src/entities/widget/index.ts @@ -1,4 +1,8 @@ -export { WidgetCard, type WidgetCardLabels } from '@/entities/widget/ui/WidgetCard'; +export { + WidgetCard, + WidgetCardSkeleton, + type WidgetCardLabels, +} from '@/entities/widget/ui/WidgetCard'; export { WidgetBlockContent, WidgetCanvas, diff --git a/client/src/entities/widget/ui/WidgetCanvas.tsx b/client/src/entities/widget/ui/WidgetCanvas.tsx index d624b9f..34764df 100644 --- a/client/src/entities/widget/ui/WidgetCanvas.tsx +++ b/client/src/entities/widget/ui/WidgetCanvas.tsx @@ -9,6 +9,7 @@ import type { WidgetBlock, } from '@/entities/widget/model'; import { paletteTokens } from '@/entities/widget/model'; +import { messages } from '@/shared/locale/content'; import styles from '@/entities/widget/ui/WidgetCanvas.module.css'; type WidgetCanvasProps = { @@ -169,6 +170,7 @@ export const WidgetBlockContent = ({ rendered?: RenderedBlock; locale?: WidgetLocale; }) => { + const t = messages[locale]; if (rendered?.error) return

{rendered.error}

; const source = block.type.startsWith('github') ? 'github' @@ -270,10 +272,13 @@ export const WidgetBlockContent = ({
{block.config.showRanking !== false && ( - + )} {block.config.showContestRating !== false && ( - + )}
diff --git a/client/src/entities/widget/ui/WidgetCard.module.css b/client/src/entities/widget/ui/WidgetCard.module.css index 969ce24..080bcf5 100644 --- a/client/src/entities/widget/ui/WidgetCard.module.css +++ b/client/src/entities/widget/ui/WidgetCard.module.css @@ -285,6 +285,27 @@ padding: 0; } +.skeletonActions { + display: flex; + gap: 8px; + padding-top: 4px; +} + +.skeletonAction { + display: block; + width: 30px; + height: 30px; + border-radius: 10px; + background: linear-gradient( + 100deg, + color-mix(in srgb, var(--muted) 16%, transparent) 20%, + color-mix(in srgb, var(--action) 30%, transparent) 45%, + color-mix(in srgb, var(--muted) 16%, transparent) 70% + ); + background-size: 220% 100%; + animation: card-skeleton-shimmer 1s linear infinite; +} + @media (max-width: 760px) { .configureAction { display: none; diff --git a/client/src/entities/widget/ui/WidgetCard.tsx b/client/src/entities/widget/ui/WidgetCard.tsx index 817cea2..dd86bc6 100644 --- a/client/src/entities/widget/ui/WidgetCard.tsx +++ b/client/src/entities/widget/ui/WidgetCard.tsx @@ -3,7 +3,7 @@ import { Button, Card, Icon, Modal } from '@gravity-ui/uikit'; import { useEffect, useRef, useState, type CSSProperties } from 'react'; import { paletteTokens, type WidgetCardData } from '@/entities/widget/model'; -import { getPublicWidgetUrl } from '@/shared/api'; +import { getPublicWidgetUrl, PUBLIC_WIDGET_MESSAGE_SOURCE } from '@/shared/api'; import styles from '@/entities/widget/ui/WidgetCard.module.css'; export type WidgetCardLabels = { @@ -30,10 +30,55 @@ type WidgetCardProps = { isLanguageLoading: boolean; }; +const PreviewSkeleton = () => ( + +); + const WidgetPreviewFrame = ({ widget }: { widget: WidgetCardData }) => { const viewportRef = useRef(null); - const [isLoaded, setLoaded] = useState(false); + const iframeRef = useRef(null); + const [loadedSlug, setLoadedSlug] = useState(null); const [scale, setScale] = useState(1); + const isLoaded = loadedSlug === widget.slug; + + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + if ( + event.origin !== window.location.origin || + event.source !== iframeRef.current?.contentWindow || + !event.data || + typeof event.data !== 'object' + ) { + return; + } + + const message = event.data as { + source?: unknown; + type?: unknown; + slug?: unknown; + }; + if ( + message.source !== PUBLIC_WIDGET_MESSAGE_SOURCE || + message.slug !== widget.slug || + (message.type !== 'ready' && message.type !== 'error') + ) { + return; + } + + setLoadedSlug(widget.slug); + }; + + window.addEventListener('message', handleMessage); + return () => window.removeEventListener('message', handleMessage); + }, [widget.slug]); useEffect(() => { const viewport = viewportRef.current; @@ -62,18 +107,9 @@ const WidgetPreviewFrame = ({ widget }: { widget: WidgetCardData }) => { className={`${styles.previewFrameViewport} ${isLoaded ? styles.previewFrameViewportLoaded : ''}`} aria-busy={!isLoaded} > - {!isLoaded && ( - - )} + {!isLoaded && } -``` - -## Проверка -- [x] Публичный виджет рендерится по `/api/public/widgets/:slug` -- [x] Приватный виджет → 404 -- [x] iframe-код корректно генерируется на клиенте -- [x] GitHub stats/langs блоки тянут реальные данные при настроенном `GITHUB_TOKEN` -- [x] LeetCode stats block использует актуальный GraphQL query diff --git a/server/src/services/statsService.ts b/server/src/services/statsService.ts index 7014750..ca4b928 100644 --- a/server/src/services/statsService.ts +++ b/server/src/services/statsService.ts @@ -155,6 +155,9 @@ type LeetcodeResponse = { acSubmissionNum?: { difficulty: string; count: number }[]; } | null; } | null; + userContestRanking?: { + rating?: number | null; + } | null; }; }; @@ -166,6 +169,7 @@ const getLeetcodeStats = async (username: string) => { profile { ranking reputation starRating } submitStatsGlobal { acSubmissionNum { difficulty count } } } + userContestRanking(username: $username) { rating } } `; const response = await getCached(`leetcode:${username}:profile`, () => @@ -187,7 +191,7 @@ const getLeetcodeStats = async (username: string) => { return { username: matchedUser.username, ranking: matchedUser.profile?.ranking ?? null, - contestRating: null, + contestRating: response.data?.userContestRanking?.rating ?? null, reputation: matchedUser.profile?.reputation ?? null, solved: { all: solved.all ?? 0, diff --git a/server/src/statsService.test.ts b/server/src/statsService.test.ts new file mode 100644 index 0000000..262c899 --- /dev/null +++ b/server/src/statsService.test.ts @@ -0,0 +1,54 @@ +import { clearStatsCache, renderWidgetStats } from '@server/services/statsService.js'; + +afterEach(() => { + vi.unstubAllGlobals(); + clearStatsCache(); +}); + +it('maps the LeetCode contest rating into rendered block data', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + data: { + matchedUser: { + username: 'tourist', + profile: { ranking: 42, reputation: 10, starRating: 5 }, + submitStatsGlobal: { + acSubmissionNum: [ + { difficulty: 'All', count: 100 }, + { difficulty: 'Easy', count: 50 }, + { difficulty: 'Medium', count: 40 }, + { difficulty: 'Hard', count: 10 }, + ], + }, + }, + userContestRanking: { rating: 2_400 }, + }, + }), + }); + vi.stubGlobal('fetch', fetchMock); + + const rendered = await renderWidgetStats({ + config: {}, + blocks: [ + { + id: 'block-1', + type: 'leetcode-stats', + position: 0, + config: { username: 'tourist' }, + }, + ], + }); + + expect(rendered.blocks[0]).toEqual( + expect.objectContaining({ + data: expect.objectContaining({ + ranking: 42, + contestRating: 2_400, + }), + }), + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + const request = fetchMock.mock.calls[0]?.[1] as RequestInit; + expect(String(request.body)).toContain('userContestRanking'); +});