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 ( -
- - {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 ( + + ) +} 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 ( +
+
+ + {me?.avatar_url ? ( + + ) : 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,