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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ Server imports must use `.js` extensions (NodeNext).
- Widget width is currently fixed at 600 and height is recomputed from rows by `widgetDimensions` on every update — client-supplied `width`/`height` are ignored.
- `statsService` and the avatar cache are per-process in-memory caches; on Vercel each cold instance starts empty. Successful lookups are cached 15 min, failed ones 60 s; the public SVG `Cache-Control` follows the same TTL. Rate limits (`express-rate-limit`, memory store) are also per instance; `trust proxy` is enabled on Vercel so limits are per client IP.
- `api/*.ts` import from `server/dist/src/app.js`. `server/tsconfig.build.json` pins `rootDir: "."` to keep that path stable, and CI runs `typecheck:api` to catch breakage.
- Auth: short-lived access token in memory (Authorization: Bearer), rotating refresh token in an httpOnly cookie scoped to `/api/auth`, sessions stored hashed in `AuthSession`. Yandex OAuth returns the access token in the URL hash of `/auth/callback`.
- Auth: short-lived access token in memory (Authorization: Bearer), rotating refresh token in an httpOnly cookie scoped to `/api/auth`, sessions stored hashed in `AuthSession`. Yandex OAuth returns the access token in the URL hash of `/auth/callback`. Accounts are keyed by provider id (`yandexId`), never linked by email; linking happens from `/account` (`/api/auth/yandex?intent=link`, the intent is stored with the OAuth state and the callback identifies the user by the refresh cookie).
- Errors: throw `AppError(status, message)` from services; controllers pass errors to `next()`; `errorMiddleware` maps them. Non-AppError → 500 with a generic message.
- All user-visible strings are bilingual (ru/en). Add both.
- `.env` is gitignored; `.env.example` must list every variable the code reads.
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ All notable changes to this project are documented here.

### Added

- Added an account page with sign-in methods: connect Yandex ID to an existing account, or disconnect it when a password remains.
- Added 404 and 500 pages for unknown addresses, missing or unpublished widgets, failed widget loads, and unexpected rendering errors.
- Added live SVG image export URLs and README-ready HTML snippets for public widgets.
- Added proportional `width` and `height` query scaling for SVG image URLs.
- Extracted shared widget SVG-rendering primitives for server-side export.
Expand All @@ -23,6 +25,7 @@ All notable changes to this project are documented here.

### Fixed

