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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions client/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export const App = () => {
const [route, setRoute] = useState<AppRoute>(getRoute);
const [authTab, setAuthTab] = useState<AuthTab>(getAuthTab);
const [visibleWidgets, setVisibleWidgets] = useState<WidgetCardData[]>([]);
const [loadedWidgetsKey, setLoadedWidgetsKey] = useState<string | null>(null);
const [themeReveal, setThemeReveal] = useState<ThemeRevealState | null>(null);
const [isLocaleTransitioning, setLocaleTransitioning] = useState(false);
const [isBootstrapped, setBootstrapped] = useState(false);
Expand All @@ -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);
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -340,6 +350,7 @@ export const App = () => {
theme={theme}
username={username}
widgets={visibleWidgets}
isWidgetsLoading={isWidgetsLoading}
isLanguageLoading={isLocaleTransitioning}
onLocaleToggle={handleLocaleToggle}
onThemeToggle={handleThemeToggle}
Expand Down
6 changes: 5 additions & 1 deletion client/src/entities/widget/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
9 changes: 7 additions & 2 deletions client/src/entities/widget/ui/WidgetCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -169,6 +170,7 @@ export const WidgetBlockContent = ({
rendered?: RenderedBlock;
locale?: WidgetLocale;
}) => {
const t = messages[locale];
if (rendered?.error) return <p className={styles.error}>{rendered.error}</p>;
const source = block.type.startsWith('github')
? 'github'
Expand Down Expand Up @@ -270,10 +272,13 @@ export const WidgetBlockContent = ({
<div className={styles.statsRow}>
<Stat label="Solved" value={solved?.all ?? (hasLiveData ? 0 : 312)} />
{block.config.showRanking !== false && (
<Stat label="Ranking" value={data.ranking ?? (hasLiveData ? '—' : 18_240)} />
<Stat label={t.leetcodeRanking} value={data.ranking ?? (hasLiveData ? '—' : 18_240)} />
)}
{block.config.showContestRating !== false && (
<Stat label="Rating" value={data.contestRating ?? (hasLiveData ? '—' : 1_726)} />
<Stat
label={t.leetcodeContestRating}
value={data.contestRating ?? (hasLiveData ? '—' : 1_726)}
/>
)}
</div>
<div className={styles.difficultyRow}>
Expand Down
21 changes: 21 additions & 0 deletions client/src/entities/widget/ui/WidgetCard.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
82 changes: 68 additions & 14 deletions client/src/entities/widget/ui/WidgetCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -30,10 +30,55 @@ type WidgetCardProps = {
isLanguageLoading: boolean;
};

const PreviewSkeleton = () => (
<div className={styles.previewSkeleton} aria-hidden="true">
<span className={styles.previewSkeletonTitle} />
<span className={styles.previewSkeletonSubtitle} />
<div className={styles.previewSkeletonStats}>
<span />
<span />
<span />
</div>
</div>
);

const WidgetPreviewFrame = ({ widget }: { widget: WidgetCardData }) => {
const viewportRef = useRef<HTMLDivElement | null>(null);
const [isLoaded, setLoaded] = useState(false);
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const [loadedSlug, setLoadedSlug] = useState<string | null>(null);
const [scale, setScale] = useState(1);
const isLoaded = loadedSlug === widget.slug;

useEffect(() => {
const handleMessage = (event: MessageEvent<unknown>) => {
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;
Expand Down Expand Up @@ -62,18 +107,9 @@ const WidgetPreviewFrame = ({ widget }: { widget: WidgetCardData }) => {
className={`${styles.previewFrameViewport} ${isLoaded ? styles.previewFrameViewportLoaded : ''}`}
aria-busy={!isLoaded}
>
{!isLoaded && (
<div className={styles.previewSkeleton} aria-hidden="true">
<span className={styles.previewSkeletonTitle} />
<span className={styles.previewSkeletonSubtitle} />
<div className={styles.previewSkeletonStats}>
<span />
<span />
<span />
</div>
</div>
)}
{!isLoaded && <PreviewSkeleton />}
<iframe
ref={iframeRef}
className={`${styles.previewFrame} ${isLoaded ? styles.previewFrameLoaded : ''}`}
src={getPublicWidgetUrl(widget.slug, true, {
width: widget.width,
Expand All @@ -88,12 +124,30 @@ const WidgetPreviewFrame = ({ widget }: { widget: WidgetCardData }) => {
transform: `scale(${scale})`,
}}
loading="lazy"
onLoad={() => setLoaded(true)}
/>
</div>
);
};

export const WidgetCardSkeleton = () => (
<Card className={styles.card} view="clear" aria-hidden="true">
<div className={styles.preview}>
<PreviewSkeleton />
</div>
<div className={styles.info}>
<div className={styles.meta}>
<span className={styles.textSkeletonHeading} />
<span className={styles.textSkeleton} />
<span className={styles.textSkeleton} />
</div>
<div className={styles.skeletonActions}>
<span className={styles.skeletonAction} />
<span className={styles.skeletonAction} />
</div>
</div>
</Card>
);

export const WidgetCard = ({
widget,
labels,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@

.status {
display: grid;
min-height: 100vh;
min-height: calc(100vh - 48px);
min-height: calc(100dvh - 48px);
place-items: center;
padding: 24px;
color: var(--muted);
Expand All @@ -81,6 +82,11 @@
}

@media (max-width: 520px) {
.status {
min-height: calc(100vh - 20px);
min-height: calc(100dvh - 20px);
}

.publicPage {
padding: 12px;
}
Expand Down
16 changes: 14 additions & 2 deletions client/src/pages/public-widget/ui/PublicWidgetPage.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useEffect, useState, type CSSProperties } from 'react';

import { WidgetCanvas, WidgetCanvasSkeleton, type PublicWidgetResponse } from '@/entities/widget';
import { getPublicWidget } from '@/shared/api';
import { getPublicWidget, PUBLIC_WIDGET_MESSAGE_SOURCE } from '@/shared/api';
import type { Locale } from '@/shared/locale/content';
import { messages } from '@/shared/locale/content';
import styles from '@/pages/public-widget/ui/PublicWidgetPage.module.css';
Expand Down Expand Up @@ -42,9 +42,21 @@ export const PublicWidgetPage = ({ slug, locale, embed = false }: PublicWidgetPa
};
}, [slug, t.unavailable]);

useEffect(() => {
if (!embed || window.parent === window || (!payload && !error)) return;
window.parent.postMessage(
{
source: PUBLIC_WIDGET_MESSAGE_SOURCE,
type: error ? 'error' : 'ready',
slug,
},
window.location.origin,
);
}, [embed, error, payload, slug]);

if (error)
return (
<div className={styles.status} role="alert">
<div className={`${styles.status} ${embed ? styles.embedStatus : ''}`} role="alert">
{error}
</div>
);
Expand Down
2 changes: 1 addition & 1 deletion client/src/pages/widget-editor/ui/WidgetEditorPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1089,7 +1089,7 @@ const BlockConfigPanel = ({
: block.type === 'leetcode-stats'
? [
['showRanking', locale === 'ru' ? 'Рейтинг' : 'Ranking'],
['showContestRating', 'Contest rating'],
['showContestRating', locale === 'ru' ? 'Рейтинг соревнований' : 'Contest rating'],
]
: [];
return (
Expand Down
21 changes: 19 additions & 2 deletions client/src/pages/widgets-gallery/ui/WidgetsGalleryPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import type { MouseEvent } from 'react';
import { useState } from 'react';

import type { AppTheme } from '@/app/config';
import { WidgetCard, type WidgetCardData, type WidgetCardLabels } from '@/entities/widget';
import {
WidgetCard,
WidgetCardSkeleton,
type WidgetCardData,
type WidgetCardLabels,
} from '@/entities/widget';
import type { CreateWidgetInput } from '@/shared/api';
import { messages, type Locale } from '@/shared/locale/content';
import { CreateWidgetModal } from '@/pages/widgets-gallery/ui/CreateWidgetModal';
Expand All @@ -15,6 +20,7 @@ type WidgetsGalleryPageProps = {
theme: AppTheme;
username: string;
widgets: WidgetCardData[];
isWidgetsLoading: boolean;
isLanguageLoading: boolean;
onLocaleToggle: () => void;
onThemeToggle: (event: MouseEvent<HTMLButtonElement>) => void;
Expand All @@ -31,6 +37,7 @@ export const WidgetsGalleryPage = ({
theme,
username,
widgets,
isWidgetsLoading,
isLanguageLoading,
onLocaleToggle,
onThemeToggle,
Expand Down Expand Up @@ -89,7 +96,17 @@ export const WidgetsGalleryPage = ({
isLanguageLoading={isLanguageLoading}
onCreateWidget={() => setCreateModalOpen(true)}
/>
{widgets.length > 0 ? (
{isWidgetsLoading ? (
<div
className={`${styles.widgetGrid} ${styles.widgetGalleryGrid}`}
aria-busy="true"
aria-label={t.loading}
>
{Array.from({ length: 6 }, (_, index) => (
<WidgetCardSkeleton key={index} />
))}
</div>
) : widgets.length > 0 ? (
<div className={`${styles.widgetGrid} ${styles.widgetGalleryGrid}`}>
{widgets.map((widget) => (
<WidgetCard
Expand Down
1 change: 1 addition & 0 deletions client/src/shared/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export {
getPublicWidget,
getPublicWidgetPath,
getPublicWidgetUrl,
PUBLIC_WIDGET_MESSAGE_SOURCE,
getWidget,
listWidgets,
previewWidgetBlock,
Expand Down
2 changes: 2 additions & 0 deletions client/src/shared/api/widgets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export type CreateWidgetInput = {
presetId?: string;
};

export const PUBLIC_WIDGET_MESSAGE_SOURCE = 'widgecode-public-widget';

export const listWidgets = async () => {
const response = await apiClient<{ widgets: Widget[] }>('/widgets');
return response.widgets;
Expand Down
4 changes: 4 additions & 0 deletions client/src/shared/locale/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ export const messages = {
blockSettings: 'Настройки блока',
githubUsername: 'GitHub username',
leetcodeUsername: 'LeetCode username',
leetcodeRanking: 'Рейтинг',
leetcodeContestRating: 'Рейтинг соревнований',
palette: 'Палитра',
choosePalette: 'Выберите готовую палитру',
width: 'Ширина',
Expand Down Expand Up @@ -176,6 +178,8 @@ export const messages = {
blockSettings: 'Block settings',
githubUsername: 'GitHub username',
leetcodeUsername: 'LeetCode username',
leetcodeRanking: 'Ranking',
leetcodeContestRating: 'Contest rating',
palette: 'Palette',
choosePalette: 'Choose a ready-made palette',
width: 'Width',
Expand Down
Loading
Loading