feat(frontend): avatar upload QOL — feedback, re-crop, drop, progress, history
Task 12 batch around the existing crop/preview flow: - Success Notice (auto-dismiss ~2.5s) after an avatar upload. - "Reposition" re-crops the current avatar without re-picking a file. - Drag & drop an image straight onto the profile avatar circle. - Real upload progress via a new XHR-backed api.upload (fetch has none); refreshOnce now also stores the renewed token (reload-restore fix). - Local avatar history (last 5, IndexedDB, this-device-only) strip that re-uploads a stored crop. Stored as ArrayBuffer (Blob is not cloneable under structuredClone); label makes the per-device scope explicit. - Added a "success" tone to Notice. Infra (same session): registered @shared/@features/@pages/@app/@test path aliases in vite.config + tsconfigs and rewrote deep ../../../ imports; moved avatar components into components/avatar/ to keep folders at <=4 files. Tests: avatarHistory cap/order, recrop visibility, drop-opens-cropper, client upload/progress/401-retry. bun test 103 pass, lint 0 errors, build ok.
This commit is contained in:
parent
e79b0aedd2
commit
fd2e29cede
92 changed files with 808 additions and 287 deletions
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
22
frontend/src/app/i18next.d.ts
vendored
22
frontend/src/app/i18next.d.ts
vendored
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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[] = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<File> {
|
||||
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<IDBDatabase> {
|
||||
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<AvatarRecord[]> {
|
||||
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<void> {
|
||||
const db = await openAvatarDb()
|
||||
const data = await blob.arrayBuffer()
|
||||
await new Promise<void>((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<void>((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<AvatarHistoryEntry[]> {
|
||||
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<DeviceLink[]> {
|
||||
return api.request('/device/links')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string>()
|
||||
const [picked, setPicked] = useState<File>()
|
||||
const mutation = useMutation({
|
||||
mutationFn: uploadAvatar,
|
||||
onSuccess: () => {
|
||||
client.invalidateQueries({ queryKey: ['me'] })
|
||||
setPicked(undefined)
|
||||
},
|
||||
})
|
||||
|
||||
function onChange(e: ChangeEvent<HTMLInputElement>) {
|
||||
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 (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<AvatarCropper file={picked} onCancel={() => setPicked(undefined)} onConfirm={onConfirmCrop} />
|
||||
{error ? <Notice tone="error">{error}</Notice> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="cursor-pointer rounded-ctl border border-shoal px-3 py-1.5 text-sm text-snow hover:border-frost"
|
||||
>
|
||||
{t('avatar.change')}
|
||||
<input
|
||||
id={id}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
className="sr-only"
|
||||
onChange={onChange}
|
||||
/>
|
||||
</label>
|
||||
{error ? <Notice tone="error">{error}</Notice> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement>) {
|
||||
e.preventDefault()
|
||||
setDragging(false)
|
||||
const file = e.dataTransfer.files?.[0]
|
||||
if (file) acceptFile.current(file)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 rounded-(--radius-panel) border border-shoal bg-slate/70 p-6 text-center">
|
||||
<div className="relative">
|
||||
<div
|
||||
data-testid="avatar-drop"
|
||||
className={`relative rounded-full ${dragging ? 'ring-2 ring-ice' : ''}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
setDragging(true)
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<div className="flex h-24 w-24 items-center justify-center overflow-hidden rounded-full border-2 border-ice bg-abyss font-display text-3xl">
|
||||
{me.avatar_url ? (
|
||||
<img src={me.avatar_url} alt="" className="h-full w-full object-cover" />
|
||||
|
|
@ -30,7 +54,7 @@ export function ProfileCard() {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
<AvatarUpload />
|
||||
<AvatarUpload onReady={handleReady} />
|
||||
<div>
|
||||
<p className="font-display text-xl">{me.display_nick}</p>
|
||||
<p className="text-sm text-frost">{me.email}</p>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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<AvatarHistoryEntry[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
listAvatarHistory()
|
||||
.then((list) => active && setEntries(list))
|
||||
.catch(() => active && setEntries([]))
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [tick])
|
||||
|
||||
if (entries.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 pt-1">
|
||||
<p className="text-xs text-frost">{t('avatar.historyHint')}</p>
|
||||
<ul className="flex list-none gap-2">
|
||||
{entries.map((entry) => (
|
||||
<li key={entry.id}>
|
||||
<HistoryThumb entry={entry} disabled={pending} onPick={onPick} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
title={t('avatar.useHistory')}
|
||||
aria-label={t('avatar.useHistory')}
|
||||
onClick={() => onPick(entry.blob)}
|
||||
className="h-10 w-10 cursor-pointer overflow-hidden rounded-full border border-shoal opacity-80 hover:border-ice hover:opacity-100 disabled:cursor-default"
|
||||
>
|
||||
{url ? <img src={url} alt="" className="h-full w-full object-cover" /> : null}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
145
frontend/src/features/account/components/avatar/AvatarUpload.tsx
Normal file
145
frontend/src/features/account/components/avatar/AvatarUpload.tsx
Normal file
|
|
@ -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<string>()
|
||||
const [picked, setPicked] = useState<File>()
|
||||
const [progress, setProgress] = useState<number | null>(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<HTMLInputElement>) {
|
||||
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 (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<AvatarCropper file={picked} onCancel={() => setPicked(undefined)} onConfirm={onConfirmCrop} />
|
||||
{progress !== null && <ProgressBar pct={progress} />}
|
||||
{error ? <Notice tone="error">{error}</Notice> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="cursor-pointer rounded-ctl border border-shoal px-3 py-1.5 text-sm text-snow hover:border-frost"
|
||||
>
|
||||
{t('avatar.change')}
|
||||
<input
|
||||
id={id}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
className="sr-only"
|
||||
onChange={onChange}
|
||||
/>
|
||||
</label>
|
||||
{me?.avatar_url ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={recropCurrent}
|
||||
className="cursor-pointer rounded-ctl border border-shoal px-3 py-1.5 text-sm text-snow hover:border-frost"
|
||||
>
|
||||
{t('avatar.recrop')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{progress !== null && <ProgressBar pct={progress} />}
|
||||
{done ? <Notice tone="success">{t('avatar.uploaded')}</Notice> : null}
|
||||
{error ? <Notice tone="error">{error}</Notice> : null}
|
||||
<AvatarHistoryStrip
|
||||
tick={historyTick}
|
||||
pending={mutation.isPending}
|
||||
onPick={(blob) => mutation.mutate(new File([blob], 'avatar.png', { type: 'image/png' }))}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressBar({ pct }: { pct: number }) {
|
||||
return (
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-valuenow={pct}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
className="h-1 w-full max-w-[240px] overflow-hidden rounded-full bg-shoal"
|
||||
>
|
||||
<div className="h-full bg-ice transition-[width] duration-150" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -5,6 +5,11 @@ export const accountRu = {
|
|||
},
|
||||
avatar: {
|
||||
change: 'Сменить аватар',
|
||||
recrop: 'Перекадрировать',
|
||||
uploaded: 'Аватар обновлён',
|
||||
fetchFailed: 'Не удалось загрузить текущий аватар.',
|
||||
historyHint: 'Последние на этом устройстве',
|
||||
useHistory: 'Вернуть этот аватар',
|
||||
reposition: 'Перетащи, чтобы выбрать область',
|
||||
zoom: 'Масштаб',
|
||||
confirm: 'Подтвердить',
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
16
frontend/src/features/account/tests/avatarHistory.test.ts
Normal file
16
frontend/src/features/account/tests/avatarHistory.test.ts
Normal file
|
|
@ -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')
|
||||
})
|
||||
|
|
@ -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: <AvatarUpload /> }])
|
||||
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: <AvatarUpload /> }])
|
||||
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: <ProfileCard /> }])
|
||||
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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
const res = await api.request<{ access_token: string }>('/auth/login', { method: 'POST', json: { email, password } })
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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<SlotSummary[]> {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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[] = [
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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() {} }))
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { api } from '../../shared/api/client'
|
||||
import { api } from '@shared/api/client'
|
||||
|
||||
export type PublicProfile = {
|
||||
id: string
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
|
|
|
|||
|
|
@ -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'] }
|
||||
|
|
|
|||
|
|
@ -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<ShowcasePage> {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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)'
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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)'
|
||||
|
||||
|
|
|
|||
|
|
@ -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)'
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export type RequestOptions = { method?: string; json?: unknown; body?: BodyInit;
|
|||
|
||||
export type ApiClient = {
|
||||
request<T>(path: string, options?: RequestOptions): Promise<T>
|
||||
upload(path: string, body: FormData, onProgress?: (pct: number) => void): Promise<unknown>
|
||||
refresh(): Promise<boolean>
|
||||
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<unknown> {
|
||||
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
|
||||
|
|
|
|||
87
frontend/src/shared/api/tests/client-upload.test.ts
Normal file
87
frontend/src/shared/api/tests/client-upload.test.ts
Normal file
|
|
@ -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<string, string> = {}
|
||||
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' })
|
||||
})
|
||||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => ({
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div role={tone === 'error' ? 'alert' : 'status'} className={`rounded-ctl border px-3 py-2 text-sm ${styles}`}>
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -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 } } })
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue