diff --git a/frontend/PLAN.md b/frontend/PLAN.md
index 58063c1..5301aee 100644
--- a/frontend/PLAN.md
+++ b/frontend/PLAN.md
@@ -1428,9 +1428,60 @@ test('russian plural for copies', () => {
---
+### Task 12: Avatar upload QOL — success feedback, re-crop, drag&drop, progress, local history
+
+**Status (DONE 2026-09-28):** shipped. Deviations from the plan below, all deliberate:
+- The IndexedDB store (`addAvatarHistory`/`listAvatarHistory`) lives in `api.ts`, not a new `components/avatarHistory.ts` — `components/avatar/` already holds 4 files (cap), and the store is data, not a component. Entries are stored as `ArrayBuffer` (Blob does not survive `structuredClone` under Node/fake-indexeddb), rewrapped as a Blob on read.
+- `api.request` gained a sibling `api.upload(path, body, onProgress)` (XHR, same bearer/refresh/error semantics) in `shared/api/client.ts`; `refreshOnce` now also stores the renewed token (reload-restore bug found while wiring progress).
+- Success-Notice-through-the-cropper UI test replaced by recrop-visibility + drop-opens-cropper tests: jsdom stubs `canvas.getContext` to null so the cropper's confirm can't produce a blob; upload/progress/401-retry logic is covered by `shared/api/tests/client-upload.test.ts` instead.
+- Added a `success` tone to `Notice` (styled with the `ice` accent) rather than inventing a toast system.
+
+**Context (2026-09-28):** the crop/preview flow from Task 7 is live (`AvatarUpload` → `AvatarCropper` → `uploadAvatar`). Owner asked for a follow-up batch of small QOL fixes around it. `ShareCode`'s "Скопировано!" feedback (`src/shared/ui/share-code/ShareCode.tsx`) already exists — nothing to do there, just confirm it still reads well while touching this area.
+
+**Files:** modify `src/features/account/components/{AvatarUpload,AvatarCropper,ProfileCard}.tsx`, `src/features/account/api.ts`, `src/features/account/i18n/{en,ru}.ts`; create `src/features/account/components/avatarHistory.ts` (pure IndexedDB-backed store), `src/features/account/components/AvatarHistoryStrip.tsx`, `src/features/account/tests/avatarHistory.test.ts`.
+
+**1. Upload success feedback.** `AvatarUpload`'s mutation `onSuccess` currently only invalidates `['me']` and closes the cropper — no confirmation the user sees. Add a short-lived `Notice tone="success"` (`t('avatar.uploaded')`, ru: «Аватар обновлён», en: "Avatar updated"), auto-dismissed after ~2.5s (same `useEffect`+`setTimeout` pattern already used in `ShareCode` for its "copied" flag — don't invent a new toast system for one message).
+
+**2. Re-crop the current avatar.** Today `AvatarUpload` only ever crops a freshly picked `File`; there's no way to reposition the avatar you already have without re-selecting the source photo from disk (which most people won't still have handy, or don't have at all if it's not the original file). Add a "Reposition" action (`t('avatar.reposition')`, ru: «Перекадрировать») next to "Изменить", visible only when `me.avatar_url` is set, that fetches the current avatar image (`fetch(me.avatar_url)` → `blob()` → wrap as a `File`) and opens the same `AvatarCropper` on it. `AvatarCropper` already takes a `File`, so no prop-shape change there — this is purely a second entry point into the existing component.
+
+**3. Drag & drop onto the avatar circle.** `ProfileCard`'s avatar circle (the `rounded-full` `
` wrapping the `
`/initial) should accept a dropped image file directly — `onDragOver`/`onDrop` handlers that call the same `checkAvatarFile` + open-cropper path `AvatarUpload` already uses for the `
` picker. This means lifting the "picked file" state (or the validate+open-cropper function) out of `AvatarUpload` so both the drop target in `ProfileCard` and the `
` in `AvatarUpload` can trigger it — simplest shape: `AvatarUpload` keeps owning the picked-file/cropper state, and exposes an `onExternalFile(file: File)` (or similar) that `ProfileCard` calls from its drop handler via a ref/callback prop, rather than duplicating validation logic. Keep it file-drop only (no full-page drop zone, no paste-from-clipboard — out of scope for this task, note as a follow-up if wanted later).
+
+**4. Upload progress.** `uploadAvatar` (`api.ts`) currently does `api.request('/avatars', { method: 'POST', body })`, and the shared `api.request` wrapper is a plain `fetch`, which gives no upload-progress events. Getting real byte-level progress needs `XMLHttpRequest` (fetch's `ReadableStream` request bodies with progress are not reliably supported across the target browsers) — add a small dedicated `uploadAvatarWithProgress(file, onProgress: (pct: number) => void)` in `api.ts` using `XMLHttpRequest` directly (mirroring what `api.request` already does for auth/error handling: attach the bearer token, `withCredentials = true`, parse the same JSON error shape on failure) rather than routing this one call through the shared `fetch`-based client. `AvatarCropper`'s confirm button shows a slim progress bar (percentage width, no animation library) while `mutation.isPending`, driven by that callback. A 5MB file over a slow connection is the actual case this addresses; don't over-build this (no cancel/retry UI, no chunked upload — just a visible bar so the button doesn't look frozen).
+
+**5. Local avatar history (last 5), shown when you click "Изменить".** No backend support exists for this — `accounts-service`'s `avatars` table is a single row per account (`ON CONFLICT (account_id) DO UPDATE SET s3_key = ...`, `backend/accounts-service/src/accounts/repo.rs:49-55`), overwritten in place with no version history, and there is no `GET /avatars/history`-style endpoint. Building real server-side history (S3 versioning or a history table, a list/restore endpoint, a retention policy) is a `backend/PLAN.md` task, not a frontend one — flagged here as a prerequisite for a *synced* history, but not the only way to deliver the feature:
+ - **Chosen approach for this task: client-side history, not server-side.** Every successful crop (the confirmed, cropped 512×512 PNG blob — before or after upload, doesn't matter which since it's the same bytes) gets stored in IndexedDB (`avatarHistory.ts`: `addEntry(blob)`, `listEntries(): Promise<{ id: string; blob: Blob; storedAt: string }[]>`, capped at 5 by dropping the oldest on insert — a plain object store, no library, `idb`-style wrapper not needed for something this small). `AvatarHistoryStrip` renders up to 5 thumbnails (`URL.createObjectURL`, revoked on unmount) below the "Изменить"/"Перекадрировать" buttons when the history store is non-empty; clicking one re-uploads that stored blob directly (skips the cropper — it's already a cropped 512×512 PNG) through the same `uploadAvatarWithProgress` path.
+ - **Explicit limitation to document in the UI copy, not hide:** this history is per-browser (IndexedDB), not per-account — it won't show up on another device or after clearing site data, and it isn't "the last 5 avatars the account ever had" in any durable sense. Label it accordingly, e.g. `t('avatar.historyHint')` ru: «Последние на этом устройстве» / en: "Recent on this device" — don't imply server-backed history that doesn't exist.
+ - If the owner later wants true cross-device history, that's a separate backend task (new `avatar_history` table or S3-versioned keys + a `GET /avatars/history` + `POST /avatars/history/{id}/restore` contract) — out of scope here, note it in `TODO.md`/`backend/PLAN.md` when picked up, don't half-build it against a nonexistent endpoint.
+
+- [x] **Step 1: Failing tests.**
+ - `avatarHistory.test.ts` (jsdom's `fake-indexeddb` or an existing test-setup shim — check `src/test/setup.ts` for whether IndexedDB is already polyfilled for tests; add `fake-indexeddb` as a dev dependency if not): `addEntry` caps the store at 5, dropping the oldest; `listEntries` returns newest-first.
+
+```ts
+import { expect, test } from 'vitest'
+import { addEntry, listEntries } from '../components/avatarHistory'
+
+test('keeps only the 5 most recent entries, newest first', async () => {
+ const blob = (n: number) => new Blob([String(n)], { type: 'image/png' })
+ for (let i = 0; i < 7; i++) await addEntry(blob(i))
+ const entries = await listEntries()
+ expect(entries).toHaveLength(5)
+ expect(await entries[0].blob.text()).toBe('6')
+ expect(await entries[4].blob.text()).toBe('2')
+})
+```
+
+ - `avatarUpload.test.tsx` additions: confirming a crop shows the success `Notice` and it clears after the timeout (use `vi.useFakeTimers()`, matching whatever fake-timer convention nearby tests already use); "Перекадрировать" only renders when `me.avatar_url` is set; dropping a file on the avatar circle opens the cropper (simulate `fireEvent.drop` with a `DataTransfer`-shaped `files` list, following Testing Library's documented drag/drop mocking pattern since jsdom doesn't implement real DnD).
+- [x] **Step 2: Implement** the five items above.
+- [x] **Step 3: pass; commit** — `feat(frontend): avatar upload QOL — feedback, re-crop, drag&drop, progress, local history`
+
+**Tooling note (same change):** added TypeScript/Vite path aliases (`@shared`, `@features`, `@pages`, `@app`, `@test` → `src/*`) in `vite.config.ts` + `tsconfig.{app,test}.json`, and rewrote cross-feature `../../../` imports to them. Avatar components moved into `components/avatar/` to keep every folder at ≤4 files. `api.ts` grew the IndexedDB avatar-history store.
+
+---
+
## Deferred
- Admin panel (`/admin`) — Подсистема 3 plan.
- Friends/chat/presence UI — Подсистема 2 plan.
- Showcase search/filters, config diff preview — after real usage.
- i18n (English UI) — not requested.
+- **Server-side avatar history** (cross-device, durable) — needs `backend/PLAN.md` work first (new table or S3-versioned keys, list/restore endpoints); Task 12 ships a client-only (IndexedDB, this-device-only) version instead.
diff --git a/frontend/bun.lock b/frontend/bun.lock
index 5484d6a..2ded820 100644
--- a/frontend/bun.lock
+++ b/frontend/bun.lock
@@ -28,6 +28,7 @@
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
+ "fake-indexeddb": "^6.2.5",
"jsdom": "^30.1.1",
"oxlint": "^1.81.0",
"typescript": "^7.0.2",
@@ -335,6 +336,8 @@
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
+ "fake-indexeddb": ["fake-indexeddb@6.2.5", "", {}, "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w=="],
+
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"framer-motion": ["framer-motion@13.4.3", "", { "dependencies": { "motion-dom": "^13.4.2", "motion-utils": "^13.3.0", "tslib": "^2.4.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-mPQe1GtRHWS30zRGaQAfmlIg0baA0jRhkpbFPT1d+s9bZPjMW6pJSjWM6ArmUEjzMgEm089ZL1wd8aPv1/5DJQ=="],
diff --git a/frontend/package.json b/frontend/package.json
index 659389e..75fbe66 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -36,6 +36,7 @@
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
+ "fake-indexeddb": "^6.2.5",
"jsdom": "^30.1.1",
"oxlint": "^1.81.0",
"typescript": "^7.0.2",
diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx
index fda517e..1c33c05 100644
--- a/frontend/src/app/App.tsx
+++ b/frontend/src/app/App.tsx
@@ -1,7 +1,7 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createBrowserRouter, RouterProvider } from 'react-router'
-import { SessionProvider } from '../features/auth/session'
-import { ActiveThemeProvider } from '../features/themes/useActiveTheme'
+import { SessionProvider } from '@features/auth/session'
+import { ActiveThemeProvider } from '@features/themes/useActiveTheme'
import { routes } from './routes'
const queryClient = new QueryClient({
diff --git a/frontend/src/app/i18n.ts b/frontend/src/app/i18n.ts
index b289c95..f7c32c8 100644
--- a/frontend/src/app/i18n.ts
+++ b/frontend/src/app/i18n.ts
@@ -1,28 +1,28 @@
import i18next, { type i18n } from 'i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import { initReactI18next } from 'react-i18next'
-import { accountEn } from '../features/account/i18n/en'
-import { accountRu } from '../features/account/i18n/ru'
-import { authEn } from '../features/auth/i18n/en'
-import { authRu } from '../features/auth/i18n/ru'
-import { clickguiEn } from '../features/clickgui/i18n/en'
-import { configsEn } from '../features/configs/i18n/en'
-import { clickguiRu } from '../features/clickgui/i18n/ru'
-import { configsRu } from '../features/configs/i18n/ru'
-import { deviceLinkEn } from '../features/device-link/i18n/en'
-import { deviceLinkRu } from '../features/device-link/i18n/ru'
-import { downloadEn } from '../features/download/i18n/en'
-import { profileEn } from '../features/profile/i18n/en'
-import { profileRu } from '../features/profile/i18n/ru'
-import { showcaseEn } from '../features/showcase/i18n/en'
-import { downloadRu } from '../features/download/i18n/ru'
-import { showcaseRu } from '../features/showcase/i18n/ru'
-import { landingEn } from '../features/landing/i18n/en'
-import { landingRu } from '../features/landing/i18n/ru'
-import { themesEn } from '../features/themes/i18n/en'
-import { themesRu } from '../features/themes/i18n/ru'
-import { commonEn } from '../shared/i18n/common.en'
-import { commonRu } from '../shared/i18n/common.ru'
+import { accountEn } from '@features/account/i18n/en'
+import { accountRu } from '@features/account/i18n/ru'
+import { authEn } from '@features/auth/i18n/en'
+import { authRu } from '@features/auth/i18n/ru'
+import { clickguiEn } from '@features/clickgui/i18n/en'
+import { configsEn } from '@features/configs/i18n/en'
+import { clickguiRu } from '@features/clickgui/i18n/ru'
+import { configsRu } from '@features/configs/i18n/ru'
+import { deviceLinkEn } from '@features/device-link/i18n/en'
+import { deviceLinkRu } from '@features/device-link/i18n/ru'
+import { downloadEn } from '@features/download/i18n/en'
+import { profileEn } from '@features/profile/i18n/en'
+import { profileRu } from '@features/profile/i18n/ru'
+import { showcaseEn } from '@features/showcase/i18n/en'
+import { downloadRu } from '@features/download/i18n/ru'
+import { showcaseRu } from '@features/showcase/i18n/ru'
+import { landingEn } from '@features/landing/i18n/en'
+import { landingRu } from '@features/landing/i18n/ru'
+import { themesEn } from '@features/themes/i18n/en'
+import { themesRu } from '@features/themes/i18n/ru'
+import { commonEn } from '@shared/i18n/common.en'
+import { commonRu } from '@shared/i18n/common.ru'
/** Creates and initializes an i18next instance. Each call is independent, so tests can run in isolation. */
export function initI18n(lng?: 'ru' | 'en'): i18n {
diff --git a/frontend/src/app/i18next.d.ts b/frontend/src/app/i18next.d.ts
index 4fb3309..c4c8504 100644
--- a/frontend/src/app/i18next.d.ts
+++ b/frontend/src/app/i18next.d.ts
@@ -1,14 +1,14 @@
-import type { accountRu } from '../features/account/i18n/ru'
-import type { authRu } from '../features/auth/i18n/ru'
-import type { clickguiRu } from '../features/clickgui/i18n/ru'
-import type { configsRu } from '../features/configs/i18n/ru'
-import type { deviceLinkRu } from '../features/device-link/i18n/ru'
-import type { downloadRu } from '../features/download/i18n/ru'
-import type { landingRu } from '../features/landing/i18n/ru'
-import type { profileRu } from '../features/profile/i18n/ru'
-import type { showcaseRu } from '../features/showcase/i18n/ru'
-import type { themesRu } from '../features/themes/i18n/ru'
-import type { commonRu } from '../shared/i18n/common.ru'
+import type { accountRu } from '@features/account/i18n/ru'
+import type { authRu } from '@features/auth/i18n/ru'
+import type { clickguiRu } from '@features/clickgui/i18n/ru'
+import type { configsRu } from '@features/configs/i18n/ru'
+import type { deviceLinkRu } from '@features/device-link/i18n/ru'
+import type { downloadRu } from '@features/download/i18n/ru'
+import type { landingRu } from '@features/landing/i18n/ru'
+import type { profileRu } from '@features/profile/i18n/ru'
+import type { showcaseRu } from '@features/showcase/i18n/ru'
+import type { themesRu } from '@features/themes/i18n/ru'
+import type { commonRu } from '@shared/i18n/common.ru'
declare module 'i18next' {
interface CustomTypeOptions {
diff --git a/frontend/src/app/routes.tsx b/frontend/src/app/routes.tsx
index 5c2903c..25e88c8 100644
--- a/frontend/src/app/routes.tsx
+++ b/frontend/src/app/routes.tsx
@@ -1,19 +1,19 @@
import type { RouteObject } from 'react-router'
-import { RequireAuth } from '../features/auth/RequireAuth'
-import { AppShell } from '../shared/layout/AppShell'
-import HomePage from '../pages/public/HomePage'
-import LoginPage from '../pages/public/auth/LoginPage'
-import RegisterPage from '../pages/public/auth/RegisterPage'
-import ProfilePage from '../pages/public/ProfilePage'
-import NotFoundPage from '../pages/public/NotFoundPage'
+import { RequireAuth } from '@features/auth/RequireAuth'
+import { AppShell } from '@shared/layout/AppShell'
+import HomePage from '@pages/public/HomePage'
+import LoginPage from '@pages/public/auth/LoginPage'
+import RegisterPage from '@pages/public/auth/RegisterPage'
+import ProfilePage from '@pages/public/ProfilePage'
+import NotFoundPage from '@pages/public/NotFoundPage'
// Code-split: the download page pulls in react-markdown, so it loads on demand.
-import DownloadRoute from '../pages/download/DownloadRoute'
-import AccountPage from '../pages/app/AccountPage'
-import LinkPage from '../pages/app/LinkPage'
-import ThemesRoute from '../pages/themes/ThemesRoute'
-import ConfigsPage from '../pages/app/ConfigsPage'
-import ShowcaseRoute from '../pages/showcase/ShowcaseRoute'
-import ListingRoute from '../pages/showcase/ListingRoute'
+import DownloadRoute from '@pages/download/DownloadRoute'
+import AccountPage from '@pages/app/AccountPage'
+import LinkPage from '@pages/app/LinkPage'
+import ThemesRoute from '@pages/themes/ThemesRoute'
+import ConfigsPage from '@pages/app/ConfigsPage'
+import ShowcaseRoute from '@pages/showcase/ShowcaseRoute'
+import ListingRoute from '@pages/showcase/ListingRoute'
export const routes: RouteObject[] = [
{
diff --git a/frontend/src/features/account/api.ts b/frontend/src/features/account/api.ts
index 48dbc04..6350f76 100644
--- a/frontend/src/features/account/api.ts
+++ b/frontend/src/features/account/api.ts
@@ -1,4 +1,4 @@
-import { api } from '../../shared/api/client'
+import { api } from '@shared/api/client'
import type { TFunction } from 'i18next'
export type DeviceLink = { id: string; linked_at: string; last_seen: string | null }
@@ -19,6 +19,81 @@ export async function uploadAvatar(file: File): Promise<{ avatar_url: string }>
return api.request('/avatars', { method: 'POST', body })
}
+/** Byte-level progress comes only from XHR (see `api.upload`), not from fetch. */
+export function uploadAvatarWithProgress(file: File, onProgress: (pct: number) => void): Promise<{ avatar_url: string }> {
+ const body = new FormData()
+ body.set('file', file)
+ return api.upload('/avatars', body, onProgress) as Promise<{ avatar_url: string }>
+}
+
+/** Downloads the current avatar so it can be re-cropped without re-picking the source file. */
+export async function fetchCurrentAvatarAsFile(url: string): Promise
{
+ const res = await fetch(url)
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
+ return new File([await res.blob()], 'avatar.png', { type: 'image/png' })
+}
+
+const DB_NAME = 'lovisual-avatars'
+const STORE = 'history'
+const HISTORY_LIMIT = 5
+
+export type AvatarHistoryEntry = { id: number; blob: Blob; storedAt: string }
+
+// IndexedDB structured-clone does not reliably persist a Blob (Node/fake-indexeddb drop it), so the
+// PNG is stored as an ArrayBuffer — universally cloneable — and rewrapped as a Blob on read.
+type AvatarRecord = { id: number; data: ArrayBuffer; storedAt: string }
+
+function openAvatarDb(): Promise {
+ return new Promise((resolve, reject) => {
+ const open = indexedDB.open(DB_NAME, 1)
+ open.onupgradeneeded = () => {
+ if (!open.result.objectStoreNames.contains(STORE)) open.result.createObjectStore(STORE, { keyPath: 'id', autoIncrement: true })
+ }
+ open.onsuccess = () => resolve(open.result)
+ open.onerror = () => reject(open.error)
+ })
+}
+
+function readAll(db: IDBDatabase): Promise {
+ return new Promise((resolve, reject) => {
+ const req = db.transaction(STORE).objectStore(STORE).getAll()
+ req.onsuccess = () => resolve(req.result as AvatarRecord[])
+ req.onerror = () => reject(req.error)
+ })
+}
+
+/** Stores a cropped avatar locally (this device only) and trims the history to the newest few. */
+export async function addAvatarHistory(blob: Blob): Promise {
+ const db = await openAvatarDb()
+ const data = await blob.arrayBuffer()
+ await new Promise((resolve, reject) => {
+ const tx = db.transaction(STORE, 'readwrite')
+ tx.objectStore(STORE).put({ data, storedAt: new Date().toISOString() })
+ tx.oncomplete = () => resolve()
+ tx.onerror = () => reject(tx.error)
+ })
+ // Same-ms inserts are indistinguishable by timestamp, so keep the last 5 by insertion order.
+ const records = await readAll(db)
+ const stale = records.slice(0, Math.max(0, records.length - HISTORY_LIMIT))
+ if (stale.length === 0) return
+ await new Promise((resolve, reject) => {
+ const tx = db.transaction(STORE, 'readwrite')
+ for (const r of stale) tx.objectStore(STORE).delete(r.id)
+ tx.oncomplete = () => resolve()
+ tx.onerror = () => reject(tx.error)
+ })
+}
+
+/** Newest-first history. */
+export async function listAvatarHistory(): Promise {
+ const db = await openAvatarDb()
+ const records = await readAll(db)
+ return records
+ .sort((a, b) => b.storedAt.localeCompare(a.storedAt) || b.id - a.id)
+ .slice(0, HISTORY_LIMIT)
+ .map(({ id, data, storedAt }) => ({ id, storedAt, blob: new Blob([data], { type: 'image/png' }) }))
+}
+
export async function listDevices(): Promise {
return api.request('/device/links')
}
diff --git a/frontend/src/features/account/components/AvatarUpload.tsx b/frontend/src/features/account/components/AvatarUpload.tsx
deleted file mode 100644
index 4d1b1fe..0000000
--- a/frontend/src/features/account/components/AvatarUpload.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-import { useMutation, useQueryClient } from '@tanstack/react-query'
-import { useId, useState, type ChangeEvent } from 'react'
-import { useTranslation } from 'react-i18next'
-import { Notice } from '../../../shared/ui/Notice'
-import { checkAvatarFile, uploadAvatar } from '../api'
-import { AvatarCropper } from './AvatarCropper'
-
-/** A label-based file picker (keyboard reachable) that opens a crop step before uploading and refreshing the cached profile. */
-export function AvatarUpload() {
- const { t } = useTranslation('account')
- const id = useId()
- const client = useQueryClient()
- const [error, setError] = useState()
- const [picked, setPicked] = useState()
- const mutation = useMutation({
- mutationFn: uploadAvatar,
- onSuccess: () => {
- client.invalidateQueries({ queryKey: ['me'] })
- setPicked(undefined)
- },
- })
-
- function onChange(e: ChangeEvent) {
- const file = e.target.files?.[0]
- e.target.value = ''
- if (!file) return
- const problem = checkAvatarFile(file, t)
- if (problem) {
- setError(problem)
- return
- }
- setError(undefined)
- setPicked(file)
- }
-
- function onConfirmCrop(blob: Blob) {
- const cropped = new File([blob], 'avatar.png', { type: 'image/png' })
- mutation.mutate(cropped)
- }
-
- if (picked) {
- return (
-
-
setPicked(undefined)} onConfirm={onConfirmCrop} />
- {error ? {error} : null}
-
- )
- }
-
- return (
-
-
- {t('avatar.change')}
-
-
- {error ? {error} : null}
-
- )
-}
diff --git a/frontend/src/features/account/components/DeviceList.tsx b/frontend/src/features/account/components/DeviceList.tsx
index 8a163ed..fe3aca9 100644
--- a/frontend/src/features/account/components/DeviceList.tsx
+++ b/frontend/src/features/account/components/DeviceList.tsx
@@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router'
-import { Button } from '../../../shared/ui/Button'
+import { Button } from '@shared/ui/Button'
import { listDevices, revokeDevice, type DeviceLink } from '../api'
function relativeDate(iso: string): string {
diff --git a/frontend/src/features/account/components/ProfileCard.tsx b/frontend/src/features/account/components/ProfileCard.tsx
index 6725a66..123c40f 100644
--- a/frontend/src/features/account/components/ProfileCard.tsx
+++ b/frontend/src/features/account/components/ProfileCard.tsx
@@ -1,14 +1,22 @@
+import { useCallback, useRef, useState, type DragEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router'
-import { Button } from '../../../shared/ui/Button'
-import { useSession } from '../../auth/session'
-import { AvatarUpload } from './AvatarUpload'
+import { Button } from '@shared/ui/Button'
+import { useSession } from '@features/auth/session'
+import { AvatarUpload } from './avatar/AvatarUpload'
-/** The mod's profile panel: big avatar with an accent ring, nick, email and join date. */
+/** The mod's profile panel: big avatar with an accent ring, nick, email and join date. The avatar circle doubles as a file drop target. */
export function ProfileCard() {
const { t } = useTranslation('account')
const { me, signOut } = useSession()
const navigate = useNavigate()
+ const [dragging, setDragging] = useState(false)
+ // AvatarUpload owns pick/cropper state; the drop handler just forwards the file into its validation path.
+ const acceptFile = useRef<(file: File) => void>(() => {})
+ // Stable handlers so AvatarUpload's expose-effect does not re-run on every parent render.
+ const handleReady = useCallback((accept: (file: File) => void) => {
+ acceptFile.current = accept
+ }, [])
if (!me) return null
@@ -19,9 +27,25 @@ export function ProfileCard() {
navigate('/')
}
+ function onDrop(e: DragEvent) {
+ e.preventDefault()
+ setDragging(false)
+ const file = e.dataTransfer.files?.[0]
+ if (file) acceptFile.current(file)
+ }
+
return (
-
+
{
+ e.preventDefault()
+ setDragging(true)
+ }}
+ onDragLeave={() => setDragging(false)}
+ onDrop={onDrop}
+ >
{me.avatar_url ? (
@@ -30,7 +54,7 @@ export function ProfileCard() {
)}
-
+
{me.display_nick}
{me.email}
diff --git a/frontend/src/features/account/components/AvatarCropper.tsx b/frontend/src/features/account/components/avatar/AvatarCropper.tsx
similarity index 98%
rename from frontend/src/features/account/components/AvatarCropper.tsx
rename to frontend/src/features/account/components/avatar/AvatarCropper.tsx
index a7ed12a..378348e 100644
--- a/frontend/src/features/account/components/AvatarCropper.tsx
+++ b/frontend/src/features/account/components/avatar/AvatarCropper.tsx
@@ -1,6 +1,6 @@
import { useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'
import { useTranslation } from 'react-i18next'
-import { Button } from '../../../shared/ui/Button'
+import { Button } from '@shared/ui/Button'
import { clampPan, clampZoom, coverScale, MAX_ZOOM, MIN_ZOOM, sourceRect } from './cropMath'
const VIEWPORT = 240
diff --git a/frontend/src/features/account/components/avatar/AvatarHistoryStrip.tsx b/frontend/src/features/account/components/avatar/AvatarHistoryStrip.tsx
new file mode 100644
index 0000000..559839e
--- /dev/null
+++ b/frontend/src/features/account/components/avatar/AvatarHistoryStrip.tsx
@@ -0,0 +1,60 @@
+import { useEffect, useMemo, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { listAvatarHistory, type AvatarHistoryEntry } from '@features/account/api'
+
+type Props = {
+ /** Bumped by the parent after each successful upload so the store is re-read. */
+ tick: number
+ pending: boolean
+ onPick: (blob: Blob) => void
+}
+
+/** The last few avatars cropped on this device (IndexedDB only, not the account's full history). */
+export function AvatarHistoryStrip({ tick, pending, onPick }: Props) {
+ const { t } = useTranslation('account')
+ const [entries, setEntries] = useState
([])
+
+ useEffect(() => {
+ let active = true
+ listAvatarHistory()
+ .then((list) => active && setEntries(list))
+ .catch(() => active && setEntries([]))
+ return () => {
+ active = false
+ }
+ }, [tick])
+
+ if (entries.length === 0) return null
+
+ return (
+
+
{t('avatar.historyHint')}
+
+ {entries.map((entry) => (
+
+
+
+ ))}
+
+
+ )
+}
+
+function HistoryThumb({ entry, disabled, onPick }: { entry: AvatarHistoryEntry; disabled: boolean; onPick: (blob: Blob) => void }) {
+ const { t } = useTranslation('account')
+ const url = useMemo(() => URL.createObjectURL(entry.blob), [entry])
+ useEffect(() => () => URL.revokeObjectURL(url), [url])
+
+ return (
+ onPick(entry.blob)}
+ className="h-10 w-10 cursor-pointer overflow-hidden rounded-full border border-shoal opacity-80 hover:border-ice hover:opacity-100 disabled:cursor-default"
+ >
+ {url ? : null}
+
+ )
+}
diff --git a/frontend/src/features/account/components/avatar/AvatarUpload.tsx b/frontend/src/features/account/components/avatar/AvatarUpload.tsx
new file mode 100644
index 0000000..4683a2f
--- /dev/null
+++ b/frontend/src/features/account/components/avatar/AvatarUpload.tsx
@@ -0,0 +1,145 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { useCallback, useEffect, useId, useState, type ChangeEvent } from 'react'
+import { useTranslation } from 'react-i18next'
+import { Notice } from '@shared/ui/Notice'
+import { useSession } from '@features/auth/session'
+import {
+ addAvatarHistory,
+ checkAvatarFile,
+ fetchCurrentAvatarAsFile,
+ uploadAvatarWithProgress,
+} from '@features/account/api'
+import { AvatarCropper } from './AvatarCropper'
+import { AvatarHistoryStrip } from './AvatarHistoryStrip'
+
+/** Picker + drop target + history strip: validates a file, opens the cropper, uploads it with progress and confirms in place. */
+export function AvatarUpload({ onReady }: { onReady?: (acceptFile: (file: File) => void) => void } = {}) {
+ const { t } = useTranslation('account')
+ const { me } = useSession()
+ const id = useId()
+ const client = useQueryClient()
+ const [error, setError] = useState()
+ const [picked, setPicked] = useState()
+ const [progress, setProgress] = useState(null)
+ const [done, setDone] = useState(false)
+ const [historyTick, setHistoryTick] = useState(0)
+ const mutation = useMutation({
+ mutationFn: (file: File) => uploadAvatarWithProgress(file, setProgress),
+ onSuccess: (_data, file) => {
+ client.invalidateQueries({ queryKey: ['me'] })
+ void addAvatarHistory(file)
+ setHistoryTick((n) => n + 1)
+ setPicked(undefined)
+ setProgress(null)
+ setDone(true)
+ },
+ onError: (err: Error) => {
+ setProgress(null)
+ setError(err.message)
+ },
+ })
+
+ useEffect(() => {
+ if (!done) return
+ const timer = setTimeout(() => setDone(false), 2500)
+ return () => clearTimeout(timer)
+ }, [done])
+
+ const validatePick = useCallback(
+ (file: File) => {
+ const problem = checkAvatarFile(file, t)
+ if (problem) {
+ setError(problem)
+ return
+ }
+ setError(undefined)
+ setPicked(file)
+ },
+ [t],
+ )
+ // Hand the validate+open-cropper path to ProfileCard's drop target without duplicating it.
+ useEffect(() => {
+ onReady?.(validatePick)
+ }, [onReady, validatePick])
+
+ function onChange(e: ChangeEvent) {
+ const file = e.target.files?.[0]
+ e.target.value = ''
+ if (file) validatePick(file)
+ }
+
+ async function recropCurrent() {
+ if (!me?.avatar_url) return
+ try {
+ setPicked(await fetchCurrentAvatarAsFile(me.avatar_url))
+ setError(undefined)
+ } catch {
+ setError(t('avatar.fetchFailed'))
+ }
+ }
+
+ function onConfirmCrop(blob: Blob) {
+ mutation.mutate(new File([blob], 'avatar.png', { type: 'image/png' }))
+ }
+
+ if (picked) {
+ return (
+
+
setPicked(undefined)} onConfirm={onConfirmCrop} />
+ {progress !== null && }
+ {error ? {error} : null}
+
+ )
+ }
+
+ return (
+
+
+
+ {t('avatar.change')}
+
+
+ {me?.avatar_url ? (
+
+ {t('avatar.recrop')}
+
+ ) : null}
+
+ {progress !== null &&
}
+ {done ?
{t('avatar.uploaded')} : null}
+ {error ?
{error} : null}
+
mutation.mutate(new File([blob], 'avatar.png', { type: 'image/png' }))}
+ />
+
+ )
+}
+
+function ProgressBar({ pct }: { pct: number }) {
+ return (
+
+ )
+}
diff --git a/frontend/src/features/account/components/cropMath.ts b/frontend/src/features/account/components/avatar/cropMath.ts
similarity index 100%
rename from frontend/src/features/account/components/cropMath.ts
rename to frontend/src/features/account/components/avatar/cropMath.ts
diff --git a/frontend/src/features/account/i18n/en.ts b/frontend/src/features/account/i18n/en.ts
index 3928f2e..5990dd1 100644
--- a/frontend/src/features/account/i18n/en.ts
+++ b/frontend/src/features/account/i18n/en.ts
@@ -1,4 +1,4 @@
-import type { Translation } from '../../../shared/i18n/common.ru'
+import type { Translation } from '@shared/i18n/common.ru'
import type { accountRu } from './ru'
export const accountEn = {
@@ -8,6 +8,11 @@ export const accountEn = {
},
avatar: {
change: 'Change avatar',
+ recrop: 'Reposition',
+ uploaded: 'Avatar updated',
+ fetchFailed: 'Could not load the current avatar.',
+ historyHint: 'Recent on this device',
+ useHistory: 'Restore this avatar',
reposition: 'Drag to reposition',
zoom: 'Zoom',
confirm: 'Confirm',
diff --git a/frontend/src/features/account/i18n/ru.ts b/frontend/src/features/account/i18n/ru.ts
index b83cc24..8f4d1e9 100644
--- a/frontend/src/features/account/i18n/ru.ts
+++ b/frontend/src/features/account/i18n/ru.ts
@@ -5,6 +5,11 @@ export const accountRu = {
},
avatar: {
change: 'Сменить аватар',
+ recrop: 'Перекадрировать',
+ uploaded: 'Аватар обновлён',
+ fetchFailed: 'Не удалось загрузить текущий аватар.',
+ historyHint: 'Последние на этом устройстве',
+ useHistory: 'Вернуть этот аватар',
reposition: 'Перетащи, чтобы выбрать область',
zoom: 'Масштаб',
confirm: 'Подтвердить',
diff --git a/frontend/src/features/account/tests/account.test.tsx b/frontend/src/features/account/tests/account.test.tsx
index 4b95e02..2cdd4ce 100644
--- a/frontend/src/features/account/tests/account.test.tsx
+++ b/frontend/src/features/account/tests/account.test.tsx
@@ -3,9 +3,9 @@ import userEvent from '@testing-library/user-event'
import { getI18n } from 'react-i18next'
import { expect, test } from 'vitest'
import { checkAvatarFile } from '../api'
-import { DeviceList } from '../components/DeviceList'
-import { renderApp } from '../../../test/render'
-import { json, mockFetch } from '../../../test/fetch'
+import { DeviceList } from '@features/account/components/DeviceList'
+import { renderApp } from '@test/render'
+import { json, mockFetch } from '@test/fetch'
test('avatar file checks', () => {
const t = getI18n().getFixedT('ru', 'account')
diff --git a/frontend/src/features/account/tests/avatarHistory.test.ts b/frontend/src/features/account/tests/avatarHistory.test.ts
new file mode 100644
index 0000000..534da4c
--- /dev/null
+++ b/frontend/src/features/account/tests/avatarHistory.test.ts
@@ -0,0 +1,16 @@
+import 'fake-indexeddb/auto'
+import { beforeEach, expect, test } from 'vitest'
+import { addAvatarHistory, listAvatarHistory } from '../api'
+
+beforeEach(async () => {
+ const { indexedDB } = globalThis
+ indexedDB.deleteDatabase('lovisual-avatars')
+})
+
+test('keeps only the 5 most recent entries, newest first', async () => {
+ for (let i = 0; i < 7; i++) await addAvatarHistory(new Blob([String(i)], { type: 'image/png' }))
+ const entries = await listAvatarHistory()
+ expect(entries).toHaveLength(5)
+ expect(await entries[0].blob.text()).toBe('6')
+ expect(await entries[4].blob.text()).toBe('2')
+})
diff --git a/frontend/src/features/account/tests/avatarUpload.test.tsx b/frontend/src/features/account/tests/avatarUpload.test.tsx
index 56fb696..7c40be9 100644
--- a/frontend/src/features/account/tests/avatarUpload.test.tsx
+++ b/frontend/src/features/account/tests/avatarUpload.test.tsx
@@ -1,9 +1,19 @@
-import { screen } from '@testing-library/react'
+import { fireEvent, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, expect, test, vi } from 'vitest'
-import { AvatarUpload } from '../components/AvatarUpload'
-import { renderApp } from '../../../test/render'
-import { json, mockFetch } from '../../../test/fetch'
+import { AvatarUpload } from '@features/account/components/avatar/AvatarUpload'
+import { ProfileCard } from '@features/account/components/ProfileCard'
+import { renderApp } from '@test/render'
+import { json, mockFetch } from '@test/fetch'
+
+const me = (avatar_url: string | null) => ({
+ id: '1',
+ email: 'a@b.c',
+ display_nick: 'Rider',
+ role: 'user',
+ avatar_url,
+ created_at: '2026-01-01T00:00:00Z',
+})
beforeEach(() => {
URL.createObjectURL = vi.fn(() => 'blob:mock')
@@ -45,3 +55,34 @@ test('cancelling the crop step returns to the picker without uploading', async (
expect(await screen.findByText('Сменить аватар')).toBeInTheDocument()
expect(fetch.mock.calls.some(([url]) => String(url).includes('/avatars'))).toBe(false)
})
+
+test('the reposition button is hidden when no avatar is set', async () => {
+ mockFetch({
+ 'POST /auth/refresh': () => json({ access_token: 't' }),
+ 'GET /me': () => json(me(null)),
+ })
+ renderApp([{ path: '/', element: }])
+ expect(await screen.findByText('Сменить аватар')).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Перекадрировать' })).not.toBeInTheDocument()
+})
+
+test('the reposition button shows when an avatar is set', async () => {
+ mockFetch({
+ 'POST /auth/refresh': () => json({ access_token: 't' }),
+ 'GET /me': () => json(me('https://cdn/avatar.png')),
+ })
+ renderApp([{ path: '/', element: }])
+ expect(await screen.findByRole('button', { name: 'Перекадрировать' })).toBeInTheDocument()
+})
+
+test('dropping a file on the avatar circle opens the crop step', async () => {
+ mockFetch({
+ 'POST /auth/refresh': () => json({ access_token: 't' }),
+ 'GET /me': () => json(me(null)),
+ })
+ renderApp([{ path: '/', element: }])
+ const target = await screen.findByTestId('avatar-drop')
+ fireEvent.drop(target, { dataTransfer: { files: [new File(['x'], 'a.png', { type: 'image/png' })] } })
+
+ expect(await screen.findByRole('button', { name: 'Подтвердить' })).toBeInTheDocument()
+})
diff --git a/frontend/src/features/account/tests/cropMath.test.ts b/frontend/src/features/account/tests/cropMath.test.ts
index a2c4359..927f330 100644
--- a/frontend/src/features/account/tests/cropMath.test.ts
+++ b/frontend/src/features/account/tests/cropMath.test.ts
@@ -1,5 +1,5 @@
import { expect, test } from 'vitest'
-import { clampPan, clampZoom, coverScale, sourceRect } from '../components/cropMath'
+import { clampPan, clampZoom, coverScale, sourceRect } from '@features/account/components/avatar/cropMath'
test('coverScale scales the shorter side to fill the viewport', () => {
expect(coverScale(1000, 2000, 200)).toBeCloseTo(0.2)
diff --git a/frontend/src/features/auth/api.ts b/frontend/src/features/auth/api.ts
index 0f91b07..776c86a 100644
--- a/frontend/src/features/auth/api.ts
+++ b/frontend/src/features/auth/api.ts
@@ -1,5 +1,5 @@
-import { api } from '../../shared/api/client'
-import type { Me } from '../../shared/types'
+import { api } from '@shared/api/client'
+import type { Me } from '@shared/types'
export async function login(email: string, password: string): Promise {
const res = await api.request<{ access_token: string }>('/auth/login', { method: 'POST', json: { email, password } })
diff --git a/frontend/src/features/auth/forms/LoginForm.tsx b/frontend/src/features/auth/forms/LoginForm.tsx
index 5c5353b..a6e0f57 100644
--- a/frontend/src/features/auth/forms/LoginForm.tsx
+++ b/frontend/src/features/auth/forms/LoginForm.tsx
@@ -1,10 +1,10 @@
import { useState, type FormEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { Link, useLocation, useNavigate } from 'react-router'
-import { describeError } from '../../../shared/api/errors'
-import { Button } from '../../../shared/ui/Button'
-import { Notice } from '../../../shared/ui/Notice'
-import { TextField } from '../../../shared/ui/text-field/TextField'
+import { describeError } from '@shared/api/errors'
+import { Button } from '@shared/ui/Button'
+import { Notice } from '@shared/ui/Notice'
+import { TextField } from '@shared/ui/text-field/TextField'
import { useSession } from '../session'
export function LoginForm() {
diff --git a/frontend/src/features/auth/forms/RegisterForm.tsx b/frontend/src/features/auth/forms/RegisterForm.tsx
index d393c47..b1f4aa0 100644
--- a/frontend/src/features/auth/forms/RegisterForm.tsx
+++ b/frontend/src/features/auth/forms/RegisterForm.tsx
@@ -1,10 +1,10 @@
import { useState, type FormEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { Link, useNavigate } from 'react-router'
-import { describeError } from '../../../shared/api/errors'
-import { Button } from '../../../shared/ui/Button'
-import { Notice } from '../../../shared/ui/Notice'
-import { TextField } from '../../../shared/ui/text-field/TextField'
+import { describeError } from '@shared/api/errors'
+import { Button } from '@shared/ui/Button'
+import { Notice } from '@shared/ui/Notice'
+import { TextField } from '@shared/ui/text-field/TextField'
import { useSession } from '../session'
import { validateEmail, validateNick, validatePassword } from '../validation'
diff --git a/frontend/src/features/auth/i18n/en.ts b/frontend/src/features/auth/i18n/en.ts
index e49d6e0..c59f265 100644
--- a/frontend/src/features/auth/i18n/en.ts
+++ b/frontend/src/features/auth/i18n/en.ts
@@ -1,4 +1,4 @@
-import type { Translation } from '../../../shared/i18n/common.ru'
+import type { Translation } from '@shared/i18n/common.ru'
import type { authRu } from './ru'
export const authEn = {
diff --git a/frontend/src/features/auth/session.tsx b/frontend/src/features/auth/session.tsx
index 70ccd90..b8fc813 100644
--- a/frontend/src/features/auth/session.tsx
+++ b/frontend/src/features/auth/session.tsx
@@ -1,7 +1,7 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { createContext, useCallback, useContext, useMemo, type ReactNode } from 'react'
-import { api } from '../../shared/api/client'
-import type { Me } from '../../shared/types'
+import { api } from '@shared/api/client'
+import type { Me } from '@shared/types'
import { fetchMe, login, logout, register } from './api'
type Session = {
diff --git a/frontend/src/features/auth/tests/forms.test.tsx b/frontend/src/features/auth/tests/forms.test.tsx
index be0c5fc..a1d2847 100644
--- a/frontend/src/features/auth/tests/forms.test.tsx
+++ b/frontend/src/features/auth/tests/forms.test.tsx
@@ -3,8 +3,8 @@ import userEvent from '@testing-library/user-event'
import { expect, test } from 'vitest'
import { LoginForm } from '../forms/LoginForm'
import { RegisterForm } from '../forms/RegisterForm'
-import { renderApp } from '../../../test/render'
-import { json, mockFetch } from '../../../test/fetch'
+import { renderApp } from '@test/render'
+import { json, mockFetch } from '@test/fetch'
const me = { id: '1', email: 'a@b.c', display_nick: 'Rider', role: 'user', avatar_url: null, created_at: '2026-09-24T00:00:00Z' }
const anon = { 'POST /auth/refresh': () => json({ error: 'unauthorized' }, 401) }
diff --git a/frontend/src/features/auth/tests/session.test.tsx b/frontend/src/features/auth/tests/session.test.tsx
index 967c96a..981d1a6 100644
--- a/frontend/src/features/auth/tests/session.test.tsx
+++ b/frontend/src/features/auth/tests/session.test.tsx
@@ -2,8 +2,8 @@ import { screen } from '@testing-library/react'
import { expect, test } from 'vitest'
import { RequireAuth } from '../RequireAuth'
import { useSession } from '../session'
-import { renderApp } from '../../../test/render'
-import { json, mockFetch } from '../../../test/fetch'
+import { renderApp } from '@test/render'
+import { json, mockFetch } from '@test/fetch'
const me = { id: '1', email: 'a@b.c', display_nick: 'Rider', role: 'user', avatar_url: null, created_at: '2026-09-24T00:00:00Z' }
diff --git a/frontend/src/features/clickgui/ClickGuiWindow.tsx b/frontend/src/features/clickgui/ClickGuiWindow.tsx
index 12b9187..f1b34af 100644
--- a/frontend/src/features/clickgui/ClickGuiWindow.tsx
+++ b/frontend/src/features/clickgui/ClickGuiWindow.tsx
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
-import { prefersReducedMotion } from '../../shared/motion/reducedMotion'
+import { prefersReducedMotion } from '@shared/motion/reducedMotion'
import type { ThemeEntry } from '../themes/types'
import { useActiveTheme } from '../themes/useActiveTheme'
import { DEMO, useDemo } from './demo'
diff --git a/frontend/src/features/clickgui/i18n/en.ts b/frontend/src/features/clickgui/i18n/en.ts
index d33f76d..b3b057d 100644
--- a/frontend/src/features/clickgui/i18n/en.ts
+++ b/frontend/src/features/clickgui/i18n/en.ts
@@ -1,4 +1,4 @@
-import type { Translation } from '../../../shared/i18n/common.ru'
+import type { Translation } from '@shared/i18n/common.ru'
import type { clickguiRu } from './ru'
export const clickguiEn = {
diff --git a/frontend/src/features/clickgui/screens/SettingsScreen.tsx b/frontend/src/features/clickgui/screens/SettingsScreen.tsx
index 791d71d..8a8c9e0 100644
--- a/frontend/src/features/clickgui/screens/SettingsScreen.tsx
+++ b/frontend/src/features/clickgui/screens/SettingsScreen.tsx
@@ -1,7 +1,7 @@
import type { CSSProperties } from 'react'
import { useTranslation } from 'react-i18next'
-import { PRESETS } from '../../themes/presets.generated'
-import { argbToCss, type GradientSpec, type ThemeColors, type ThemeEntry } from '../../themes/types'
+import { PRESETS } from '@features/themes/presets.generated'
+import { argbToCss, type GradientSpec, type ThemeColors, type ThemeEntry } from '@features/themes/types'
import { Icon } from '../parts/Glyphs'
import { SIDEBAR } from '../parts/modules'
diff --git a/frontend/src/features/clickgui/tests/clickgui.test.tsx b/frontend/src/features/clickgui/tests/clickgui.test.tsx
index b9c01be..3010aa7 100644
--- a/frontend/src/features/clickgui/tests/clickgui.test.tsx
+++ b/frontend/src/features/clickgui/tests/clickgui.test.tsx
@@ -1,9 +1,9 @@
import { act, render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test, vi } from 'vitest'
-import { PRESETS } from '../../themes/presets.generated'
-import { argbToCss } from '../../themes/types'
-import { ActiveThemeProvider } from '../../themes/useActiveTheme'
+import { PRESETS } from '@features/themes/presets.generated'
+import { argbToCss } from '@features/themes/types'
+import { ActiveThemeProvider } from '@features/themes/useActiveTheme'
import { ClickGuiWindow } from '../ClickGuiWindow'
import { DEMO, guiReducer, initialGuiState, LOOP_MS } from '../demo'
diff --git a/frontend/src/features/clickgui/tests/themeVars.test.ts b/frontend/src/features/clickgui/tests/themeVars.test.ts
index 80e3331..e635426 100644
--- a/frontend/src/features/clickgui/tests/themeVars.test.ts
+++ b/frontend/src/features/clickgui/tests/themeVars.test.ts
@@ -1,5 +1,5 @@
import { expect, test } from 'vitest'
-import { PRESETS } from '../../themes/presets.generated'
+import { PRESETS } from '@features/themes/presets.generated'
import { cssAngle, themeToCssVars } from '../themeVars'
const preset = (id: string) => {
diff --git a/frontend/src/features/configs/api.ts b/frontend/src/features/configs/api.ts
index 18365ff..9ffbb34 100644
--- a/frontend/src/features/configs/api.ts
+++ b/frontend/src/features/configs/api.ts
@@ -1,4 +1,4 @@
-import { api } from '../../shared/api/client'
+import { api } from '@shared/api/client'
import type { SlotSummary } from './types'
export function listSlots(): Promise {
diff --git a/frontend/src/features/configs/components/PublishForm.tsx b/frontend/src/features/configs/components/PublishForm.tsx
index 7a6ea07..e08414f 100644
--- a/frontend/src/features/configs/components/PublishForm.tsx
+++ b/frontend/src/features/configs/components/PublishForm.tsx
@@ -1,8 +1,8 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
-import { Button } from '../../../shared/ui/Button'
-import { TextField } from '../../../shared/ui/text-field/TextField'
+import { Button } from '@shared/ui/Button'
+import { TextField } from '@shared/ui/text-field/TextField'
import { publish } from '../api'
const MAX_DESCRIPTION = 500
diff --git a/frontend/src/features/configs/components/SlotPanel.tsx b/frontend/src/features/configs/components/SlotPanel.tsx
index 2370e2e..4174a9f 100644
--- a/frontend/src/features/configs/components/SlotPanel.tsx
+++ b/frontend/src/features/configs/components/SlotPanel.tsx
@@ -1,9 +1,9 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useId, useState } from 'react'
import { useTranslation } from 'react-i18next'
-import { Button } from '../../../shared/ui/Button'
-import { Notice } from '../../../shared/ui/Notice'
-import { ShareCode } from '../../../shared/ui/share-code/ShareCode'
+import { Button } from '@shared/ui/Button'
+import { Notice } from '@shared/ui/Notice'
+import { ShareCode } from '@shared/ui/share-code/ShareCode'
import { regenerateCode, unpublish } from '../api'
import type { SlotSummary } from '../types'
import { PublishForm } from './PublishForm'
diff --git a/frontend/src/features/configs/tests/configs.test.tsx b/frontend/src/features/configs/tests/configs.test.tsx
index 7b03061..f039f0b 100644
--- a/frontend/src/features/configs/tests/configs.test.tsx
+++ b/frontend/src/features/configs/tests/configs.test.tsx
@@ -2,8 +2,8 @@ import { screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test } from 'vitest'
import { SlotGrid } from '../components/SlotGrid'
-import { renderApp } from '../../../test/render'
-import { json, mockFetch } from '../../../test/fetch'
+import { renderApp } from '@test/render'
+import { json, mockFetch } from '@test/fetch'
const filled = { slot: 2, name: 'pvp', updated_at: '2026-09-24T10:00:00Z', share_code: '7KQ3M9XA', published: false }
diff --git a/frontend/src/features/device-link/LinkDeviceForm.tsx b/frontend/src/features/device-link/LinkDeviceForm.tsx
index 89ec689..e2a50de 100644
--- a/frontend/src/features/device-link/LinkDeviceForm.tsx
+++ b/frontend/src/features/device-link/LinkDeviceForm.tsx
@@ -1,9 +1,9 @@
import { useMutation } from '@tanstack/react-query'
import { useState, type FormEvent } from 'react'
import { useTranslation } from 'react-i18next'
-import { describeError } from '../../shared/api/errors'
-import { Button } from '../../shared/ui/Button'
-import { Notice } from '../../shared/ui/Notice'
+import { describeError } from '@shared/api/errors'
+import { Button } from '@shared/ui/Button'
+import { Notice } from '@shared/ui/Notice'
import { confirmDevice, normalizeUserCode } from './api'
/** The device-code confirmation form: styled like typing a command into the in-game chat bar. */
diff --git a/frontend/src/features/device-link/api.ts b/frontend/src/features/device-link/api.ts
index d54fcdf..74b0a6d 100644
--- a/frontend/src/features/device-link/api.ts
+++ b/frontend/src/features/device-link/api.ts
@@ -1,4 +1,4 @@
-import { api } from '../../shared/api/client'
+import { api } from '@shared/api/client'
/** Uppercases, strips spaces, and inserts the dash after 4 chars if missing (`abcd1234` -> `ABCD-1234`). */
export function normalizeUserCode(raw: string): string {
diff --git a/frontend/src/features/device-link/i18n/en.ts b/frontend/src/features/device-link/i18n/en.ts
index fdbe1a9..485e944 100644
--- a/frontend/src/features/device-link/i18n/en.ts
+++ b/frontend/src/features/device-link/i18n/en.ts
@@ -1,4 +1,4 @@
-import type { Translation } from '../../../shared/i18n/common.ru'
+import type { Translation } from '@shared/i18n/common.ru'
import type { deviceLinkRu } from './ru'
export const deviceLinkEn = {
diff --git a/frontend/src/features/device-link/tests/link.test.tsx b/frontend/src/features/device-link/tests/link.test.tsx
index 43f26c9..097a0a4 100644
--- a/frontend/src/features/device-link/tests/link.test.tsx
+++ b/frontend/src/features/device-link/tests/link.test.tsx
@@ -3,8 +3,8 @@ import userEvent from '@testing-library/user-event'
import { expect, test } from 'vitest'
import { LinkDeviceForm } from '../LinkDeviceForm'
import { normalizeUserCode } from '../api'
-import { renderApp } from '../../../test/render'
-import { json, mockFetch } from '../../../test/fetch'
+import { renderApp } from '@test/render'
+import { json, mockFetch } from '@test/fetch'
test('normalizes typed codes', () => {
expect(normalizeUserCode(' abcd1234 ')).toBe('ABCD-1234')
diff --git a/frontend/src/features/download/i18n/en.ts b/frontend/src/features/download/i18n/en.ts
index 9a05262..ff38d81 100644
--- a/frontend/src/features/download/i18n/en.ts
+++ b/frontend/src/features/download/i18n/en.ts
@@ -1,4 +1,4 @@
-import type { Translation } from '../../../shared/i18n/common.ru'
+import type { Translation } from '@shared/i18n/common.ru'
import type { downloadRu } from './ru'
export const downloadEn = {
diff --git a/frontend/src/features/landing/CommandHero.tsx b/frontend/src/features/landing/CommandHero.tsx
index bc15564..723249f 100644
--- a/frontend/src/features/landing/CommandHero.tsx
+++ b/frontend/src/features/landing/CommandHero.tsx
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
-import { prefersReducedMotion } from '../../shared/motion/reducedMotion'
-import { ButtonLink } from '../../shared/ui/Button'
+import { prefersReducedMotion } from '@shared/motion/reducedMotion'
+import { ButtonLink } from '@shared/ui/Button'
import { useSession } from '../auth/session'
const COMMAND = '%config load 7KQ3M9XA'
diff --git a/frontend/src/features/landing/effects/ParticleField.tsx b/frontend/src/features/landing/effects/ParticleField.tsx
index c98ca22..e25ae1f 100644
--- a/frontend/src/features/landing/effects/ParticleField.tsx
+++ b/frontend/src/features/landing/effects/ParticleField.tsx
@@ -1,5 +1,5 @@
import { useEffect, useRef } from 'react'
-import { prefersReducedMotion } from '../../../shared/motion/reducedMotion'
+import { prefersReducedMotion } from '@shared/motion/reducedMotion'
const MAX_PARTICLES = 150
const MAX_DPR = 2
diff --git a/frontend/src/features/landing/effects/VoxelScene.tsx b/frontend/src/features/landing/effects/VoxelScene.tsx
index 2e19c65..406d6d5 100644
--- a/frontend/src/features/landing/effects/VoxelScene.tsx
+++ b/frontend/src/features/landing/effects/VoxelScene.tsx
@@ -1,5 +1,5 @@
import { useEffect, useRef } from 'react'
-import { prefersReducedMotion } from '../../../shared/motion/reducedMotion'
+import { prefersReducedMotion } from '@shared/motion/reducedMotion'
/** Deterministic PRNG so the landscape looks the same on every visit. */
function mulberry32(seed: number) {
diff --git a/frontend/src/features/landing/i18n/en.ts b/frontend/src/features/landing/i18n/en.ts
index 7cbd8db..fecea93 100644
--- a/frontend/src/features/landing/i18n/en.ts
+++ b/frontend/src/features/landing/i18n/en.ts
@@ -1,4 +1,4 @@
-import type { Translation } from '../../../shared/i18n/common.ru'
+import type { Translation } from '@shared/i18n/common.ru'
import type { landingRu } from './ru'
export const landingEn = {
diff --git a/frontend/src/features/landing/sections/explore/FaqDownload.tsx b/frontend/src/features/landing/sections/explore/FaqDownload.tsx
index 47c5a28..b552eee 100644
--- a/frontend/src/features/landing/sections/explore/FaqDownload.tsx
+++ b/frontend/src/features/landing/sections/explore/FaqDownload.tsx
@@ -1,8 +1,8 @@
import { useTranslation } from 'react-i18next'
-import { themeToCssVars } from '../../../clickgui/themeVars'
-import { useActiveTheme } from '../../../themes/useActiveTheme'
-import { DOWNLOAD_URL, GITHUB_REPO } from '../../../download/api'
-import '../../styles/landing.css'
+import { themeToCssVars } from '@features/clickgui/themeVars'
+import { useActiveTheme } from '@features/themes/useActiveTheme'
+import { DOWNLOAD_URL, GITHUB_REPO } from '@features/download/api'
+import '@features/landing/styles/landing.css'
const KEYS = ['1', '2', '3', '4', '5'] as const
diff --git a/frontend/src/features/landing/sections/explore/HudPlayground.tsx b/frontend/src/features/landing/sections/explore/HudPlayground.tsx
index 587a9d5..a05324a 100644
--- a/frontend/src/features/landing/sections/explore/HudPlayground.tsx
+++ b/frontend/src/features/landing/sections/explore/HudPlayground.tsx
@@ -1,11 +1,11 @@
import { useLayoutEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
-import { Button } from '../../../../shared/ui/Button'
-import { themeToCssVars } from '../../../clickgui/themeVars'
-import { parseArgb } from '../../../themes/types'
-import { useActiveTheme } from '../../../themes/useActiveTheme'
+import { Button } from '@shared/ui/Button'
+import { themeToCssVars } from '@features/clickgui/themeVars'
+import { parseArgb } from '@features/themes/types'
+import { useActiveTheme } from '@features/themes/useActiveTheme'
import { VoxelScene } from '../../effects/VoxelScene'
-import '../../styles/landing.css'
+import '@features/landing/styles/landing.css'
const STEP = 8
/** Fallback playfield size, used before the box is measured (and under jsdom). */
diff --git a/frontend/src/features/landing/sections/explore/ModuleWall.tsx b/frontend/src/features/landing/sections/explore/ModuleWall.tsx
index 1b8529a..8519361 100644
--- a/frontend/src/features/landing/sections/explore/ModuleWall.tsx
+++ b/frontend/src/features/landing/sections/explore/ModuleWall.tsx
@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next'
-import { CATEGORIES } from '../../../clickgui/parts/modules'
-import '../../styles/landing.css'
+import { CATEGORIES } from '@features/clickgui/parts/modules'
+import '@features/landing/styles/landing.css'
/** Each column scrolls at its own pace; the list is duplicated so the loop is seamless. */
const SPEED_MS = [42, 30, 50, 36]
diff --git a/frontend/src/features/landing/sections/explore/ShowcaseTeaser.tsx b/frontend/src/features/landing/sections/explore/ShowcaseTeaser.tsx
index d3415e7..53d13aa 100644
--- a/frontend/src/features/landing/sections/explore/ShowcaseTeaser.tsx
+++ b/frontend/src/features/landing/sections/explore/ShowcaseTeaser.tsx
@@ -1,9 +1,9 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router'
-import { browse } from '../../../showcase/api'
-import type { Listing } from '../../../showcase/types'
-import '../../styles/landing.css'
+import { browse } from '@features/showcase/api'
+import type { Listing } from '@features/showcase/types'
+import '@features/landing/styles/landing.css'
/** Placeholder cards so the section never collapses while loading or when the API is down. */
const EXAMPLES: Listing[] = [
diff --git a/frontend/src/features/landing/sections/hero/Hero.tsx b/frontend/src/features/landing/sections/hero/Hero.tsx
index cf7b9ef..a5d7d65 100644
--- a/frontend/src/features/landing/sections/hero/Hero.tsx
+++ b/frontend/src/features/landing/sections/hero/Hero.tsx
@@ -1,15 +1,15 @@
import { MotionConfig, motion } from 'motion/react'
import { useRef } from 'react'
import { useTranslation } from 'react-i18next'
-import { ClickGuiWindow } from '../../../clickgui/ClickGuiWindow'
-import { DOWNLOAD_URL } from '../../../download/api'
-import { PRESETS } from '../../../themes/presets.generated'
-import { parseArgb } from '../../../themes/types'
-import { useActiveTheme } from '../../../themes/useActiveTheme'
+import { ClickGuiWindow } from '@features/clickgui/ClickGuiWindow'
+import { DOWNLOAD_URL } from '@features/download/api'
+import { PRESETS } from '@features/themes/presets.generated'
+import { parseArgb } from '@features/themes/types'
+import { useActiveTheme } from '@features/themes/useActiveTheme'
import { ParticleField } from '../../effects/ParticleField'
import { useInView } from '../../effects/useInView'
import { VoxelScene } from '../../effects/VoxelScene'
-import '../../styles/landing.css'
+import '@features/landing/styles/landing.css'
const rise = (delay: number) => ({
initial: { opacity: 0, y: 24 },
diff --git a/frontend/src/features/landing/sections/hero/ThemeStrip.tsx b/frontend/src/features/landing/sections/hero/ThemeStrip.tsx
index 6fd5652..7e35449 100644
--- a/frontend/src/features/landing/sections/hero/ThemeStrip.tsx
+++ b/frontend/src/features/landing/sections/hero/ThemeStrip.tsx
@@ -1,13 +1,13 @@
import { motion } from 'motion/react'
import { useRef } from 'react'
import { useTranslation } from 'react-i18next'
-import { ButtonLink } from '../../../../shared/ui/Button'
-import { themeToCssVars } from '../../../clickgui/themeVars'
-import { PRESETS } from '../../../themes/presets.generated'
-import { argbToCss, type ThemeColors } from '../../../themes/types'
-import { useActiveTheme } from '../../../themes/useActiveTheme'
+import { ButtonLink } from '@shared/ui/Button'
+import { themeToCssVars } from '@features/clickgui/themeVars'
+import { PRESETS } from '@features/themes/presets.generated'
+import { argbToCss, type ThemeColors } from '@features/themes/types'
+import { useActiveTheme } from '@features/themes/useActiveTheme'
import { useInView } from '../../effects/useInView'
-import '../../styles/landing.css'
+import '@features/landing/styles/landing.css'
const PALETTE_KEYS: (keyof ThemeColors)[] = [
'windowBg', 'windowHeader', 'surface', 'cardEnabled', 'accent', 'accentSoft', 'textPrimary', 'textMuted',
diff --git a/frontend/src/features/landing/tests/landing.test.tsx b/frontend/src/features/landing/tests/landing.test.tsx
index e0b8dac..badedc3 100644
--- a/frontend/src/features/landing/tests/landing.test.tsx
+++ b/frontend/src/features/landing/tests/landing.test.tsx
@@ -5,9 +5,9 @@ import { CommandHero } from '../CommandHero'
import { Hero } from '../sections/hero/Hero'
import { createMemoryRouter, RouterProvider } from 'react-router'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
-import { initI18n } from '../../../app/i18n'
-import { SessionProvider } from '../../auth/session'
-import { json, mockFetch } from '../../../test/fetch'
+import { initI18n } from '@app/i18n'
+import { SessionProvider } from '@features/auth/session'
+import { json, mockFetch } from '@test/fetch'
test('reduced motion shows the full command at once', async () => {
vi.stubGlobal('matchMedia', (q: string) => ({ matches: q.includes('reduce'), addEventListener() {}, removeEventListener() {} }))
diff --git a/frontend/src/features/landing/tests/sections.test.tsx b/frontend/src/features/landing/tests/sections.test.tsx
index be205f3..ee51180 100644
--- a/frontend/src/features/landing/tests/sections.test.tsx
+++ b/frontend/src/features/landing/tests/sections.test.tsx
@@ -2,7 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createMemoryRouter, RouterProvider } from 'react-router'
import { afterEach, expect, test, vi } from 'vitest'
-import { mockFetch } from '../../../test/fetch'
+import { mockFetch } from '@test/fetch'
import { ParticleField } from '../effects/ParticleField'
import { HudPlayground } from '../sections/explore/HudPlayground'
import { ModuleWall } from '../sections/explore/ModuleWall'
diff --git a/frontend/src/features/profile/api.ts b/frontend/src/features/profile/api.ts
index 8b1f4f2..29809b1 100644
--- a/frontend/src/features/profile/api.ts
+++ b/frontend/src/features/profile/api.ts
@@ -1,4 +1,4 @@
-import { api } from '../../shared/api/client'
+import { api } from '@shared/api/client'
export type PublicProfile = {
id: string
diff --git a/frontend/src/features/profile/components/PublicProfileView.tsx b/frontend/src/features/profile/components/PublicProfileView.tsx
index 1894403..cfb272d 100644
--- a/frontend/src/features/profile/components/PublicProfileView.tsx
+++ b/frontend/src/features/profile/components/PublicProfileView.tsx
@@ -1,8 +1,8 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
-import { ApiError } from '../../../shared/api/client'
-import { Notice } from '../../../shared/ui/Notice'
-import { ListingList } from '../../showcase/components/ListingList'
+import { ApiError } from '@shared/api/client'
+import { Notice } from '@shared/ui/Notice'
+import { ListingList } from '@features/showcase/components/ListingList'
import { getProfile } from '../api'
export function PublicProfileView({ id }: { id: string }) {
diff --git a/frontend/src/features/profile/tests/profile.test.tsx b/frontend/src/features/profile/tests/profile.test.tsx
index 39c83ce..bdb0e76 100644
--- a/frontend/src/features/profile/tests/profile.test.tsx
+++ b/frontend/src/features/profile/tests/profile.test.tsx
@@ -1,7 +1,7 @@
import { screen } from '@testing-library/react'
import { expect, test } from 'vitest'
-import { json, mockFetch } from '../../../test/fetch'
-import { renderApp } from '../../../test/render'
+import { json, mockFetch } from '@test/fetch'
+import { renderApp } from '@test/render'
import { PublicProfileView } from '../components/PublicProfileView'
const profile = { id: 'u1', display_nick: 'loki', avatar_url: null, created_at: '2026-01-05T10:00:00Z', badges: ['early'] }
diff --git a/frontend/src/features/showcase/api.ts b/frontend/src/features/showcase/api.ts
index 77eeafe..1d4d4c0 100644
--- a/frontend/src/features/showcase/api.ts
+++ b/frontend/src/features/showcase/api.ts
@@ -1,4 +1,4 @@
-import { api } from '../../shared/api/client'
+import { api } from '@shared/api/client'
import type { ListingDetail, ShowcasePage } from './types'
export function browse(sort: 'new' | 'popular', page: number, author?: string): Promise {
diff --git a/frontend/src/features/showcase/components/CopyToSlot.tsx b/frontend/src/features/showcase/components/CopyToSlot.tsx
index a38d7fc..2554c9e 100644
--- a/frontend/src/features/showcase/components/CopyToSlot.tsx
+++ b/frontend/src/features/showcase/components/CopyToSlot.tsx
@@ -2,10 +2,10 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Link, useLocation } from 'react-router'
-import { Notice } from '../../../shared/ui/Notice'
-import { ApiError } from '../../../shared/api/client'
-import { useSession } from '../../auth/session'
-import { listSlots } from '../../configs/api'
+import { Notice } from '@shared/ui/Notice'
+import { ApiError } from '@shared/api/client'
+import { useSession } from '@features/auth/session'
+import { listSlots } from '@features/configs/api'
import { copyToSlot } from '../api'
const SLOTS = [1, 2, 3, 4] as const
diff --git a/frontend/src/features/showcase/components/ListingDetail.tsx b/frontend/src/features/showcase/components/ListingDetail.tsx
index b7c17ee..67dabce 100644
--- a/frontend/src/features/showcase/components/ListingDetail.tsx
+++ b/frontend/src/features/showcase/components/ListingDetail.tsx
@@ -1,7 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router'
-import { Notice } from '../../../shared/ui/Notice'
+import { Notice } from '@shared/ui/Notice'
import { getListing } from '../api'
import { copiesText } from '../copies'
import { CopyToSlot } from './CopyToSlot'
diff --git a/frontend/src/features/showcase/components/ListingList.tsx b/frontend/src/features/showcase/components/ListingList.tsx
index 5e2686b..6050c2f 100644
--- a/frontend/src/features/showcase/components/ListingList.tsx
+++ b/frontend/src/features/showcase/components/ListingList.tsx
@@ -1,8 +1,8 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { useSearchParams } from 'react-router'
-import { Button } from '../../../shared/ui/Button'
-import { Notice } from '../../../shared/ui/Notice'
+import { Button } from '@shared/ui/Button'
+import { Notice } from '@shared/ui/Notice'
import { browse } from '../api'
import { ListingItem } from './ListingItem'
diff --git a/frontend/src/features/showcase/tests/showcase.test.tsx b/frontend/src/features/showcase/tests/showcase.test.tsx
index e20ce3d..378e2bf 100644
--- a/frontend/src/features/showcase/tests/showcase.test.tsx
+++ b/frontend/src/features/showcase/tests/showcase.test.tsx
@@ -4,8 +4,8 @@ import { expect, test } from 'vitest'
import { copiesLabel } from '../copies'
import { ListingList } from '../components/ListingList'
import { CopyToSlot } from '../components/CopyToSlot'
-import { renderApp } from '../../../test/render'
-import { json, mockFetch } from '../../../test/fetch'
+import { renderApp } from '@test/render'
+import { json, mockFetch } from '@test/fetch'
const listing = {
id: 'l1',
diff --git a/frontend/src/features/themes/editor/EditorPreview.tsx b/frontend/src/features/themes/editor/EditorPreview.tsx
index bc9b470..37eef6e 100644
--- a/frontend/src/features/themes/editor/EditorPreview.tsx
+++ b/frontend/src/features/themes/editor/EditorPreview.tsx
@@ -1,7 +1,7 @@
import { useMemo } from 'react'
-import { ClickGuiWindow } from '../../clickgui/ClickGuiWindow'
-import { themeToCssVars } from '../../clickgui/themeVars'
-import { VoxelScene } from '../../landing/effects/VoxelScene'
+import { ClickGuiWindow } from '@features/clickgui/ClickGuiWindow'
+import { themeToCssVars } from '@features/clickgui/themeVars'
+import { VoxelScene } from '@features/landing/effects/VoxelScene'
import { parseArgb, type ThemeEntry } from '../types'
type Props = {
diff --git a/frontend/src/features/themes/editor/ThemeEditor.tsx b/frontend/src/features/themes/editor/ThemeEditor.tsx
index 6cc214a..2a66141 100644
--- a/frontend/src/features/themes/editor/ThemeEditor.tsx
+++ b/frontend/src/features/themes/editor/ThemeEditor.tsx
@@ -1,6 +1,6 @@
import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
-import { Button } from '../../../shared/ui/Button'
+import { Button } from '@shared/ui/Button'
import { PRESETS } from '../presets.generated'
import type { GradientSpec, ThemeColors, ThemeEntry } from '../types'
import { EditorPreview } from './EditorPreview'
diff --git a/frontend/src/features/themes/i18n/en.ts b/frontend/src/features/themes/i18n/en.ts
index 13bd4e0..bd1ac70 100644
--- a/frontend/src/features/themes/i18n/en.ts
+++ b/frontend/src/features/themes/i18n/en.ts
@@ -1,4 +1,4 @@
-import type { Translation } from '../../../shared/i18n/common.ru'
+import type { Translation } from '@shared/i18n/common.ru'
import type { themesRu } from './ru'
export const themesEn = {
diff --git a/frontend/src/pages/app/AccountPage.tsx b/frontend/src/pages/app/AccountPage.tsx
index d1c26fb..29ed68c 100644
--- a/frontend/src/pages/app/AccountPage.tsx
+++ b/frontend/src/pages/app/AccountPage.tsx
@@ -1,5 +1,5 @@
-import { DeviceList } from '../../features/account/components/DeviceList'
-import { ProfileCard } from '../../features/account/components/ProfileCard'
+import { DeviceList } from '@features/account/components/DeviceList'
+import { ProfileCard } from '@features/account/components/ProfileCard'
export default function AccountPage() {
return (
diff --git a/frontend/src/pages/app/ConfigsPage.tsx b/frontend/src/pages/app/ConfigsPage.tsx
index d04448e..97d8208 100644
--- a/frontend/src/pages/app/ConfigsPage.tsx
+++ b/frontend/src/pages/app/ConfigsPage.tsx
@@ -1,8 +1,8 @@
import { useTranslation } from 'react-i18next'
-import { ParticleField } from '../../features/landing/effects/ParticleField'
-import { VoxelScene } from '../../features/landing/effects/VoxelScene'
-import { SlotGrid } from '../../features/configs/components/SlotGrid'
-import { PageTitle } from '../../shared/layout/PageTitle'
+import { ParticleField } from '@features/landing/effects/ParticleField'
+import { VoxelScene } from '@features/landing/effects/VoxelScene'
+import { SlotGrid } from '@features/configs/components/SlotGrid'
+import { PageTitle } from '@shared/layout/PageTitle'
const ACCENT = 'rgb(92, 200, 231)'
diff --git a/frontend/src/pages/app/LinkPage.tsx b/frontend/src/pages/app/LinkPage.tsx
index d6d64db..cc3a4a0 100644
--- a/frontend/src/pages/app/LinkPage.tsx
+++ b/frontend/src/pages/app/LinkPage.tsx
@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next'
-import { LinkDeviceForm } from '../../features/device-link/LinkDeviceForm'
-import { PageTitle } from '../../shared/layout/PageTitle'
+import { LinkDeviceForm } from '@features/device-link/LinkDeviceForm'
+import { PageTitle } from '@shared/layout/PageTitle'
const STEPS = ['one', 'two', 'three'] as const
diff --git a/frontend/src/pages/download/DownloadPage.tsx b/frontend/src/pages/download/DownloadPage.tsx
index 5d3743d..f2acea7 100644
--- a/frontend/src/pages/download/DownloadPage.tsx
+++ b/frontend/src/pages/download/DownloadPage.tsx
@@ -1,9 +1,9 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
-import { DownloadButton } from '../../features/download/DownloadButton'
-import { ReleaseNotes } from '../../features/download/ReleaseNotes'
-import { GITHUB_REPO, fetchReleases, releaseSize } from '../../features/download/api'
-import { PageTitle } from '../../shared/layout/PageTitle'
+import { DownloadButton } from '@features/download/DownloadButton'
+import { ReleaseNotes } from '@features/download/ReleaseNotes'
+import { GITHUB_REPO, fetchReleases, releaseSize } from '@features/download/api'
+import { PageTitle } from '@shared/layout/PageTitle'
// Requirement versions mirror mod/gradle.properties (the mod's build source of truth).
const REQUIREMENTS = [
diff --git a/frontend/src/pages/public/HomePage.tsx b/frontend/src/pages/public/HomePage.tsx
index 370ce80..812eda0 100644
--- a/frontend/src/pages/public/HomePage.tsx
+++ b/frontend/src/pages/public/HomePage.tsx
@@ -1,11 +1,11 @@
-import { CommandHero } from '../../features/landing/CommandHero'
-import { HowItWorks } from '../../features/landing/HowItWorks'
-import { FaqDownload } from '../../features/landing/sections/explore/FaqDownload'
-import { Hero } from '../../features/landing/sections/hero/Hero'
-import { HudPlayground } from '../../features/landing/sections/explore/HudPlayground'
-import { ModuleWall } from '../../features/landing/sections/explore/ModuleWall'
-import { ShowcaseTeaser } from '../../features/landing/sections/explore/ShowcaseTeaser'
-import { ThemeStrip } from '../../features/landing/sections/hero/ThemeStrip'
+import { CommandHero } from '@features/landing/CommandHero'
+import { HowItWorks } from '@features/landing/HowItWorks'
+import { FaqDownload } from '@features/landing/sections/explore/FaqDownload'
+import { Hero } from '@features/landing/sections/hero/Hero'
+import { HudPlayground } from '@features/landing/sections/explore/HudPlayground'
+import { ModuleWall } from '@features/landing/sections/explore/ModuleWall'
+import { ShowcaseTeaser } from '@features/landing/sections/explore/ShowcaseTeaser'
+import { ThemeStrip } from '@features/landing/sections/hero/ThemeStrip'
export default function HomePage() {
return (
diff --git a/frontend/src/pages/public/NotFoundPage.tsx b/frontend/src/pages/public/NotFoundPage.tsx
index f4b7d75..4686cf4 100644
--- a/frontend/src/pages/public/NotFoundPage.tsx
+++ b/frontend/src/pages/public/NotFoundPage.tsx
@@ -1,7 +1,7 @@
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router'
-import { BreakableBlock } from '../../shared/easter/BreakableBlock'
-import { PageTitle } from '../../shared/layout/PageTitle'
+import { BreakableBlock } from '@shared/easter/BreakableBlock'
+import { PageTitle } from '@shared/layout/PageTitle'
export default function NotFoundPage() {
const { t } = useTranslation('common')
diff --git a/frontend/src/pages/public/ProfilePage.tsx b/frontend/src/pages/public/ProfilePage.tsx
index 8306d33..898c749 100644
--- a/frontend/src/pages/public/ProfilePage.tsx
+++ b/frontend/src/pages/public/ProfilePage.tsx
@@ -1,5 +1,5 @@
import { useParams } from 'react-router'
-import { PublicProfileView } from '../../features/profile/components/PublicProfileView'
+import { PublicProfileView } from '@features/profile/components/PublicProfileView'
export default function ProfilePage() {
const { id = '' } = useParams()
diff --git a/frontend/src/pages/public/auth/AuthSplit.tsx b/frontend/src/pages/public/auth/AuthSplit.tsx
index 5653b97..ee8ede5 100644
--- a/frontend/src/pages/public/auth/AuthSplit.tsx
+++ b/frontend/src/pages/public/auth/AuthSplit.tsx
@@ -1,10 +1,10 @@
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
-import { ClickGuiWindow } from '../../../features/clickgui/ClickGuiWindow'
-import { ParticleField } from '../../../features/landing/effects/ParticleField'
-import { VoxelScene } from '../../../features/landing/effects/VoxelScene'
-import { useActiveTheme } from '../../../features/themes/useActiveTheme'
-import { parseArgb } from '../../../features/themes/types'
+import { ClickGuiWindow } from '@features/clickgui/ClickGuiWindow'
+import { ParticleField } from '@features/landing/effects/ParticleField'
+import { VoxelScene } from '@features/landing/effects/VoxelScene'
+import { useActiveTheme } from '@features/themes/useActiveTheme'
+import { parseArgb } from '@features/themes/types'
/** Two-pane auth layout: a ClickGui-styled glass form panel, and a decorative scene (hidden < 900px). */
export function AuthSplit({ title, children }: { title: string; children: ReactNode }) {
diff --git a/frontend/src/pages/public/auth/LoginPage.tsx b/frontend/src/pages/public/auth/LoginPage.tsx
index d2623d4..28604a6 100644
--- a/frontend/src/pages/public/auth/LoginPage.tsx
+++ b/frontend/src/pages/public/auth/LoginPage.tsx
@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'
-import { LoginForm } from '../../../features/auth/forms/LoginForm'
+import { LoginForm } from '@features/auth/forms/LoginForm'
import { AuthSplit } from './AuthSplit'
export default function LoginPage() {
diff --git a/frontend/src/pages/public/auth/RegisterPage.tsx b/frontend/src/pages/public/auth/RegisterPage.tsx
index 57c4400..5c8f687 100644
--- a/frontend/src/pages/public/auth/RegisterPage.tsx
+++ b/frontend/src/pages/public/auth/RegisterPage.tsx
@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'
-import { RegisterForm } from '../../../features/auth/forms/RegisterForm'
+import { RegisterForm } from '@features/auth/forms/RegisterForm'
import { AuthSplit } from './AuthSplit'
export default function RegisterPage() {
diff --git a/frontend/src/pages/showcase/ListingPage.tsx b/frontend/src/pages/showcase/ListingPage.tsx
index 3753e0d..7ad4c5c 100644
--- a/frontend/src/pages/showcase/ListingPage.tsx
+++ b/frontend/src/pages/showcase/ListingPage.tsx
@@ -1,7 +1,7 @@
import { useParams } from 'react-router'
-import { ParticleField } from '../../features/landing/effects/ParticleField'
-import { VoxelScene } from '../../features/landing/effects/VoxelScene'
-import { ListingDetail } from '../../features/showcase/components/ListingDetail'
+import { ParticleField } from '@features/landing/effects/ParticleField'
+import { VoxelScene } from '@features/landing/effects/VoxelScene'
+import { ListingDetail } from '@features/showcase/components/ListingDetail'
const ACCENT = 'rgb(92, 200, 231)'
diff --git a/frontend/src/pages/showcase/ShowcasePage.tsx b/frontend/src/pages/showcase/ShowcasePage.tsx
index 2f961ce..f32ed14 100644
--- a/frontend/src/pages/showcase/ShowcasePage.tsx
+++ b/frontend/src/pages/showcase/ShowcasePage.tsx
@@ -1,8 +1,8 @@
import { useTranslation } from 'react-i18next'
-import { ParticleField } from '../../features/landing/effects/ParticleField'
-import { VoxelScene } from '../../features/landing/effects/VoxelScene'
-import { ListingList } from '../../features/showcase/components/ListingList'
-import { PageTitle } from '../../shared/layout/PageTitle'
+import { ParticleField } from '@features/landing/effects/ParticleField'
+import { VoxelScene } from '@features/landing/effects/VoxelScene'
+import { ListingList } from '@features/showcase/components/ListingList'
+import { PageTitle } from '@shared/layout/PageTitle'
const ACCENT = 'rgb(92, 200, 231)'
diff --git a/frontend/src/pages/themes/ThemesPage.tsx b/frontend/src/pages/themes/ThemesPage.tsx
index e912990..6d64bcb 100644
--- a/frontend/src/pages/themes/ThemesPage.tsx
+++ b/frontend/src/pages/themes/ThemesPage.tsx
@@ -1,10 +1,10 @@
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
-import { ThemeEditor } from '../../features/themes/editor/ThemeEditor'
-import { fromShareHash } from '../../features/themes/editor/model/codec'
-import { PRESETS } from '../../features/themes/presets.generated'
-import { PageTitle } from '../../shared/layout/PageTitle'
-import '../../features/themes/editor/editor.css'
+import { ThemeEditor } from '@features/themes/editor/ThemeEditor'
+import { fromShareHash } from '@features/themes/editor/model/codec'
+import { PRESETS } from '@features/themes/presets.generated'
+import { PageTitle } from '@shared/layout/PageTitle'
+import '@features/themes/editor/editor.css'
/** `/themes` — pick a preset (or load one from a `#t=` share link) and edit it live. */
export default function ThemesPage() {
diff --git a/frontend/src/shared/api/client.ts b/frontend/src/shared/api/client.ts
index 6d7407a..8a28c32 100644
--- a/frontend/src/shared/api/client.ts
+++ b/frontend/src/shared/api/client.ts
@@ -14,6 +14,7 @@ export type RequestOptions = { method?: string; json?: unknown; body?: BodyInit;
export type ApiClient = {
request(path: string, options?: RequestOptions): Promise
+ upload(path: string, body: FormData, onProgress?: (pct: number) => void): Promise
refresh(): Promise
setAccessToken(token: string | null): void
hasAccessToken(): boolean
@@ -47,6 +48,8 @@ export function createApiClient(
return false
}
const body = (await res.json()) as { access_token: string }
+ // Store the renewed token: callers (session restore) rely on refresh()
+ // alone, and requests after a reload would otherwise go out without a bearer.
accessToken = body.access_token
return true
} catch {
@@ -88,8 +91,52 @@ export function createApiClient(
return (text ? JSON.parse(text) : undefined) as T
}
+ /**
+ * Sends `body` via XMLHttpRequest so byte-level upload progress is observable —
+ * fetch exposes none. Same auth/refresh/error semantics as `request`.
+ */
+ function upload(path: string, body: FormData, onProgress?: (pct: number) => void): Promise {
+ return new Promise((resolve, reject) => {
+ const attempt = (retried: boolean) => {
+ const xhr = new XMLHttpRequest()
+ xhr.withCredentials = true
+ xhr.upload.onprogress = (e) => {
+ if (e.lengthComputable) onProgress?.(Math.round((e.loaded / e.total) * 100))
+ }
+ xhr.onload = () => {
+ if (xhr.status >= 200 && xhr.status < 300) {
+ resolve(xhr.response ? JSON.parse(xhr.response) : undefined)
+ return
+ }
+ if (xhr.status === 401 && !retried) {
+ void refresh().then((ok) => (ok ? attempt(true) : reject(errorFrom(xhr))))
+ return
+ }
+ reject(errorFrom(xhr))
+ }
+ xhr.onerror = () => reject(new ApiError(0, 'Network error'))
+ xhr.open('POST', `${baseUrl}${path}`)
+ if (accessToken) xhr.setRequestHeader('authorization', `Bearer ${accessToken}`)
+ xhr.send(body)
+ }
+ attempt(false)
+ })
+ }
+
+ function errorFrom(xhr: XMLHttpRequest): ApiError {
+ let message = `HTTP ${xhr.status}`
+ try {
+ const body: unknown = JSON.parse(xhr.response)
+ if (body && typeof body === 'object' && 'error' in body && typeof body.error === 'string') message = body.error
+ } catch {
+ // non-JSON error body: keep the status text
+ }
+ return new ApiError(xhr.status, message)
+ }
+
return {
request,
+ upload,
refresh,
setAccessToken: (token) => {
accessToken = token
diff --git a/frontend/src/shared/api/tests/client-upload.test.ts b/frontend/src/shared/api/tests/client-upload.test.ts
new file mode 100644
index 0000000..cf0ef41
--- /dev/null
+++ b/frontend/src/shared/api/tests/client-upload.test.ts
@@ -0,0 +1,87 @@
+import { beforeEach, expect, test, vi } from 'vitest'
+import { createApiClient } from '../client'
+import { json, mockFetch } from '@test/fetch'
+
+/** Minimal XMLHttpRequest stand-in: `send` hands the instance to the test to fire events on. */
+class XhrStub {
+ static instances: XhrStub[] = []
+ method = ''
+ url = ''
+ headers: Record = {}
+ withCredentials = false
+ status = 200
+ response = ''
+ upload: { onprogress?: (e: { lengthComputable: boolean; loaded: number; total: number }) => void } = {}
+ onload?: () => void
+ onerror?: () => void
+ open(method: string, url: string) {
+ this.method = method
+ this.url = url
+ }
+ setRequestHeader(name: string, value: string) {
+ this.headers[name.toLowerCase()] = value
+ }
+ send() {
+ XhrStub.instances.push(this)
+ }
+}
+
+beforeEach(() => {
+ XhrStub.instances = []
+ vi.stubGlobal('XMLHttpRequest', XhrStub)
+})
+
+test('upload sends bearer token, cookies and reports progress', async () => {
+ mockFetch({})
+ const api = createApiClient()
+ api.setAccessToken('tok')
+ const progress: number[] = []
+ const promise = api.upload('/avatars', new FormData(), (pct) => progress.push(pct))
+ const xhr = XhrStub.instances[0]
+ xhr.upload.onprogress?.({ lengthComputable: true, loaded: 50, total: 100 })
+ xhr.upload.onprogress?.({ lengthComputable: false, loaded: 0, total: 0 })
+ xhr.status = 200
+ xhr.response = JSON.stringify({ avatar_url: 'x' })
+ xhr.onload?.()
+
+ await expect(promise).resolves.toEqual({ avatar_url: 'x' })
+ expect(progress).toEqual([50])
+ expect(xhr.url).toBe('/api/avatars')
+ expect(xhr.headers.authorization).toBe('Bearer tok')
+ expect(xhr.withCredentials).toBe(true)
+})
+
+test('upload retries once after a successful refresh on 401', async () => {
+ const fetch = mockFetch({ 'POST /auth/refresh': () => json({ access_token: 'new' }) })
+ const api = createApiClient()
+ const promise = api.upload('/avatars', new FormData())
+ const first = XhrStub.instances[0]
+ expect(first.headers.authorization).toBeUndefined()
+ first.status = 401
+ first.response = JSON.stringify({ error: 'unauthorized' })
+ first.onload?.()
+
+ const second = await vi.waitFor(() => {
+ expect(XhrStub.instances).toHaveLength(2)
+ return XhrStub.instances[1]
+ })
+ expect(second.headers.authorization).toBe('Bearer new')
+ second.status = 200
+ second.response = ''
+ second.onload?.()
+
+ await expect(promise).resolves.toBeUndefined()
+ expect(fetch.mock.calls.some(([url]) => String(url).includes('/auth/refresh'))).toBe(true)
+})
+
+test('upload rejects with the server error message when refresh fails', async () => {
+ mockFetch({ 'POST /auth/refresh': () => json({ error: 'unauthorized' }, 401) })
+ const api = createApiClient()
+ const promise = api.upload('/avatars', new FormData())
+ const xhr = XhrStub.instances[0]
+ xhr.status = 413
+ xhr.response = JSON.stringify({ error: 'too large' })
+ xhr.onload?.()
+
+ await expect(promise).rejects.toMatchObject({ status: 413, message: 'too large' })
+})
diff --git a/frontend/src/shared/api/tests/client.test.ts b/frontend/src/shared/api/tests/client.test.ts
index a1bd8a0..da46b3f 100644
--- a/frontend/src/shared/api/tests/client.test.ts
+++ b/frontend/src/shared/api/tests/client.test.ts
@@ -1,8 +1,8 @@
import { expect, test } from 'vitest'
-import { initI18n } from '../../../app/i18n'
+import { initI18n } from '@app/i18n'
import { ApiError, createApiClient } from '../client'
import { describeError } from '../errors'
-import { json, mockFetch } from '../../../test/fetch'
+import { json, mockFetch } from '@test/fetch'
const t = initI18n('ru').getFixedT('ru', 'common')
diff --git a/frontend/src/shared/easter/tests/breakable.test.tsx b/frontend/src/shared/easter/tests/breakable.test.tsx
index 53c3b76..56c7936 100644
--- a/frontend/src/shared/easter/tests/breakable.test.tsx
+++ b/frontend/src/shared/easter/tests/breakable.test.tsx
@@ -2,7 +2,7 @@ import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test, vi } from 'vitest'
import { BreakableBlock } from '../BreakableBlock'
-import { renderApp } from '../../../test/render'
+import { renderApp } from '@test/render'
// motion's useReducedMotion reads matchMedia, which jsdom lacks.
vi.stubGlobal('matchMedia', (query: string) => ({
diff --git a/frontend/src/shared/i18n/tests/i18n.test.tsx b/frontend/src/shared/i18n/tests/i18n.test.tsx
index 6ed49b3..3df4e50 100644
--- a/frontend/src/shared/i18n/tests/i18n.test.tsx
+++ b/frontend/src/shared/i18n/tests/i18n.test.tsx
@@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { I18nextProvider, useTranslation } from 'react-i18next'
import { expect, test } from 'vitest'
-import { initI18n } from '../../../app/i18n'
+import { initI18n } from '@app/i18n'
import { LanguageSwitch } from '../LanguageSwitch'
function Probe() {
diff --git a/frontend/src/shared/layout/Footer.tsx b/frontend/src/shared/layout/Footer.tsx
index a1b8699..3d11920 100644
--- a/frontend/src/shared/layout/Footer.tsx
+++ b/frontend/src/shared/layout/Footer.tsx
@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router'
-import { DOWNLOAD_URL, GITHUB_REPO } from '../../features/download/api'
+import { DOWNLOAD_URL, GITHUB_REPO } from '@features/download/api'
import { LanguageSwitch } from '../i18n/LanguageSwitch'
export function Footer() {
diff --git a/frontend/src/shared/layout/tests/layout.test.tsx b/frontend/src/shared/layout/tests/layout.test.tsx
index e30cb31..0db896e 100644
--- a/frontend/src/shared/layout/tests/layout.test.tsx
+++ b/frontend/src/shared/layout/tests/layout.test.tsx
@@ -2,9 +2,9 @@ import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { getI18n } from 'react-i18next'
import { afterEach, expect, test } from 'vitest'
-import { routes } from '../../../app/routes'
-import { renderApp } from '../../../test/render'
-import { json, mockFetch } from '../../../test/fetch'
+import { routes } from '@app/routes'
+import { renderApp } from '@test/render'
+import { json, mockFetch } from '@test/fetch'
import { PageTitle } from '../PageTitle'
afterEach(async () => {
diff --git a/frontend/src/shared/layout/topbar/parts.tsx b/frontend/src/shared/layout/topbar/parts.tsx
index 77ca8bb..c6ba15f 100644
--- a/frontend/src/shared/layout/topbar/parts.tsx
+++ b/frontend/src/shared/layout/topbar/parts.tsx
@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router'
-import { useSession } from '../../../features/auth/session'
+import { useSession } from '@features/auth/session'
import type { Me } from '../../types'
/** The pixel-heart mark, standalone (no badge background) — same shape as public/favicon.svg. */
diff --git a/frontend/src/shared/ui/Notice.tsx b/frontend/src/shared/ui/Notice.tsx
index 0175aba..4295fb3 100644
--- a/frontend/src/shared/ui/Notice.tsx
+++ b/frontend/src/shared/ui/Notice.tsx
@@ -1,7 +1,8 @@
import type { ReactNode } from 'react'
-export function Notice({ tone, children }: { tone: 'info' | 'error'; children: ReactNode }) {
- const styles = tone === 'error' ? 'border-ember/60 text-ember' : 'border-shoal text-frost'
+export function Notice({ tone, children }: { tone: 'info' | 'error' | 'success'; children: ReactNode }) {
+ const styles =
+ tone === 'error' ? 'border-ember/60 text-ember' : tone === 'success' ? 'border-ice/60 text-ice' : 'border-shoal text-frost'
return (
{children}
diff --git a/frontend/src/test/render.tsx b/frontend/src/test/render.tsx
index 98bc2f7..0eef031 100644
--- a/frontend/src/test/render.tsx
+++ b/frontend/src/test/render.tsx
@@ -1,7 +1,7 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render } from '@testing-library/react'
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router'
-import { SessionProvider } from '../features/auth/session'
+import { SessionProvider } from '@features/auth/session'
export function renderApp(routes: RouteObject[], initialPath = '/') {
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts
index a4ed11e..41d921d 100644
--- a/frontend/src/test/setup.ts
+++ b/frontend/src/test/setup.ts
@@ -1,7 +1,8 @@
import '@testing-library/jest-dom/vitest'
+import 'fake-indexeddb/auto'
import { cleanup } from '@testing-library/react'
import { afterEach, vi } from 'vitest'
-import { initI18n } from '../app/i18n'
+import { initI18n } from '@app/i18n'
initI18n('ru')
diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json
index af5db10..12ec299 100644
--- a/frontend/tsconfig.app.json
+++ b/frontend/tsconfig.app.json
@@ -10,6 +10,13 @@
/* Bundler mode */
"moduleResolution": "bundler",
+ "paths": {
+ "@shared/*": ["./src/shared/*"],
+ "@features/*": ["./src/features/*"],
+ "@pages/*": ["./src/pages/*"],
+ "@app/*": ["./src/app/*"],
+ "@test/*": ["./src/test/*"]
+ },
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
diff --git a/frontend/tsconfig.test.json b/frontend/tsconfig.test.json
index c94ffb2..48c0190 100644
--- a/frontend/tsconfig.test.json
+++ b/frontend/tsconfig.test.json
@@ -11,6 +11,13 @@
/* Bundler mode */
"moduleResolution": "bundler",
+ "paths": {
+ "@shared/*": ["./src/shared/*"],
+ "@features/*": ["./src/features/*"],
+ "@pages/*": ["./src/pages/*"],
+ "@app/*": ["./src/app/*"],
+ "@test/*": ["./src/test/*"]
+ },
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 3095792..8459305 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -1,9 +1,22 @@
import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react'
+import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vitest/config'
+// Path aliases mirror tsconfig `paths` — register here once instead of writing `../../../` chains.
+const src = fileURLToPath(new URL('./src', import.meta.url))
+
export default defineConfig({
plugins: [react(), tailwindcss()],
+ resolve: {
+ alias: {
+ '@shared': `${src}/shared`,
+ '@features': `${src}/features`,
+ '@pages': `${src}/pages`,
+ '@app': `${src}/app`,
+ '@test': `${src}/test`,
+ },
+ },
server: {
// Tests read the mod's theme presets (outside this package) to check the generated copy.
fs: process.env.VITEST ? { allow: ['.', '../mod/src/main/java/dev/loki/lovisual/features/theme/presets'] } : undefined,