- Starting Yandex sign-in when it isn't configured now returns to the app with an error message instead of a raw JSON response.
- Fixed editor blocks jumping to another column or far down the grid when resized: the edited block stays in place, neighbours move down and gaps close; dropping a block onto one of the same size swaps them, and every moved block animates.
- Fixed saving failing with a raw validation error after clearing a text block or entering an out-of-range language count; the count is now a 3–8 select and text/username fields have length limits.
- Fixed legacy blocks without a stored layout all landing on the first row in the editor.
Expand Down
182 changes: 105 additions & 77 deletions client/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import { LandingHeader, LandingPage } from '@/pages/landing';
import { WidgetsGalleryPage } from '@/pages/widgets-gallery';
import { PublicWidgetPage } from '@/pages/public-widget';
import { WidgetEditorPage } from '@/pages/widget-editor';
import { ErrorPage } from '@/widgets/error-page';
import { AccountPage } from '@/pages/account';
import { AppErrorBoundary } from '@/app/AppErrorBoundary';
import {
createWidget,
deleteWidget,
Expand All @@ -31,18 +34,24 @@ 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' | 'editor' | 'public' | 'callback';
type AppRoute =
'landing' | 'auth' | 'dashboard' | 'editor' | 'account' | 'public' | 'callback' | 'not-found';

const isPrivateRoute = (route: AppRoute) =>
route === 'dashboard' || route === 'editor' || route === 'account';

const getPathname = () => window.location.pathname.replace(/\/+$/, '') || '/';

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

const getRouteParam = (prefix: string) => getPathname().slice(prefix.length) || null;
Expand Down Expand Up @@ -84,8 +93,10 @@ const getDocumentTitle = (route: AppRoute, authTab: AuthTab) => {
return authTab === 'signup' ? 'WidgeCode | Register' : 'WidgeCode | Sign in';
if (route === 'dashboard') return 'WidgeCode | Dashboard';
if (route === 'editor') return 'WidgeCode | Widget editor';
if (route === 'account') return 'WidgeCode | Account';
if (route === 'public') return 'WidgeCode | Public widget';
if (route === 'callback') return 'WidgeCode | Sign in';
if (route === 'not-found') return 'WidgeCode | Page not found';
return 'WidgeCode';
};

Expand Down Expand Up @@ -188,7 +199,7 @@ export const App = () => {
if (
isBootstrapped &&
!isLoggingOut &&
(route === 'dashboard' || route === 'editor') &&
isPrivateRoute(route) &&
authStatus === 'unauthenticated'
) {
window.location.replace('/login');
Expand Down Expand Up @@ -311,15 +322,17 @@ export const App = () => {
if (isEmbedRoute)
return (
<ThemeProvider theme={theme}>
<PublicWidgetPage slug={getRouteParam('/w/') ?? ''} locale={locale} embed />
<PublicWidgetPage
slug={getRouteParam('/w/') ?? ''}
locale={locale}
embed
onHome={() => navigate('/')}
/>
</ThemeProvider>
);
const isRedirectingFromPrivateRoute =
isBootstrapped &&
(route === 'dashboard' || route === 'editor') &&
authStatus === 'unauthenticated';
const isPrivateRouteTransitioning =
(route === 'dashboard' || route === 'editor') && !isAuthorized;
isBootstrapped && isPrivateRoute(route) && authStatus === 'unauthenticated';
const isPrivateRouteTransitioning = isPrivateRoute(route) && !isAuthorized;
const isRedirectingFromAuthRoute =
isBootstrapped && route === 'auth' && authStatus === 'authenticated';
const isAuthTransitioning =
Expand Down Expand Up @@ -368,6 +381,7 @@ export const App = () => {
}
onCopyWidget={handleCopyWidget}
onLogout={handleLogout}
onOpenAccount={() => navigate('/account')}
onDeleteWidget={(id) => void handleDeleteWidget(id)}
/>
) : (
Expand All @@ -376,6 +390,7 @@ export const App = () => {
locale={locale}
onBack={() => navigate('/dashboard')}
onOpenPublic={(slug) => navigate(`/w/${slug}`)}
onHome={() => navigate('/dashboard')}
onSave={(saved) =>
setVisibleWidgets((current) =>
current.map((widget) => (widget.id === saved.id ? toCardData(saved) : widget)),
Expand All @@ -386,79 +401,92 @@ export const App = () => {

return (
<ThemeProvider theme={theme}>
<div className={styles.appShell}>
<div className={`${styles.orb} ${styles.orbLavender}`} />
<div className={`${styles.orb} ${styles.orbBlue}`} />
{isAuthTransitioning ? (
<AuthTransitionLoader locale={locale} reducedMotion={Boolean(prefersReducedMotion)} />
) : (
<AnimatePresence mode="wait" initial={false}>
<motion.div
className={styles.motionDiv}
key={route}
initial={prefersReducedMotion ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={prefersReducedMotion ? undefined : { opacity: 0, y: -10 }}
transition={{ duration: 0.24, ease: 'easeOut' }}
>
{route === 'landing' && (
<LandingHeader
locale={locale}
theme={theme}
isAuthorized={isAuthorized}
onAuthNavigate={() => navigate('/register')}
onDashboardNavigate={() => navigate('/dashboard')}
onLogout={handleLogout}
onLocaleToggle={handleLocaleToggle}
onThemeToggle={handleThemeToggle}
/>
)}
<main
className={
isDashboardRoute
? styles.authorizedShell
: route === 'auth' || route === 'dashboard' || route === 'editor'
? styles.authRoute
: route === 'public'
? styles.publicRoute
: styles.landingLayout
}
<AppErrorBoundary locale={locale}>
<div className={styles.appShell}>
<div className={`${styles.orb} ${styles.orbLavender}`} />
<div className={`${styles.orb} ${styles.orbBlue}`} />
{isAuthTransitioning ? (
<AuthTransitionLoader locale={locale} reducedMotion={Boolean(prefersReducedMotion)} />
) : (
<AnimatePresence mode="wait" initial={false}>
<motion.div
className={styles.motionDiv}
key={route}
initial={prefersReducedMotion ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={prefersReducedMotion ? undefined : { opacity: 0, y: -10 }}
transition={{ duration: 0.24, ease: 'easeOut' }}
>
{isDashboardRoute ? (
authorizedContent
) : route === 'editor' && isAuthorized ? (
<div className={styles.authorizedContent}>{authorizedContent}</div>
) : route === 'auth' ? (
<AuthPage
authTab={authTab}
locale={locale}
onAuthTabChange={handleAuthTabChange}
isSubmitting={authStatus === 'loading'}
error={authError || oauthError}
onSubmit={handleAuthSubmit}
onYandexAuth={() => window.location.assign(`${API_BASE_URL}/auth/yandex`)}
/>
) : route === 'public' ? (
<PublicWidgetPage
slug={getRouteParam('/w/') ?? ''}
locale={locale}
embed={isEmbedRoute}
/>
) : (
<LandingPage
{route === 'landing' && (
<LandingHeader
locale={locale}
theme={theme}
isAuthorized={isAuthorized}
onAuthNavigate={() =>
isAuthorized ? navigate('/dashboard') : navigate('/register')
}
onAuthNavigate={() => navigate('/register')}
onDashboardNavigate={() => navigate('/dashboard')}
onLogout={handleLogout}
onLocaleToggle={handleLocaleToggle}
onThemeToggle={handleThemeToggle}
/>
)}
</main>
</motion.div>
</AnimatePresence>
)}
<ThemeReveal reveal={themeReveal} onComplete={() => setThemeReveal(null)} />
</div>
<main
className={
isDashboardRoute
? styles.authorizedShell
: route === 'auth' || isPrivateRoute(route)
? styles.authRoute
: route === 'public' || route === 'not-found'
? styles.publicRoute
: styles.landingLayout
}
>
{isDashboardRoute ? (
authorizedContent
) : route === 'account' && isAuthorized ? (
<div className={styles.authorizedContent}>
<AccountPage locale={locale} onBack={() => navigate('/dashboard')} />
</div>
) : route === 'editor' && isAuthorized ? (
<div className={styles.authorizedContent}>{authorizedContent}</div>
) : route === 'auth' ? (
<AuthPage
authTab={authTab}
locale={locale}
onAuthTabChange={handleAuthTabChange}
isSubmitting={authStatus === 'loading'}
error={authError || oauthError}
onSubmit={handleAuthSubmit}
onYandexAuth={() => window.location.assign(`${API_BASE_URL}/auth/yandex`)}
/>
) : route === 'public' ? (
<PublicWidgetPage
slug={getRouteParam('/w/') ?? ''}
locale={locale}
embed={isEmbedRoute}
onHome={() => navigate('/')}
/>
) : route === 'not-found' ? (
<ErrorPage
code={404}
locale={locale}
onHome={() => navigate(isAuthorized ? '/dashboard' : '/')}
/>
) : (
<LandingPage
locale={locale}
isAuthorized={isAuthorized}
onAuthNavigate={() =>
isAuthorized ? navigate('/dashboard') : navigate('/register')
}
/>
)}
</main>
</motion.div>
</AnimatePresence>
)}
<ThemeReveal reveal={themeReveal} onComplete={() => setThemeReveal(null)} />
</div>
</AppErrorBoundary>
</ThemeProvider>
);
};
31 changes: 31 additions & 0 deletions client/src/app/AppErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Component, type ReactNode } from 'react';

import { ErrorPage } from '@/widgets/error-page';
import type { Locale } from '@/shared/locale/content';

type AppErrorBoundaryProps = { locale: Locale; children: ReactNode };

/** Shows the 500 page instead of a blank screen when rendering throws. */
export class AppErrorBoundary extends Component<AppErrorBoundaryProps, { failed: boolean }> {
state = { failed: false };

static getDerivedStateFromError() {
return { failed: true };
}

componentDidCatch(error: unknown) {
console.error(error);
}

render() {
if (!this.state.failed) return this.props.children;
return (
<ErrorPage
code={500}
locale={this.props.locale}
onRetry={() => window.location.reload()}
onHome={() => window.location.assign('/')}
/>
);
}
}
1 change: 1 addition & 0 deletions client/src/pages/account/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { AccountPage } from '@/pages/account/ui/AccountPage';
Loading
Loading