feat: auth & admin

This commit is contained in:
2026-06-03 10:41:53 +03:00
parent 612d0f0125
commit 7dc59fb3c4
120 changed files with 4683 additions and 2159 deletions
File diff suppressed because it is too large Load Diff
@@ -10,22 +10,25 @@ A structured repository for creating and maintaining React Best Practices optimi
- `area-description.md` - Individual rule files
- `src/` - Build scripts and utilities
- `metadata.json` - Document metadata (version, organization, abstract)
- __`AGENTS.md`__ - Compiled output (generated)
- __`test-cases.json`__ - Test cases for LLM evaluation (generated)
- **`AGENTS.md`** - Compiled output (generated)
- **`test-cases.json`** - Test cases for LLM evaluation (generated)
## Getting Started
1. Install dependencies:
```bash
pnpm install
```
2. Build AGENTS.md from rules:
```bash
pnpm build
```
3. Validate rule files:
```bash
pnpm validate
```
@@ -55,7 +58,7 @@ A structured repository for creating and maintaining React Best Practices optimi
Each rule file should follow this structure:
```markdown
````markdown
---
title: Rule Title Here
impact: MEDIUM
@@ -72,6 +75,7 @@ Brief explanation of the rule and why it matters.
```typescript
// Bad code example
```
````
**Correct (description of what's right):**
@@ -4,7 +4,7 @@ description: React and Next.js performance optimization guidelines from Vercel E
license: MIT
metadata:
author: vercel
version: "1.0.0"
version: '1.0.0'
---
# Vercel React Best Practices
@@ -14,6 +14,7 @@ Comprehensive performance optimization guide for React and Next.js applications,
## When to Apply
Reference these guidelines when:
- Writing new React components or Next.js pages
- Implementing data fetching (client or server-side)
- Reviewing code for performance issues
@@ -23,7 +24,7 @@ Reference these guidelines when:
## Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|----------|----------|--------|--------|
| -------- | ------------------------- | ----------- | ------------ |
| 1 | Eliminating Waterfalls | CRITICAL | `async-` |
| 2 | Bundle Size Optimization | CRITICAL | `bundle-` |
| 3 | Server-Side Performance | HIGH | `server-` |
@@ -139,6 +140,7 @@ rules/bundle-barrel-imports.md
```
Each rule file contains:
- Brief explanation of why it matters
- Incorrect code example with explanation
- Correct code example with explanation
@@ -15,14 +15,14 @@ Brief explanation of the rule and why it matters. This should be clear and conci
```typescript
// Bad code example here
const bad = example()
const bad = example();
```
**Correct (description of what's right):**
```typescript
// Good code example here
const good = example()
const good = example();
```
Reference: [Link to documentation or resource](https://example.com)
@@ -12,21 +12,24 @@ Effect Event functions do not have a stable identity. Their identity intentional
**Incorrect (Effect Event added as a dependency):**
```tsx
import { useEffect, useEffectEvent } from 'react'
import { useEffect, useEffectEvent } from 'react';
function ChatRoom({ roomId, onConnected }: {
roomId: string
onConnected: () => void
function ChatRoom({
roomId,
onConnected,
}: {
roomId: string;
onConnected: () => void;
}) {
const handleConnected = useEffectEvent(onConnected)
const handleConnected = useEffectEvent(onConnected);
useEffect(() => {
const connection = createConnection(roomId)
connection.on('connected', handleConnected)
connection.connect()
const connection = createConnection(roomId);
connection.on('connected', handleConnected);
connection.connect();
return () => connection.disconnect()
}, [roomId, handleConnected])
return () => connection.disconnect();
}, [roomId, handleConnected]);
}
```
@@ -35,21 +38,24 @@ Including the Effect Event in dependencies makes the effect re-run every render
**Correct (depend on reactive values, not the Effect Event):**
```tsx
import { useEffect, useEffectEvent } from 'react'
import { useEffect, useEffectEvent } from 'react';
function ChatRoom({ roomId, onConnected }: {
roomId: string
onConnected: () => void
function ChatRoom({
roomId,
onConnected,
}: {
roomId: string;
onConnected: () => void;
}) {
const handleConnected = useEffectEvent(onConnected)
const handleConnected = useEffectEvent(onConnected);
useEffect(() => {
const connection = createConnection(roomId)
connection.on('connected', handleConnected)
connection.connect()
const connection = createConnection(roomId);
connection.on('connected', handleConnected);
connection.connect();
return () => connection.disconnect()
}, [roomId])
return () => connection.disconnect();
}, [roomId]);
}
```
@@ -14,9 +14,9 @@ Store callbacks in refs when used in effects that shouldn't re-subscribe on call
```tsx
function useWindowEvent(event: string, handler: (e) => void) {
useEffect(() => {
window.addEventListener(event, handler)
return () => window.removeEventListener(event, handler)
}, [event, handler])
window.addEventListener(event, handler);
return () => window.removeEventListener(event, handler);
}, [event, handler]);
}
```
@@ -24,31 +24,31 @@ function useWindowEvent(event: string, handler: (e) => void) {
```tsx
function useWindowEvent(event: string, handler: (e) => void) {
const handlerRef = useRef(handler)
const handlerRef = useRef(handler);
useEffect(() => {
handlerRef.current = handler
}, [handler])
handlerRef.current = handler;
}, [handler]);
useEffect(() => {
const listener = (e) => handlerRef.current(e)
window.addEventListener(event, listener)
return () => window.removeEventListener(event, listener)
}, [event])
const listener = (e) => handlerRef.current(e);
window.addEventListener(event, listener);
return () => window.removeEventListener(event, listener);
}, [event]);
}
```
**Alternative: use `useEffectEvent` if you're on latest React:**
```tsx
import { useEffectEvent } from 'react'
import { useEffectEvent } from 'react';
function useWindowEvent(event: string, handler: (e) => void) {
const onEvent = useEffectEvent(handler)
const onEvent = useEffectEvent(handler);
useEffect(() => {
window.addEventListener(event, onEvent)
return () => window.removeEventListener(event, onEvent)
}, [event])
window.addEventListener(event, onEvent);
return () => window.removeEventListener(event, onEvent);
}, [event]);
}
```
@@ -14,9 +14,9 @@ Do not put app-wide initialization that must run once per app load inside `useEf
```tsx
function Comp() {
useEffect(() => {
loadFromStorage()
checkAuthToken()
}, [])
loadFromStorage();
checkAuthToken();
}, []);
// ...
}
@@ -25,15 +25,15 @@ function Comp() {
**Correct (once per app load):**
```tsx
let didInit = false
let didInit = false;
function Comp() {
useEffect(() => {
if (didInit) return
didInit = true
loadFromStorage()
checkAuthToken()
}, [])
if (didInit) return;
didInit = true;
loadFromStorage();
checkAuthToken();
}, []);
// ...
}
@@ -13,12 +13,12 @@ Access latest values in callbacks without adding them to dependency arrays. Prev
```tsx
function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
const [query, setQuery] = useState('')
const [query, setQuery] = useState('');
useEffect(() => {
const timeout = setTimeout(() => onSearch(query), 300)
return () => clearTimeout(timeout)
}, [query, onSearch])
const timeout = setTimeout(() => onSearch(query), 300);
return () => clearTimeout(timeout);
}, [query, onSearch]);
}
```
@@ -28,12 +28,12 @@ function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
import { useEffectEvent } from 'react';
function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
const [query, setQuery] = useState('')
const onSearchEvent = useEffectEvent(onSearch)
const [query, setQuery] = useState('');
const onSearchEvent = useEffectEvent(onSearch);
useEffect(() => {
const timeout = setTimeout(() => onSearchEvent(query), 300)
return () => clearTimeout(timeout)
}, [query])
const timeout = setTimeout(() => onSearchEvent(query), 300);
return () => clearTimeout(timeout);
}, [query]);
}
```
@@ -13,10 +13,10 @@ In API routes and Server Actions, start independent operations immediately, even
```typescript
export async function GET(request: Request) {
const session = await auth()
const config = await fetchConfig()
const data = await fetchData(session.user.id)
return Response.json({ data, config })
const session = await auth();
const config = await fetchConfig();
const data = await fetchData(session.user.id);
return Response.json({ data, config });
}
```
@@ -24,14 +24,14 @@ export async function GET(request: Request) {
```typescript
export async function GET(request: Request) {
const sessionPromise = auth()
const configPromise = fetchConfig()
const session = await sessionPromise
const sessionPromise = auth();
const configPromise = fetchConfig();
const session = await sessionPromise;
const [config, data] = await Promise.all([
configPromise,
fetchData(session.user.id)
])
return Response.json({ data, config })
fetchData(session.user.id),
]);
return Response.json({ data, config });
}
```
@@ -14,7 +14,7 @@ This is a specialization of [Defer Await Until Needed](./async-defer-await.md) f
**Incorrect:**
```typescript
const someFlag = await getFlag()
const someFlag = await getFlag();
if (someFlag && someCondition) {
// ...
@@ -25,7 +25,7 @@ if (someFlag && someCondition) {
```typescript
if (someCondition) {
const someFlag = await getFlag()
const someFlag = await getFlag();
if (someFlag) {
// ...
}
@@ -13,15 +13,15 @@ Move `await` operations into the branches where they're actually used to avoid b
```typescript
async function handleRequest(userId: string, skipProcessing: boolean) {
const userData = await fetchUserData(userId)
const userData = await fetchUserData(userId);
if (skipProcessing) {
// Returns immediately but still waited for userData
return { skipped: true }
return { skipped: true };
}
// Only this branch uses userData
return processUserData(userData)
return processUserData(userData);
}
```
@@ -31,12 +31,12 @@ async function handleRequest(userId: string, skipProcessing: boolean) {
async function handleRequest(userId: string, skipProcessing: boolean) {
if (skipProcessing) {
// Returns immediately without waiting
return { skipped: true }
return { skipped: true };
}
// Fetch only when needed
const userData = await fetchUserData(userId)
return processUserData(userData)
const userData = await fetchUserData(userId);
return processUserData(userData);
}
```
@@ -45,35 +45,35 @@ async function handleRequest(userId: string, skipProcessing: boolean) {
```typescript
// Incorrect: always fetches permissions
async function updateResource(resourceId: string, userId: string) {
const permissions = await fetchPermissions(userId)
const resource = await getResource(resourceId)
const permissions = await fetchPermissions(userId);
const resource = await getResource(resourceId);
if (!resource) {
return { error: 'Not found' }
return { error: 'Not found' };
}
if (!permissions.canEdit) {
return { error: 'Forbidden' }
return { error: 'Forbidden' };
}
return await updateResourceData(resource, permissions)
return await updateResourceData(resource, permissions);
}
// Correct: fetches only when needed
async function updateResource(resourceId: string, userId: string) {
const resource = await getResource(resourceId)
const resource = await getResource(resourceId);
if (!resource) {
return { error: 'Not found' }
return { error: 'Not found' };
}
const permissions = await fetchPermissions(userId)
const permissions = await fetchPermissions(userId);
if (!permissions.canEdit) {
return { error: 'Forbidden' }
return { error: 'Forbidden' };
}
return await updateResourceData(resource, permissions)
return await updateResourceData(resource, permissions);
}
```
@@ -12,25 +12,26 @@ For operations with partial dependencies, use `better-all` to maximize paralleli
**Incorrect (profile waits for config unnecessarily):**
```typescript
const [user, config] = await Promise.all([
fetchUser(),
fetchConfig()
])
const profile = await fetchProfile(user.id)
const [user, config] = await Promise.all([fetchUser(), fetchConfig()]);
const profile = await fetchProfile(user.id);
```
**Correct (config and profile run in parallel):**
```typescript
import { all } from 'better-all'
import { all } from 'better-all';
const { user, config, profile } = await all({
async user() { return fetchUser() },
async config() { return fetchConfig() },
async user() {
return fetchUser();
},
async config() {
return fetchConfig();
},
async profile() {
return fetchProfile((await this.$.user).id)
}
})
return fetchProfile((await this.$.user).id);
},
});
```
**Alternative without extra dependencies:**
@@ -38,14 +39,14 @@ const { user, config, profile } = await all({
We can also create all the promises first, and do `Promise.all()` at the end.
```typescript
const userPromise = fetchUser()
const profilePromise = userPromise.then(user => fetchProfile(user.id))
const userPromise = fetchUser();
const profilePromise = userPromise.then((user) => fetchProfile(user.id));
const [user, config, profile] = await Promise.all([
userPromise,
fetchConfig(),
profilePromise
])
profilePromise,
]);
```
Reference: [https://github.com/shuding/better-all](https://github.com/shuding/better-all)
@@ -12,9 +12,9 @@ When async operations have no interdependencies, execute them concurrently using
**Incorrect (sequential execution, 3 round trips):**
```typescript
const user = await fetchUser()
const posts = await fetchPosts()
const comments = await fetchComments()
const user = await fetchUser();
const posts = await fetchPosts();
const comments = await fetchComments();
```
**Correct (parallel execution, 1 round trip):**
@@ -23,6 +23,6 @@ const comments = await fetchComments()
const [user, posts, comments] = await Promise.all([
fetchUser(),
fetchPosts(),
fetchComments()
])
fetchComments(),
]);
```
@@ -13,7 +13,7 @@ Instead of awaiting data in async components before returning JSX, use Suspense
```tsx
async function Page() {
const data = await fetchData() // Blocks entire page
const data = await fetchData(); // Blocks entire page
return (
<div>
@@ -24,7 +24,7 @@ async function Page() {
</div>
<div>Footer</div>
</div>
)
);
}
```
@@ -45,12 +45,12 @@ function Page() {
</div>
<div>Footer</div>
</div>
)
);
}
async function DataDisplay() {
const data = await fetchData() // Only blocks this component
return <div>{data.content}</div>
const data = await fetchData(); // Only blocks this component
return <div>{data.content}</div>;
}
```
@@ -61,7 +61,7 @@ Sidebar, Header, and Footer render immediately. Only DataDisplay waits for data.
```tsx
function Page() {
// Start fetch immediately, but don't await
const dataPromise = fetchData()
const dataPromise = fetchData();
return (
<div>
@@ -73,17 +73,17 @@ function Page() {
</Suspense>
<div>Footer</div>
</div>
)
);
}
function DataDisplay({ dataPromise }: { dataPromise: Promise<Data> }) {
const data = use(dataPromise) // Unwraps the promise
return <div>{data.content}</div>
const data = use(dataPromise); // Unwraps the promise
return <div>{data.content}</div>;
}
function DataSummary({ dataPromise }: { dataPromise: Promise<Data> }) {
const data = use(dataPromise) // Reuses the same promise
return <div>{data.summary}</div>
const data = use(dataPromise); // Reuses the same promise
return <div>{data.summary}</div>;
}
```
@@ -12,6 +12,7 @@ Build tools work best when import and file-system paths are obvious at build tim
Prefer explicit maps or literal paths so the set of reachable files stays narrow and predictable. This is the same rule whether you are choosing modules with `import()` or reading files in server/build code.
When analysis becomes too broad, the cost is real:
- Larger server bundles
- Slower builds
- Worse cold starts
@@ -25,9 +26,9 @@ When analysis becomes too broad, the cost is real:
const PAGE_MODULES = {
home: './pages/home',
settings: './pages/settings',
} as const
} as const;
const Page = await import(PAGE_MODULES[pageName])
const Page = await import(PAGE_MODULES[pageName]);
```
**Correct (use an explicit map of allowed modules):**
@@ -36,9 +37,9 @@ const Page = await import(PAGE_MODULES[pageName])
const PAGE_MODULES = {
home: () => import('./pages/home'),
settings: () => import('./pages/settings'),
} as const
} as const;
const Page = await PAGE_MODULES[pageName]()
const Page = await PAGE_MODULES[pageName]();
```
### File-System Paths
@@ -46,7 +47,7 @@ const Page = await PAGE_MODULES[pageName]()
**Incorrect (a 2-value enum still hides the final path from static analysis):**
```ts
const baseDir = path.join(process.cwd(), 'content/' + contentKind)
const baseDir = path.join(process.cwd(), 'content/' + contentKind);
```
**Correct (make each final path literal at the callsite):**
@@ -55,7 +56,7 @@ const baseDir = path.join(process.cwd(), 'content/' + contentKind)
const baseDir =
kind === ContentKind.Blog
? path.join(process.cwd(), 'content/blog')
: path.join(process.cwd(), 'content/docs')
: path.join(process.cwd(), 'content/docs');
```
In Next.js server code, this matters for output file tracing too. `path.join(process.cwd(), someVar)` can widen the traced file set because Next.js statically analyze `import`, `require`, and `fs` usage.
@@ -16,11 +16,11 @@ Popular icon and component libraries can have **up to 10,000 re-exports** in the
**Incorrect (imports entire library):**
```tsx
import { Check, X, Menu } from 'lucide-react'
import { Check, X, Menu } from 'lucide-react';
// Loads 1,583 modules, takes ~2.8s extra in dev
// Runtime cost: 200-800ms on every cold start
import { Button, TextField } from '@mui/material'
import { Button, TextField } from '@mui/material';
// Loads 2,225 modules, takes ~4.2s extra in dev
```
@@ -30,14 +30,14 @@ import { Button, TextField } from '@mui/material'
// next.config.js - automatically optimizes barrel imports at build time
module.exports = {
experimental: {
optimizePackageImports: ['lucide-react', '@mui/material']
}
}
optimizePackageImports: ['lucide-react', '@mui/material'],
},
};
```
```tsx
// Keep the standard imports - Next.js transforms them to direct imports
import { Check, X, Menu } from 'lucide-react'
import { Check, X, Menu } from 'lucide-react';
// Full TypeScript support, no manual path wrangling
```
@@ -46,8 +46,8 @@ This is the recommended approach because it preserves TypeScript type safety and
**Correct - Direct imports (non-Next.js projects):**
```tsx
import Button from '@mui/material/Button'
import TextField from '@mui/material/TextField'
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
// Loads only what you use
```
@@ -12,19 +12,25 @@ Load large data or modules only when a feature is activated.
**Example (lazy-load animation frames):**
```tsx
function AnimationPlayer({ enabled, setEnabled }: { enabled: boolean; setEnabled: React.Dispatch<React.SetStateAction<boolean>> }) {
const [frames, setFrames] = useState<Frame[] | null>(null)
function AnimationPlayer({
enabled,
setEnabled,
}: {
enabled: boolean;
setEnabled: React.Dispatch<React.SetStateAction<boolean>>;
}) {
const [frames, setFrames] = useState<Frame[] | null>(null);
useEffect(() => {
if (enabled && !frames && typeof window !== 'undefined') {
import('./animation-frames.js')
.then(mod => setFrames(mod.frames))
.catch(() => setEnabled(false))
.then((mod) => setFrames(mod.frames))
.catch(() => setEnabled(false));
}
}, [enabled, frames, setEnabled])
}, [enabled, frames, setEnabled]);
if (!frames) return <Skeleton />
return <Canvas frames={frames} />
if (!frames) return <Skeleton />;
return <Canvas frames={frames} />;
}
```
@@ -12,7 +12,7 @@ Analytics, logging, and error tracking don't block user interaction. Load them a
**Incorrect (blocks initial bundle):**
```tsx
import { Analytics } from '@vercel/analytics/react'
import { Analytics } from '@vercel/analytics/react';
export default function RootLayout({ children }) {
return (
@@ -22,19 +22,19 @@ export default function RootLayout({ children }) {
<Analytics />
</body>
</html>
)
);
}
```
**Correct (loads after hydration):**
```tsx
import dynamic from 'next/dynamic'
import dynamic from 'next/dynamic';
const Analytics = dynamic(
() => import('@vercel/analytics/react').then(m => m.Analytics),
{ ssr: false }
)
() => import('@vercel/analytics/react').then((m) => m.Analytics),
{ ssr: false },
);
export default function RootLayout({ children }) {
return (
@@ -44,6 +44,6 @@ export default function RootLayout({ children }) {
<Analytics />
</body>
</html>
)
);
}
```
@@ -12,24 +12,24 @@ Use `next/dynamic` to lazy-load large components not needed on initial render.
**Incorrect (Monaco bundles with main chunk ~300KB):**
```tsx
import { MonacoEditor } from './monaco-editor'
import { MonacoEditor } from './monaco-editor';
function CodePanel({ code }: { code: string }) {
return <MonacoEditor value={code} />
return <MonacoEditor value={code} />;
}
```
**Correct (Monaco loads on demand):**
```tsx
import dynamic from 'next/dynamic'
import dynamic from 'next/dynamic';
const MonacoEditor = dynamic(
() => import('./monaco-editor').then(m => m.MonacoEditor),
{ ssr: false }
)
() => import('./monaco-editor').then((m) => m.MonacoEditor),
{ ssr: false },
);
function CodePanel({ code }: { code: string }) {
return <MonacoEditor value={code} />
return <MonacoEditor value={code} />;
}
```
@@ -15,19 +15,15 @@ Preload heavy bundles before they're needed to reduce perceived latency.
function EditorButton({ onClick }: { onClick: () => void }) {
const preload = () => {
if (typeof window !== 'undefined') {
void import('./monaco-editor')
}
void import('./monaco-editor');
}
};
return (
<button
onMouseEnter={preload}
onFocus={preload}
onClick={onClick}
>
<button onMouseEnter={preload} onFocus={preload} onClick={onClick}>
Open Editor
</button>
)
);
}
```
@@ -37,13 +33,13 @@ function EditorButton({ onClick }: { onClick: () => void }) {
function FlagsProvider({ children, flags }: Props) {
useEffect(() => {
if (flags.editorEnabled && typeof window !== 'undefined') {
void import('./monaco-editor').then(mod => mod.init())
void import('./monaco-editor').then((mod) => mod.init());
}
}, [flags.editorEnabled])
}, [flags.editorEnabled]);
return <FlagsContext.Provider value={flags}>
{children}
</FlagsContext.Provider>
return (
<FlagsContext.Provider value={flags}>{children}</FlagsContext.Provider>
);
}
```
@@ -16,12 +16,12 @@ function useKeyboardShortcut(key: string, callback: () => void) {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.metaKey && e.key === key) {
callback()
callback();
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [key, callback])
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [key, callback]);
}
```
@@ -30,45 +30,49 @@ When using the `useKeyboardShortcut` hook multiple times, each instance will reg
**Correct (N instances = 1 listener):**
```tsx
import useSWRSubscription from 'swr/subscription'
import useSWRSubscription from 'swr/subscription';
// Module-level Map to track callbacks per key
const keyCallbacks = new Map<string, Set<() => void>>()
const keyCallbacks = new Map<string, Set<() => void>>();
function useKeyboardShortcut(key: string, callback: () => void) {
// Register this callback in the Map
useEffect(() => {
if (!keyCallbacks.has(key)) {
keyCallbacks.set(key, new Set())
keyCallbacks.set(key, new Set());
}
keyCallbacks.get(key)!.add(callback)
keyCallbacks.get(key)!.add(callback);
return () => {
const set = keyCallbacks.get(key)
const set = keyCallbacks.get(key);
if (set) {
set.delete(callback)
set.delete(callback);
if (set.size === 0) {
keyCallbacks.delete(key)
keyCallbacks.delete(key);
}
}
}
}, [key, callback])
};
}, [key, callback]);
useSWRSubscription('global-keydown', () => {
const handler = (e: KeyboardEvent) => {
if (e.metaKey && keyCallbacks.has(e.key)) {
keyCallbacks.get(e.key)!.forEach(cb => cb())
keyCallbacks.get(e.key)!.forEach((cb) => cb());
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
})
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
});
}
function Profile() {
// Multiple shortcuts will share the same listener
useKeyboardShortcut('p', () => { /* ... */ })
useKeyboardShortcut('k', () => { /* ... */ })
useKeyboardShortcut('p', () => {
/* ... */
});
useKeyboardShortcut('k', () => {
/* ... */
});
// ...
}
```
@@ -13,18 +13,18 @@ Add version prefix to keys and store only needed fields. Prevents schema conflic
```typescript
// No version, stores everything, no error handling
localStorage.setItem('userConfig', JSON.stringify(fullUserObject))
const data = localStorage.getItem('userConfig')
localStorage.setItem('userConfig', JSON.stringify(fullUserObject));
const data = localStorage.getItem('userConfig');
```
**Correct:**
```typescript
const VERSION = 'v2'
const VERSION = 'v2';
function saveConfig(config: { theme: string; language: string }) {
try {
localStorage.setItem(`userConfig:${VERSION}`, JSON.stringify(config))
localStorage.setItem(`userConfig:${VERSION}`, JSON.stringify(config));
} catch {
// Throws in incognito/private browsing, quota exceeded, or disabled
}
@@ -32,21 +32,24 @@ function saveConfig(config: { theme: string; language: string }) {
function loadConfig() {
try {
const data = localStorage.getItem(`userConfig:${VERSION}`)
return data ? JSON.parse(data) : null
const data = localStorage.getItem(`userConfig:${VERSION}`);
return data ? JSON.parse(data) : null;
} catch {
return null
return null;
}
}
// Migration from v1 to v2
function migrate() {
try {
const v1 = localStorage.getItem('userConfig:v1')
const v1 = localStorage.getItem('userConfig:v1');
if (v1) {
const old = JSON.parse(v1)
saveConfig({ theme: old.darkMode ? 'dark' : 'light', language: old.lang })
localStorage.removeItem('userConfig:v1')
const old = JSON.parse(v1);
saveConfig({
theme: old.darkMode ? 'dark' : 'light',
language: old.lang,
});
localStorage.removeItem('userConfig:v1');
}
} catch {}
}
@@ -58,10 +61,13 @@ function migrate() {
// User object has 20+ fields, only store what UI needs
function cachePrefs(user: FullUser) {
try {
localStorage.setItem('prefs:v1', JSON.stringify({
localStorage.setItem(
'prefs:v1',
JSON.stringify({
theme: user.preferences.theme,
notifications: user.preferences.notifications
}))
notifications: user.preferences.notifications,
}),
);
} catch {}
}
```
@@ -13,34 +13,34 @@ Add `{ passive: true }` to touch and wheel event listeners to enable immediate s
```typescript
useEffect(() => {
const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX)
const handleWheel = (e: WheelEvent) => console.log(e.deltaY)
const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX);
const handleWheel = (e: WheelEvent) => console.log(e.deltaY);
document.addEventListener('touchstart', handleTouch)
document.addEventListener('wheel', handleWheel)
document.addEventListener('touchstart', handleTouch);
document.addEventListener('wheel', handleWheel);
return () => {
document.removeEventListener('touchstart', handleTouch)
document.removeEventListener('wheel', handleWheel)
}
}, [])
document.removeEventListener('touchstart', handleTouch);
document.removeEventListener('wheel', handleWheel);
};
}, []);
```
**Correct:**
```typescript
useEffect(() => {
const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX)
const handleWheel = (e: WheelEvent) => console.log(e.deltaY)
const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX);
const handleWheel = (e: WheelEvent) => console.log(e.deltaY);
document.addEventListener('touchstart', handleTouch, { passive: true })
document.addEventListener('wheel', handleWheel, { passive: true })
document.addEventListener('touchstart', handleTouch, { passive: true });
document.addEventListener('wheel', handleWheel, { passive: true });
return () => {
document.removeEventListener('touchstart', handleTouch)
document.removeEventListener('wheel', handleWheel)
}
}, [])
document.removeEventListener('touchstart', handleTouch);
document.removeEventListener('wheel', handleWheel);
};
}, []);
```
**Use passive when:** tracking/analytics, logging, any listener that doesn't call `preventDefault()`.
@@ -13,43 +13,43 @@ SWR enables request deduplication, caching, and revalidation across component in
```tsx
function UserList() {
const [users, setUsers] = useState([])
const [users, setUsers] = useState([]);
useEffect(() => {
fetch('/api/users')
.then(r => r.json())
.then(setUsers)
}, [])
.then((r) => r.json())
.then(setUsers);
}, []);
}
```
**Correct (multiple instances share one request):**
```tsx
import useSWR from 'swr'
import useSWR from 'swr';
function UserList() {
const { data: users } = useSWR('/api/users', fetcher)
const { data: users } = useSWR('/api/users', fetcher);
}
```
**For immutable data:**
```tsx
import { useImmutableSWR } from '@/lib/swr'
import { useImmutableSWR } from '@/lib/swr';
function StaticContent() {
const { data } = useImmutableSWR('/api/config', fetcher)
const { data } = useImmutableSWR('/api/config', fetcher);
}
```
**For mutations:**
```tsx
import { useSWRMutation } from 'swr/mutation'
import { useSWRMutation } from 'swr/mutation';
function UpdateButton() {
const { trigger } = useSWRMutation('/api/user', updateUser)
return <button onClick={() => trigger()}>Update</button>
const { trigger } = useSWRMutation('/api/user', updateUser);
return <button onClick={() => trigger()}>Update</button>;
}
```
@@ -10,55 +10,60 @@ tags: javascript, dom, css, performance, reflow, layout-thrashing
Avoid interleaving style writes with layout reads. When you read a layout property (like `offsetWidth`, `getBoundingClientRect()`, or `getComputedStyle()`) between style changes, the browser is forced to trigger a synchronous reflow.
**This is OK (browser batches style changes):**
```typescript
function updateElementStyles(element: HTMLElement) {
// Each line invalidates style, but browser batches the recalculation
element.style.width = '100px'
element.style.height = '200px'
element.style.backgroundColor = 'blue'
element.style.border = '1px solid black'
element.style.width = '100px';
element.style.height = '200px';
element.style.backgroundColor = 'blue';
element.style.border = '1px solid black';
}
```
**Incorrect (interleaved reads and writes force reflows):**
```typescript
function layoutThrashing(element: HTMLElement) {
element.style.width = '100px'
const width = element.offsetWidth // Forces reflow
element.style.height = '200px'
const height = element.offsetHeight // Forces another reflow
element.style.width = '100px';
const width = element.offsetWidth; // Forces reflow
element.style.height = '200px';
const height = element.offsetHeight; // Forces another reflow
}
```
**Correct (batch writes, then read once):**
```typescript
function updateElementStyles(element: HTMLElement) {
// Batch all writes together
element.style.width = '100px'
element.style.height = '200px'
element.style.backgroundColor = 'blue'
element.style.border = '1px solid black'
element.style.width = '100px';
element.style.height = '200px';
element.style.backgroundColor = 'blue';
element.style.border = '1px solid black';
// Read after all writes are done (single reflow)
const { width, height } = element.getBoundingClientRect()
const { width, height } = element.getBoundingClientRect();
}
```
**Correct (batch reads, then writes):**
```typescript
function avoidThrashing(element: HTMLElement) {
// Read phase - all layout queries first
const rect1 = element.getBoundingClientRect()
const offsetWidth = element.offsetWidth
const offsetHeight = element.offsetHeight
const rect1 = element.getBoundingClientRect();
const offsetWidth = element.offsetWidth;
const offsetHeight = element.offsetHeight;
// Write phase - all style changes after
element.style.width = '100px'
element.style.height = '200px'
element.style.width = '100px';
element.style.height = '200px';
}
```
**Better: use CSS classes**
```css
.highlighted-box {
width: 100px;
@@ -67,38 +72,36 @@ function avoidThrashing(element: HTMLElement) {
border: 1px solid black;
}
```
```typescript
function updateElementStyles(element: HTMLElement) {
element.classList.add('highlighted-box')
element.classList.add('highlighted-box');
const { width, height } = element.getBoundingClientRect()
const { width, height } = element.getBoundingClientRect();
}
```
**React example:**
```tsx
// Incorrect: interleaving style changes with layout queries
function Box({ isHighlighted }: { isHighlighted: boolean }) {
const ref = useRef<HTMLDivElement>(null)
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (ref.current && isHighlighted) {
ref.current.style.width = '100px'
const width = ref.current.offsetWidth // Forces layout
ref.current.style.height = '200px'
ref.current.style.width = '100px';
const width = ref.current.offsetWidth; // Forces layout
ref.current.style.height = '200px';
}
}, [isHighlighted])
}, [isHighlighted]);
return <div ref={ref}>Content</div>
return <div ref={ref}>Content</div>;
}
// Correct: toggle class
function Box({ isHighlighted }: { isHighlighted: boolean }) {
return (
<div className={isHighlighted ? 'highlighted-box' : ''}>
Content
</div>
)
return <div className={isHighlighted ? 'highlighted-box' : ''}>Content</div>;
}
```
@@ -58,20 +58,20 @@ function ProjectList({ projects }: { projects: Project[] }) {
**Simpler pattern for single-value functions:**
```typescript
let isLoggedInCache: boolean | null = null
let isLoggedInCache: boolean | null = null;
function isLoggedIn(): boolean {
if (isLoggedInCache !== null) {
return isLoggedInCache
return isLoggedInCache;
}
isLoggedInCache = document.cookie.includes('auth=')
return isLoggedInCache
isLoggedInCache = document.cookie.includes('auth=');
return isLoggedInCache;
}
// Clear cache when auth changes
function onAuthChange() {
isLoggedInCache = null
isLoggedInCache = null;
}
```
@@ -13,16 +13,16 @@ Cache object property lookups in hot paths.
```typescript
for (let i = 0; i < arr.length; i++) {
process(obj.config.settings.value)
process(obj.config.settings.value);
}
```
**Correct (1 lookup total):**
```typescript
const value = obj.config.settings.value
const len = arr.length
const value = obj.config.settings.value;
const len = arr.length;
for (let i = 0; i < len; i++) {
process(value)
process(value);
}
```
@@ -13,7 +13,7 @@ tags: javascript, localStorage, storage, caching, performance
```typescript
function getTheme() {
return localStorage.getItem('theme') ?? 'light'
return localStorage.getItem('theme') ?? 'light';
}
// Called 10 times = 10 storage reads
```
@@ -21,18 +21,18 @@ function getTheme() {
**Correct (Map cache):**
```typescript
const storageCache = new Map<string, string | null>()
const storageCache = new Map<string, string | null>();
function getLocalStorage(key: string) {
if (!storageCache.has(key)) {
storageCache.set(key, localStorage.getItem(key))
storageCache.set(key, localStorage.getItem(key));
}
return storageCache.get(key)
return storageCache.get(key);
}
function setLocalStorage(key: string, value: string) {
localStorage.setItem(key, value)
storageCache.set(key, value) // keep cache in sync
localStorage.setItem(key, value);
storageCache.set(key, value); // keep cache in sync
}
```
@@ -41,15 +41,15 @@ Use a Map (not a hook) so it works everywhere: utilities, event handlers, not ju
**Cookie caching:**
```typescript
let cookieCache: Record<string, string> | null = null
let cookieCache: Record<string, string> | null = null;
function getCookie(name: string) {
if (!cookieCache) {
cookieCache = Object.fromEntries(
document.cookie.split('; ').map(c => c.split('='))
)
document.cookie.split('; ').map((c) => c.split('=')),
);
}
return cookieCache[name]
return cookieCache[name];
}
```
@@ -59,12 +59,12 @@ If storage can change externally (another tab, server-set cookies), invalidate c
```typescript
window.addEventListener('storage', (e) => {
if (e.key) storageCache.delete(e.key)
})
if (e.key) storageCache.delete(e.key);
});
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
storageCache.clear()
storageCache.clear();
}
})
});
```
@@ -12,21 +12,21 @@ Multiple `.filter()` or `.map()` calls iterate the array multiple times. Combine
**Incorrect (3 iterations):**
```typescript
const admins = users.filter(u => u.isAdmin)
const testers = users.filter(u => u.isTester)
const inactive = users.filter(u => !u.isActive)
const admins = users.filter((u) => u.isAdmin);
const testers = users.filter((u) => u.isTester);
const inactive = users.filter((u) => !u.isActive);
```
**Correct (1 iteration):**
```typescript
const admins: User[] = []
const testers: User[] = []
const inactive: User[] = []
const admins: User[] = [];
const testers: User[] = [];
const inactive: User[] = [];
for (const user of users) {
if (user.isAdmin) admins.push(user)
if (user.isTester) testers.push(user)
if (!user.isActive) inactive.push(user)
if (user.isAdmin) admins.push(user);
if (user.isTester) testers.push(user);
if (!user.isActive) inactive.push(user);
}
```
@@ -13,22 +13,22 @@ Return early when result is determined to skip unnecessary processing.
```typescript
function validateUsers(users: User[]) {
let hasError = false
let errorMessage = ''
let hasError = false;
let errorMessage = '';
for (const user of users) {
if (!user.email) {
hasError = true
errorMessage = 'Email required'
hasError = true;
errorMessage = 'Email required';
}
if (!user.name) {
hasError = true
errorMessage = 'Name required'
hasError = true;
errorMessage = 'Name required';
}
// Continues checking all users even after error found
}
return hasError ? { valid: false, error: errorMessage } : { valid: true }
return hasError ? { valid: false, error: errorMessage } : { valid: true };
}
```
@@ -38,13 +38,13 @@ function validateUsers(users: User[]) {
function validateUsers(users: User[]) {
for (const user of users) {
if (!user.email) {
return { valid: false, error: 'Email required' }
return { valid: false, error: 'Email required' };
}
if (!user.name) {
return { valid: false, error: 'Name required' }
return { valid: false, error: 'Name required' };
}
}
return { valid: true }
return { valid: true };
}
```
@@ -15,16 +15,14 @@ Chaining `.map().filter(Boolean)` creates an intermediate array and iterates twi
```typescript
const userNames = users
.map(user => user.isActive ? user.name : null)
.filter(Boolean)
.map((user) => (user.isActive ? user.name : null))
.filter(Boolean);
```
**Correct (1 iteration, no intermediate array):**
```typescript
const userNames = users.flatMap(user =>
user.isActive ? [user.name] : []
)
const userNames = users.flatMap((user) => (user.isActive ? [user.name] : []));
```
**More examples:**
@@ -33,28 +31,25 @@ const userNames = users.flatMap(user =>
// Extract valid emails from responses
// Before
const emails = responses
.map(r => r.success ? r.data.email : null)
.filter(Boolean)
.map((r) => (r.success ? r.data.email : null))
.filter(Boolean);
// After
const emails = responses.flatMap(r =>
r.success ? [r.data.email] : []
)
const emails = responses.flatMap((r) => (r.success ? [r.data.email] : []));
// Parse and filter valid numbers
// Before
const numbers = strings
.map(s => parseInt(s, 10))
.filter(n => !isNaN(n))
const numbers = strings.map((s) => parseInt(s, 10)).filter((n) => !isNaN(n));
// After
const numbers = strings.flatMap(s => {
const n = parseInt(s, 10)
return isNaN(n) ? [] : [n]
})
const numbers = strings.flatMap((s) => {
const n = parseInt(s, 10);
return isNaN(n) ? [] : [n];
});
```
**When to use:**
- Transforming items while filtering some out
- Conditional mapping where some inputs produce no output
- Parsing/validating where invalid inputs should be skipped
@@ -39,7 +39,7 @@ function Highlighter({ text, query }: Props) {
Global regex (`/g`) has mutable `lastIndex` state:
```typescript
const regex = /foo/g
regex.test('foo') // true, lastIndex = 3
regex.test('foo') // false, lastIndex = 0
const regex = /foo/g;
regex.test('foo'); // true, lastIndex = 3
regex.test('foo'); // false, lastIndex = 0
```
@@ -13,10 +13,10 @@ Multiple `.find()` calls by the same key should use a Map.
```typescript
function processOrders(orders: Order[], users: User[]) {
return orders.map(order => ({
return orders.map((order) => ({
...order,
user: users.find(u => u.id === order.userId)
}))
user: users.find((u) => u.id === order.userId),
}));
}
```
@@ -24,12 +24,12 @@ function processOrders(orders: Order[], users: User[]) {
```typescript
function processOrders(orders: Order[], users: User[]) {
const userById = new Map(users.map(u => [u.id, u]))
const userById = new Map(users.map((u) => [u.id, u]));
return orders.map(order => ({
return orders.map((order) => ({
...order,
user: userById.get(order.userId)
}))
user: userById.get(order.userId),
}));
}
```
@@ -16,7 +16,7 @@ In real-world applications, this optimization is especially valuable when the co
```typescript
function hasChanges(current: string[], original: string[]) {
// Always sorts and joins, even when lengths differ
return current.sort().join() !== original.sort().join()
return current.sort().join() !== original.sort().join();
}
```
@@ -28,21 +28,22 @@ Two O(n log n) sorts run even when `current.length` is 5 and `original.length` i
function hasChanges(current: string[], original: string[]) {
// Early return if lengths differ
if (current.length !== original.length) {
return true
return true;
}
// Only sort when lengths match
const currentSorted = current.toSorted()
const originalSorted = original.toSorted()
const currentSorted = current.toSorted();
const originalSorted = original.toSorted();
for (let i = 0; i < currentSorted.length; i++) {
if (currentSorted[i] !== originalSorted[i]) {
return true
return true;
}
}
return false
return false;
}
```
This new approach is more efficient because:
- It avoids the overhead of sorting and joining the arrays when lengths differ
- It avoids consuming memory for the joined strings (especially important for large arrays)
- It avoids mutating the original arrays
@@ -13,14 +13,14 @@ Finding the smallest or largest element only requires a single pass through the
```typescript
interface Project {
id: string
name: string
updatedAt: number
id: string;
name: string;
updatedAt: number;
}
function getLatestProject(projects: Project[]) {
const sorted = [...projects].sort((a, b) => b.updatedAt - a.updatedAt)
return sorted[0]
const sorted = [...projects].sort((a, b) => b.updatedAt - a.updatedAt);
return sorted[0];
}
```
@@ -30,8 +30,8 @@ Sorts the entire array just to find the maximum value.
```typescript
function getOldestAndNewest(projects: Project[]) {
const sorted = [...projects].sort((a, b) => a.updatedAt - b.updatedAt)
return { oldest: sorted[0], newest: sorted[sorted.length - 1] }
const sorted = [...projects].sort((a, b) => a.updatedAt - b.updatedAt);
return { oldest: sorted[0], newest: sorted[sorted.length - 1] };
}
```
@@ -41,31 +41,31 @@ Still sorts unnecessarily when only min/max are needed.
```typescript
function getLatestProject(projects: Project[]) {
if (projects.length === 0) return null
if (projects.length === 0) return null;
let latest = projects[0]
let latest = projects[0];
for (let i = 1; i < projects.length; i++) {
if (projects[i].updatedAt > latest.updatedAt) {
latest = projects[i]
latest = projects[i];
}
}
return latest
return latest;
}
function getOldestAndNewest(projects: Project[]) {
if (projects.length === 0) return { oldest: null, newest: null }
if (projects.length === 0) return { oldest: null, newest: null };
let oldest = projects[0]
let newest = projects[0]
let oldest = projects[0];
let newest = projects[0];
for (let i = 1; i < projects.length; i++) {
if (projects[i].updatedAt < oldest.updatedAt) oldest = projects[i]
if (projects[i].updatedAt > newest.updatedAt) newest = projects[i]
if (projects[i].updatedAt < oldest.updatedAt) oldest = projects[i];
if (projects[i].updatedAt > newest.updatedAt) newest = projects[i];
}
return { oldest, newest }
return { oldest, newest };
}
```
@@ -74,9 +74,9 @@ Single pass through the array, no copying, no sorting.
**Alternative (Math.min/Math.max for small arrays):**
```typescript
const numbers = [5, 2, 8, 1, 9]
const min = Math.min(...numbers)
const max = Math.max(...numbers)
const numbers = [5, 2, 8, 1, 9];
const min = Math.min(...numbers);
const max = Math.max(...numbers);
```
This works for small arrays, but can be slower or just throw an error for very large arrays due to spread operator limitations. Maximal array length is approximately 124000 in Chrome 143 and 638000 in Safari 18; exact numbers may vary - see [the fiddle](https://jsfiddle.net/qw1jabsx/4/). Use the loop approach for reliability.
@@ -15,13 +15,13 @@ Use `requestIdleCallback()` to schedule non-critical work during browser idle pe
```typescript
function handleSearch(query: string) {
const results = searchItems(query)
setResults(results)
const results = searchItems(query);
setResults(results);
// These block the main thread immediately
analytics.track('search', { query })
saveToRecentSearches(query)
prefetchTopResults(results.slice(0, 3))
analytics.track('search', { query });
saveToRecentSearches(query);
prefetchTopResults(results.slice(0, 3));
}
```
@@ -29,21 +29,21 @@ function handleSearch(query: string) {
```typescript
function handleSearch(query: string) {
const results = searchItems(query)
setResults(results)
const results = searchItems(query);
setResults(results);
// Defer non-critical work to idle periods
requestIdleCallback(() => {
analytics.track('search', { query })
})
analytics.track('search', { query });
});
requestIdleCallback(() => {
saveToRecentSearches(query)
})
saveToRecentSearches(query);
});
requestIdleCallback(() => {
prefetchTopResults(results.slice(0, 3))
})
prefetchTopResults(results.slice(0, 3));
});
}
```
@@ -53,41 +53,42 @@ function handleSearch(query: string) {
// Ensure analytics fires within 2 seconds even if browser stays busy
requestIdleCallback(
() => analytics.track('page_view', { path: location.pathname }),
{ timeout: 2000 }
)
{ timeout: 2000 },
);
```
**Chunking large tasks:**
```typescript
function processLargeDataset(items: Item[]) {
let index = 0
let index = 0;
function processChunk(deadline: IdleDeadline) {
// Process items while we have idle time (aim for <50ms chunks)
while (index < items.length && deadline.timeRemaining() > 0) {
processItem(items[index])
index++
processItem(items[index]);
index++;
}
// Schedule next chunk if more items remain
if (index < items.length) {
requestIdleCallback(processChunk)
requestIdleCallback(processChunk);
}
}
requestIdleCallback(processChunk)
requestIdleCallback(processChunk);
}
```
**With fallback for unsupported browsers:**
```typescript
const scheduleIdleWork = window.requestIdleCallback ?? ((cb: () => void) => setTimeout(cb, 1))
const scheduleIdleWork =
window.requestIdleCallback ?? ((cb: () => void) => setTimeout(cb, 1));
scheduleIdleWork(() => {
// Non-critical work
})
});
```
**When to use:**
@@ -46,7 +46,7 @@ function UserList({ users }: { users: User[] }) {
```typescript
// Fallback for older browsers
const sorted = [...items].sort((a, b) => a.value - b.value)
const sorted = [...items].sort((a, b) => a.value - b.value);
```
**Other immutable array methods:**
@@ -12,14 +12,14 @@ Use React's `<Activity>` to preserve state/DOM for expensive components that fre
**Usage:**
```tsx
import { Activity } from 'react'
import { Activity } from 'react';
function Dropdown({ isOpen }: Props) {
return (
<Activity mode={isOpen ? 'visible' : 'hidden'}>
<ExpensiveMenu />
</Activity>
)
);
}
```
@@ -14,15 +14,10 @@ Many browsers don't have hardware acceleration for CSS3 animations on SVG elemen
```tsx
function LoadingSpinner() {
return (
<svg
className="animate-spin"
width="24"
height="24"
viewBox="0 0 24 24"
>
<svg className="animate-spin" width="24" height="24" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="currentColor" />
</svg>
)
);
}
```
@@ -32,15 +27,11 @@ function LoadingSpinner() {
function LoadingSpinner() {
return (
<div className="animate-spin">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
>
<svg width="24" height="24" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="currentColor" />
</svg>
</div>
)
);
}
```
@@ -13,11 +13,7 @@ Use explicit ternary operators (`? :`) instead of `&&` for conditional rendering
```tsx
function Badge({ count }: { count: number }) {
return (
<div>
{count && <span className="badge">{count}</span>}
</div>
)
return <div>{count && <span className="badge">{count}</span>}</div>;
}
// When count = 0, renders: <div>0</div>
@@ -28,11 +24,7 @@ function Badge({ count }: { count: number }) {
```tsx
function Badge({ count }: { count: number }) {
return (
<div>
{count > 0 ? <span className="badge">{count}</span> : null}
</div>
)
return <div>{count > 0 ? <span className="badge">{count}</span> : null}</div>;
}
// When count = 0, renders: <div></div>
@@ -24,14 +24,14 @@ Apply `content-visibility: auto` to defer off-screen rendering.
function MessageList({ messages }: { messages: Message[] }) {
return (
<div className="overflow-y-auto h-screen">
{messages.map(msg => (
{messages.map((msg) => (
<div key={msg.id} className="message-item">
<Avatar user={msg.author} />
<div>{msg.content}</div>
</div>
))}
</div>
)
);
}
```
@@ -13,31 +13,21 @@ Extract static JSX outside components to avoid re-creation.
```tsx
function LoadingSkeleton() {
return <div className="animate-pulse h-20 bg-gray-200" />
return <div className="animate-pulse h-20 bg-gray-200" />;
}
function Container() {
return (
<div>
{loading && <LoadingSkeleton />}
</div>
)
return <div>{loading && <LoadingSkeleton />}</div>;
}
```
**Correct (reuses same element):**
```tsx
const loadingSkeleton = (
<div className="animate-pulse h-20 bg-gray-200" />
)
const loadingSkeleton = <div className="animate-pulse h-20 bg-gray-200" />;
function Container() {
return (
<div>
{loading && loadingSkeleton}
</div>
)
return <div>{loading && loadingSkeleton}</div>;
}
```
@@ -14,13 +14,9 @@ When rendering content that depends on client-side storage (localStorage, cookie
```tsx
function ThemeWrapper({ children }: { children: ReactNode }) {
// localStorage is not available on server - throws error
const theme = localStorage.getItem('theme') || 'light'
const theme = localStorage.getItem('theme') || 'light';
return (
<div className={theme}>
{children}
</div>
)
return <div className={theme}>{children}</div>;
}
```
@@ -30,21 +26,17 @@ Server-side rendering will fail because `localStorage` is undefined.
```tsx
function ThemeWrapper({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState('light')
const [theme, setTheme] = useState('light');
useEffect(() => {
// Runs after hydration - causes visible flash
const stored = localStorage.getItem('theme')
const stored = localStorage.getItem('theme');
if (stored) {
setTheme(stored)
setTheme(stored);
}
}, [])
}, []);
return (
<div className={theme}>
{children}
</div>
)
return <div className={theme}>{children}</div>;
}
```
@@ -56,9 +48,7 @@ Component first renders with default value (`light`), then updates after hydrati
function ThemeWrapper({ children }: { children: ReactNode }) {
return (
<>
<div id="theme-wrapper">
{children}
</div>
<div id="theme-wrapper">{children}</div>
<script
dangerouslySetInnerHTML={{
__html: `
@@ -73,7 +63,7 @@ function ThemeWrapper({ children }: { children: ReactNode }) {
}}
/>
</>
)
);
}
```
@@ -7,13 +7,13 @@ tags: rendering, hydration, ssr, nextjs
## Suppress Expected Hydration Mismatches
In SSR frameworks (e.g., Next.js), some values are intentionally different on server vs client (random IDs, dates, locale/timezone formatting). For these *expected* mismatches, wrap the dynamic text in an element with `suppressHydrationWarning` to prevent noisy warnings. Do not use this to hide real bugs. Dont overuse it.
In SSR frameworks (e.g., Next.js), some values are intentionally different on server vs client (random IDs, dates, locale/timezone formatting). For these _expected_ mismatches, wrap the dynamic text in an element with `suppressHydrationWarning` to prevent noisy warnings. Do not use this to hide real bugs. Dont overuse it.
**Incorrect (known mismatch warnings):**
```tsx
function Timestamp() {
return <span>{new Date().toLocaleString()}</span>
return <span>{new Date().toLocaleString()}</span>;
}
```
@@ -21,10 +21,6 @@ function Timestamp() {
```tsx
function Timestamp() {
return (
<span suppressHydrationWarning>
{new Date().toLocaleString()}
</span>
)
return <span suppressHydrationWarning>{new Date().toLocaleString()}</span>;
}
```
@@ -21,45 +21,49 @@ React DOM provides APIs to hint the browser about resources it will need. These
**Example (preconnect to third-party APIs):**
```tsx
import { preconnect, prefetchDNS } from 'react-dom'
import { preconnect, prefetchDNS } from 'react-dom';
export default function App() {
prefetchDNS('https://analytics.example.com')
preconnect('https://api.example.com')
prefetchDNS('https://analytics.example.com');
preconnect('https://api.example.com');
return <main>{/* content */}</main>
return <main>{/* content */}</main>;
}
```
**Example (preload critical fonts and styles):**
```tsx
import { preload, preinit } from 'react-dom'
import { preload, preinit } from 'react-dom';
export default function RootLayout({ children }) {
// Preload font file
preload('/fonts/inter.woff2', { as: 'font', type: 'font/woff2', crossOrigin: 'anonymous' })
preload('/fonts/inter.woff2', {
as: 'font',
type: 'font/woff2',
crossOrigin: 'anonymous',
});
// Fetch and apply critical stylesheet immediately
preinit('/styles/critical.css', { as: 'style' })
preinit('/styles/critical.css', { as: 'style' });
return (
<html>
<body>{children}</body>
</html>
)
);
}
```
**Example (preload modules for code-split routes):**
```tsx
import { preloadModule, preinitModule } from 'react-dom'
import { preloadModule, preinitModule } from 'react-dom';
function Navigation() {
const preloadDashboard = () => {
preloadModule('/dashboard.js', { as: 'script' })
}
preloadModule('/dashboard.js', { as: 'script' });
};
return (
<nav>
@@ -67,14 +71,14 @@ function Navigation() {
Dashboard
</a>
</nav>
)
);
}
```
**When to use each:**
| API | Use case |
|-----|----------|
| --------------- | ------------------------------------------- |
| `prefetchDNS` | Third-party domains you'll connect to later |
| `preconnect` | APIs or CDNs you'll fetch from immediately |
| `preload` | Critical resources needed for current page |
@@ -28,7 +28,7 @@ export default function Document() {
</head>
<body>{/* content */}</body>
</html>
)
);
}
```
@@ -46,22 +46,25 @@ export default function Document() {
</head>
<body>{/* content */}</body>
</html>
)
);
}
```
**Note:** In Next.js, prefer the `next/script` component with `strategy` prop instead of raw script tags:
```tsx
import Script from 'next/script'
import Script from 'next/script';
export default function Page() {
return (
<>
<Script src="https://example.com/analytics.js" strategy="afterInteractive" />
<Script
src="https://example.com/analytics.js"
strategy="afterInteractive"
/>
<Script src="/scripts/utils.js" strategy="beforeInteractive" />
</>
)
);
}
```
@@ -13,17 +13,17 @@ Use `useTransition` instead of manual `useState` for loading states. This provid
```tsx
function SearchResults() {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [isLoading, setIsLoading] = useState(false)
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const handleSearch = async (value: string) => {
setIsLoading(true)
setQuery(value)
const data = await fetchResults(value)
setResults(data)
setIsLoading(false)
}
setIsLoading(true);
setQuery(value);
const data = await fetchResults(value);
setResults(data);
setIsLoading(false);
};
return (
<>
@@ -31,29 +31,29 @@ function SearchResults() {
{isLoading && <Spinner />}
<ResultsList results={results} />
</>
)
);
}
```
**Correct (useTransition with built-in pending state):**
```tsx
import { useTransition, useState } from 'react'
import { useTransition, useState } from 'react';
function SearchResults() {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [isPending, startTransition] = useTransition()
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();
const handleSearch = (value: string) => {
setQuery(value) // Update input immediately
setQuery(value); // Update input immediately
startTransition(async () => {
// Fetch and update results
const data = await fetchResults(value)
setResults(data)
})
}
const data = await fetchResults(value);
setResults(data);
});
};
return (
<>
@@ -61,7 +61,7 @@ function SearchResults() {
{isPending && <Spinner />}
<ResultsList results={results} />
</>
)
);
}
```
@@ -13,14 +13,14 @@ Don't subscribe to dynamic state (searchParams, localStorage) if you only read i
```tsx
function ShareButton({ chatId }: { chatId: string }) {
const searchParams = useSearchParams()
const searchParams = useSearchParams();
const handleShare = () => {
const ref = searchParams.get('ref')
shareChat(chatId, { ref })
}
const ref = searchParams.get('ref');
shareChat(chatId, { ref });
};
return <button onClick={handleShare}>Share</button>
return <button onClick={handleShare}>Share</button>;
}
```
@@ -29,11 +29,11 @@ function ShareButton({ chatId }: { chatId: string }) {
```tsx
function ShareButton({ chatId }: { chatId: string }) {
const handleShare = () => {
const params = new URLSearchParams(window.location.search)
const ref = params.get('ref')
shareChat(chatId, { ref })
}
const params = new URLSearchParams(window.location.search);
const ref = params.get('ref');
shareChat(chatId, { ref });
};
return <button onClick={handleShare}>Share</button>
return <button onClick={handleShare}>Share</button>;
}
```
@@ -13,16 +13,16 @@ Specify primitive dependencies instead of objects to minimize effect re-runs.
```tsx
useEffect(() => {
console.log(user.id)
}, [user])
console.log(user.id);
}, [user]);
```
**Correct (re-runs only when id changes):**
```tsx
useEffect(() => {
console.log(user.id)
}, [user.id])
console.log(user.id);
}, [user.id]);
```
**For derived state, compute outside effect:**
@@ -31,15 +31,15 @@ useEffect(() => {
// Incorrect: runs on width=767, 766, 765...
useEffect(() => {
if (width < 768) {
enableMobileMode()
enableMobileMode();
}
}, [width])
}, [width]);
// Correct: runs only on boolean transition
const isMobile = width < 768
const isMobile = width < 768;
useEffect(() => {
if (isMobile) {
enableMobileMode()
enableMobileMode();
}
}, [isMobile])
}, [isMobile]);
```
@@ -13,15 +13,15 @@ If a value can be computed from current props/state, do not store it in state or
```tsx
function Form() {
const [firstName, setFirstName] = useState('First')
const [lastName, setLastName] = useState('Last')
const [fullName, setFullName] = useState('')
const [firstName, setFirstName] = useState('First');
const [lastName, setLastName] = useState('Last');
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName)
}, [firstName, lastName])
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
return <p>{fullName}</p>
return <p>{fullName}</p>;
}
```
@@ -29,11 +29,11 @@ function Form() {
```tsx
function Form() {
const [firstName, setFirstName] = useState('First')
const [lastName, setLastName] = useState('Last')
const fullName = firstName + ' ' + lastName
const [firstName, setFirstName] = useState('First');
const [lastName, setLastName] = useState('Last');
const fullName = firstName + ' ' + lastName;
return <p>{fullName}</p>
return <p>{fullName}</p>;
}
```
@@ -13,9 +13,9 @@ Subscribe to derived boolean state instead of continuous values to reduce re-ren
```tsx
function Sidebar() {
const width = useWindowWidth() // updates continuously
const isMobile = width < 768
return <nav className={isMobile ? 'mobile' : 'desktop'} />
const width = useWindowWidth(); // updates continuously
const isMobile = width < 768;
return <nav className={isMobile ? 'mobile' : 'desktop'} />;
}
```
@@ -23,7 +23,7 @@ function Sidebar() {
```tsx
function Sidebar() {
const isMobile = useMediaQuery('(max-width: 767px)')
return <nav className={isMobile ? 'mobile' : 'desktop'} />
const isMobile = useMediaQuery('(max-width: 767px)');
return <nav className={isMobile ? 'mobile' : 'desktop'} />;
}
```
@@ -13,19 +13,22 @@ When updating state based on the current state value, use the functional update
```tsx
function TodoList() {
const [items, setItems] = useState(initialItems)
const [items, setItems] = useState(initialItems);
// Callback must depend on items, recreated on every items change
const addItems = useCallback((newItems: Item[]) => {
setItems([...items, ...newItems])
}, [items]) // ❌ items dependency causes recreations
const addItems = useCallback(
(newItems: Item[]) => {
setItems([...items, ...newItems]);
},
[items],
); // ❌ items dependency causes recreations
// Risk of stale closure if dependency is forgotten
const removeItem = useCallback((id: string) => {
setItems(items.filter(item => item.id !== id))
}, []) // ❌ Missing items dependency - will use stale items!
setItems(items.filter((item) => item.id !== id));
}, []); // ❌ Missing items dependency - will use stale items!
return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} />
return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} />;
}
```
@@ -35,19 +38,19 @@ The first callback is recreated every time `items` changes, which can cause chil
```tsx
function TodoList() {
const [items, setItems] = useState(initialItems)
const [items, setItems] = useState(initialItems);
// Stable callback, never recreated
const addItems = useCallback((newItems: Item[]) => {
setItems(curr => [...curr, ...newItems])
}, []) // ✅ No dependencies needed
setItems((curr) => [...curr, ...newItems]);
}, []); // ✅ No dependencies needed
// Always uses latest state, no stale closure risk
const removeItem = useCallback((id: string) => {
setItems(curr => curr.filter(item => item.id !== id))
}, []) // ✅ Safe and stable
setItems((curr) => curr.filter((item) => item.id !== id));
}, []); // ✅ Safe and stable
return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} />
return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} />;
}
```
@@ -14,20 +14,20 @@ Pass a function to `useState` for expensive initial values. Without the function
```tsx
function FilteredList({ items }: { items: Item[] }) {
// buildSearchIndex() runs on EVERY render, even after initialization
const [searchIndex, setSearchIndex] = useState(buildSearchIndex(items))
const [query, setQuery] = useState('')
const [searchIndex, setSearchIndex] = useState(buildSearchIndex(items));
const [query, setQuery] = useState('');
// When query changes, buildSearchIndex runs again unnecessarily
return <SearchResults index={searchIndex} query={query} />
return <SearchResults index={searchIndex} query={query} />;
}
function UserProfile() {
// JSON.parse runs on every render
const [settings, setSettings] = useState(
JSON.parse(localStorage.getItem('settings') || '{}')
)
JSON.parse(localStorage.getItem('settings') || '{}'),
);
return <SettingsForm settings={settings} onChange={setSettings} />
return <SettingsForm settings={settings} onChange={setSettings} />;
}
```
@@ -36,20 +36,20 @@ function UserProfile() {
```tsx
function FilteredList({ items }: { items: Item[] }) {
// buildSearchIndex() runs ONLY on initial render
const [searchIndex, setSearchIndex] = useState(() => buildSearchIndex(items))
const [query, setQuery] = useState('')
const [searchIndex, setSearchIndex] = useState(() => buildSearchIndex(items));
const [query, setQuery] = useState('');
return <SearchResults index={searchIndex} query={query} />
return <SearchResults index={searchIndex} query={query} />;
}
function UserProfile() {
// JSON.parse runs only on initial render
const [settings, setSettings] = useState(() => {
const stored = localStorage.getItem('settings')
return stored ? JSON.parse(stored) : {}
})
const stored = localStorage.getItem('settings');
return stored ? JSON.parse(stored) : {};
});
return <SettingsForm settings={settings} onChange={setSettings} />
return <SettingsForm settings={settings} onChange={setSettings} />;
}
```
@@ -1,10 +1,8 @@
---
title: Extract Default Non-primitive Parameter Value from Memoized Component to Constant
impact: MEDIUM
impactDescription: restores memoization by using a constant for default value
tags: rerender, memo, optimization
---
## Extract Default Non-primitive Parameter Value from Memoized Component to Constant
@@ -14,12 +14,12 @@ Extract expensive work into memoized components to enable early returns before c
```tsx
function Profile({ user, loading }: Props) {
const avatar = useMemo(() => {
const id = computeAvatarId(user)
return <Avatar id={id} />
}, [user])
const id = computeAvatarId(user);
return <Avatar id={id} />;
}, [user]);
if (loading) return <Skeleton />
return <div>{avatar}</div>
if (loading) return <Skeleton />;
return <div>{avatar}</div>;
}
```
@@ -27,17 +27,17 @@ function Profile({ user, loading }: Props) {
```tsx
const UserAvatar = memo(function UserAvatar({ user }: { user: User }) {
const id = useMemo(() => computeAvatarId(user), [user])
return <Avatar id={id} />
})
const id = useMemo(() => computeAvatarId(user), [user]);
return <Avatar id={id} />;
});
function Profile({ user, loading }: Props) {
if (loading) return <Skeleton />
if (loading) return <Skeleton />;
return (
<div>
<UserAvatar user={user} />
</div>
)
);
}
```
@@ -13,17 +13,17 @@ If a side effect is triggered by a specific user action (submit, click, drag), r
```tsx
function Form() {
const [submitted, setSubmitted] = useState(false)
const theme = useContext(ThemeContext)
const [submitted, setSubmitted] = useState(false);
const theme = useContext(ThemeContext);
useEffect(() => {
if (submitted) {
post('/api/register')
showToast('Registered', theme)
post('/api/register');
showToast('Registered', theme);
}
}, [submitted, theme])
}, [submitted, theme]);
return <button onClick={() => setSubmitted(true)}>Submit</button>
return <button onClick={() => setSubmitted(true)}>Submit</button>;
}
```
@@ -31,14 +31,14 @@ function Form() {
```tsx
function Form() {
const theme = useContext(ThemeContext)
const theme = useContext(ThemeContext);
function handleSubmit() {
post('/api/register')
showToast('Registered', theme)
post('/api/register');
showToast('Registered', theme);
}
return <button onClick={handleSubmit}>Submit</button>
return <button onClick={handleSubmit}>Submit</button>;
}
```
@@ -23,7 +23,7 @@ function UserProfile({ user, theme }) {
src={user.avatarUrl}
className={theme === 'dark' ? 'avatar-dark' : 'avatar-light'}
/>
)
);
// Defined inside to access `user` - BAD
const Stats = () => (
@@ -31,14 +31,14 @@ function UserProfile({ user, theme }) {
<span>{user.followers} followers</span>
<span>{user.posts} posts</span>
</div>
)
);
return (
<div>
<Avatar />
<Stats />
</div>
)
);
}
```
@@ -53,7 +53,7 @@ function Avatar({ src, theme }: { src: string; theme: string }) {
src={src}
className={theme === 'dark' ? 'avatar-dark' : 'avatar-light'}
/>
)
);
}
function Stats({ followers, posts }: { followers: number; posts: number }) {
@@ -62,7 +62,7 @@ function Stats({ followers, posts }: { followers: number; posts: number }) {
<span>{followers} followers</span>
<span>{posts} posts</span>
</div>
)
);
}
function UserProfile({ user, theme }) {
@@ -71,11 +71,12 @@ function UserProfile({ user, theme }) {
<Avatar src={user.avatarUrl} theme={theme} />
<Stats followers={user.followers} posts={user.posts} />
</div>
)
);
}
```
**Symptoms of this bug:**
- Input fields lose focus on every keystroke
- Animations restart unexpectedly
- `useEffect` cleanup/setup runs on every parent render
@@ -15,10 +15,10 @@ Calling `useMemo` and comparing hook dependencies may consume more resources tha
```tsx
function Header({ user, notifications }: Props) {
const isLoading = useMemo(() => {
return user.isLoading || notifications.isLoading
}, [user.isLoading, notifications.isLoading])
return user.isLoading || notifications.isLoading;
}, [user.isLoading, notifications.isLoading]);
if (isLoading) return <Skeleton />
if (isLoading) return <Skeleton />;
// return some markup
}
```
@@ -27,9 +27,9 @@ function Header({ user, notifications }: Props) {
```tsx
function Header({ user, notifications }: Props) {
const isLoading = user.isLoading || notifications.isLoading
const isLoading = user.isLoading || notifications.isLoading;
if (isLoading) return <Skeleton />
if (isLoading) return <Skeleton />;
// return some markup
}
```
@@ -13,12 +13,12 @@ When a hook contains multiple independent tasks with different dependencies, spl
```tsx
const sortedProducts = useMemo(() => {
const filtered = products.filter((p) => p.category === category)
const filtered = products.filter((p) => p.category === category);
const sorted = filtered.toSorted((a, b) =>
sortOrder === "asc" ? a.price - b.price : b.price - a.price
)
return sorted
}, [products, category, sortOrder])
sortOrder === 'asc' ? a.price - b.price : b.price - a.price,
);
return sorted;
}, [products, category, sortOrder]);
```
**Correct (filtering only recomputes when products or category change):**
@@ -26,16 +26,16 @@ const sortedProducts = useMemo(() => {
```tsx
const filteredProducts = useMemo(
() => products.filter((p) => p.category === category),
[products, category]
)
[products, category],
);
const sortedProducts = useMemo(
() =>
filteredProducts.toSorted((a, b) =>
sortOrder === "asc" ? a.price - b.price : b.price - a.price
sortOrder === 'asc' ? a.price - b.price : b.price - a.price,
),
[filteredProducts, sortOrder]
)
[filteredProducts, sortOrder],
);
```
This pattern also applies to `useEffect` when combining unrelated side effects:
@@ -44,21 +44,21 @@ This pattern also applies to `useEffect` when combining unrelated side effects:
```tsx
useEffect(() => {
analytics.trackPageView(pathname)
document.title = `${pageTitle} | My App`
}, [pathname, pageTitle])
analytics.trackPageView(pathname);
document.title = `${pageTitle} | My App`;
}, [pathname, pageTitle]);
```
**Correct (effects run independently):**
```tsx
useEffect(() => {
analytics.trackPageView(pathname)
}, [pathname])
analytics.trackPageView(pathname);
}, [pathname]);
useEffect(() => {
document.title = `${pageTitle} | My App`
}, [pageTitle])
document.title = `${pageTitle} | My App`;
}, [pageTitle]);
```
**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, it automatically optimizes dependency tracking and may handle some of these cases for you.
@@ -13,28 +13,28 @@ Mark frequent, non-urgent state updates as transitions to maintain UI responsive
```tsx
function ScrollTracker() {
const [scrollY, setScrollY] = useState(0)
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
const handler = () => setScrollY(window.scrollY)
window.addEventListener('scroll', handler, { passive: true })
return () => window.removeEventListener('scroll', handler)
}, [])
const handler = () => setScrollY(window.scrollY);
window.addEventListener('scroll', handler, { passive: true });
return () => window.removeEventListener('scroll', handler);
}, []);
}
```
**Correct (non-blocking updates):**
```tsx
import { startTransition } from 'react'
import { startTransition } from 'react';
function ScrollTracker() {
const [scrollY, setScrollY] = useState(0)
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
const handler = () => {
startTransition(() => setScrollY(window.scrollY))
}
window.addEventListener('scroll', handler, { passive: true })
return () => window.removeEventListener('scroll', handler)
}, [])
startTransition(() => setScrollY(window.scrollY));
};
window.addEventListener('scroll', handler, { passive: true });
return () => window.removeEventListener('scroll', handler);
}, []);
}
```
@@ -13,15 +13,15 @@ When user input triggers expensive computations or renders, use `useDeferredValu
```tsx
function Search({ items }: { items: Item[] }) {
const [query, setQuery] = useState('')
const filtered = items.filter(item => fuzzyMatch(item, query))
const [query, setQuery] = useState('');
const filtered = items.filter((item) => fuzzyMatch(item, query));
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} />
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ResultsList results={filtered} />
</>
)
);
}
```
@@ -29,22 +29,22 @@ function Search({ items }: { items: Item[] }) {
```tsx
function Search({ items }: { items: Item[] }) {
const [query, setQuery] = useState('')
const deferredQuery = useDeferredValue(query)
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
const filtered = useMemo(
() => items.filter(item => fuzzyMatch(item, deferredQuery)),
[items, deferredQuery]
)
const isStale = query !== deferredQuery
() => items.filter((item) => fuzzyMatch(item, deferredQuery)),
[items, deferredQuery],
);
const isStale = query !== deferredQuery;
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} />
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<div style={{ opacity: isStale ? 0.7 : 1 }}>
<ResultsList results={filtered} />
</div>
</>
)
);
}
```
@@ -13,13 +13,13 @@ When a value changes frequently and you don't want a re-render on every update (
```tsx
function Tracker() {
const [lastX, setLastX] = useState(0)
const [lastX, setLastX] = useState(0);
useEffect(() => {
const onMove = (e: MouseEvent) => setLastX(e.clientX)
window.addEventListener('mousemove', onMove)
return () => window.removeEventListener('mousemove', onMove)
}, [])
const onMove = (e: MouseEvent) => setLastX(e.clientX);
window.addEventListener('mousemove', onMove);
return () => window.removeEventListener('mousemove', onMove);
}, []);
return (
<div
@@ -32,7 +32,7 @@ function Tracker() {
background: 'black',
}}
/>
)
);
}
```
@@ -40,20 +40,20 @@ function Tracker() {
```tsx
function Tracker() {
const lastXRef = useRef(0)
const dotRef = useRef<HTMLDivElement>(null)
const lastXRef = useRef(0);
const dotRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const onMove = (e: MouseEvent) => {
lastXRef.current = e.clientX
const node = dotRef.current
lastXRef.current = e.clientX;
const node = dotRef.current;
if (node) {
node.style.transform = `translateX(${e.clientX}px)`
node.style.transform = `translateX(${e.clientX}px)`;
}
}
window.addEventListener('mousemove', onMove)
return () => window.removeEventListener('mousemove', onMove)
}, [])
};
window.addEventListener('mousemove', onMove);
return () => window.removeEventListener('mousemove', onMove);
}, []);
return (
<div
@@ -68,6 +68,6 @@ function Tracker() {
transform: 'translateX(0px)',
}}
/>
)
);
}
```
@@ -12,46 +12,47 @@ Use Next.js's `after()` to schedule work that should execute after a response is
**Incorrect (blocks response):**
```tsx
import { logUserAction } from '@/app/utils'
import { logUserAction } from '@/app/utils';
export async function POST(request: Request) {
// Perform mutation
await updateDatabase(request)
await updateDatabase(request);
// Logging blocks the response
const userAgent = request.headers.get('user-agent') || 'unknown'
await logUserAction({ userAgent })
const userAgent = request.headers.get('user-agent') || 'unknown';
await logUserAction({ userAgent });
return new Response(JSON.stringify({ status: 'success' }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
})
headers: { 'Content-Type': 'application/json' },
});
}
```
**Correct (non-blocking):**
```tsx
import { after } from 'next/server'
import { headers, cookies } from 'next/headers'
import { logUserAction } from '@/app/utils'
import { after } from 'next/server';
import { headers, cookies } from 'next/headers';
import { logUserAction } from '@/app/utils';
export async function POST(request: Request) {
// Perform mutation
await updateDatabase(request)
await updateDatabase(request);
// Log after response is sent
after(async () => {
const userAgent = (await headers()).get('user-agent') || 'unknown'
const sessionCookie = (await cookies()).get('session-id')?.value || 'anonymous'
const userAgent = (await headers()).get('user-agent') || 'unknown';
const sessionCookie =
(await cookies()).get('session-id')?.value || 'anonymous';
logUserAction({ sessionCookie, userAgent })
})
logUserAction({ sessionCookie, userAgent });
});
return new Response(JSON.stringify({ status: 'success' }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
})
headers: { 'Content-Type': 'application/json' },
});
}
```
@@ -16,68 +16,68 @@ Next.js documentation explicitly states: "Treat Server Actions with the same sec
**Incorrect (no authentication check):**
```typescript
'use server'
'use server';
export async function deleteUser(userId: string) {
// Anyone can call this! No auth check
await db.user.delete({ where: { id: userId } })
return { success: true }
await db.user.delete({ where: { id: userId } });
return { success: true };
}
```
**Correct (authentication inside the action):**
```typescript
'use server'
'use server';
import { verifySession } from '@/lib/auth'
import { unauthorized } from '@/lib/errors'
import { verifySession } from '@/lib/auth';
import { unauthorized } from '@/lib/errors';
export async function deleteUser(userId: string) {
// Always check auth inside the action
const session = await verifySession()
const session = await verifySession();
if (!session) {
throw unauthorized('Must be logged in')
throw unauthorized('Must be logged in');
}
// Check authorization too
if (session.user.role !== 'admin' && session.user.id !== userId) {
throw unauthorized('Cannot delete other users')
throw unauthorized('Cannot delete other users');
}
await db.user.delete({ where: { id: userId } })
return { success: true }
await db.user.delete({ where: { id: userId } });
return { success: true };
}
```
**With input validation:**
```typescript
'use server'
'use server';
import { verifySession } from '@/lib/auth'
import { z } from 'zod'
import { verifySession } from '@/lib/auth';
import { z } from 'zod';
const updateProfileSchema = z.object({
userId: z.string().uuid(),
name: z.string().min(1).max(100),
email: z.string().email()
})
email: z.string().email(),
});
export async function updateProfile(data: unknown) {
// Validate input first
const validated = updateProfileSchema.parse(data)
const validated = updateProfileSchema.parse(data);
// Then authenticate
const session = await verifySession()
const session = await verifySession();
if (!session) {
throw new Error('Unauthorized')
throw new Error('Unauthorized');
}
// Then authorize
if (session.user.id !== validated.userId) {
throw new Error('Can only update own profile')
throw new Error('Can only update own profile');
}
// Finally perform the mutation
@@ -85,11 +85,11 @@ export async function updateProfile(data: unknown) {
where: { id: validated.userId },
data: {
name: validated.name,
email: validated.email
}
})
email: validated.email,
},
});
return { success: true }
return { success: true };
}
```
@@ -12,20 +12,20 @@ tags: server, cache, lru, cross-request
**Implementation:**
```typescript
import { LRUCache } from 'lru-cache'
import { LRUCache } from 'lru-cache';
const cache = new LRUCache<string, any>({
max: 1000,
ttl: 5 * 60 * 1000 // 5 minutes
})
ttl: 5 * 60 * 1000, // 5 minutes
});
export async function getUser(id: string) {
const cached = cache.get(id)
if (cached) return cached
const cached = cache.get(id);
if (cached) return cached;
const user = await db.user.findUnique({ where: { id } })
cache.set(id, user)
return user
const user = await db.user.findUnique({ where: { id } });
cache.set(id, user);
return user;
}
// Request 1: DB query, result cached
@@ -12,15 +12,15 @@ Use `React.cache()` for server-side request deduplication. Authentication and da
**Usage:**
```typescript
import { cache } from 'react'
import { cache } from 'react';
export const getCurrentUser = cache(async () => {
const session = await auth()
if (!session?.user?.id) return null
const session = await auth();
if (!session?.user?.id) return null;
return await db.user.findUnique({
where: { id: session.user.id }
})
})
where: { id: session.user.id },
});
});
```
Within a single request, multiple calls to `getCurrentUser()` execute the query only once.
@@ -33,32 +33,32 @@ Within a single request, multiple calls to `getCurrentUser()` execute the query
```typescript
const getUser = cache(async (params: { uid: number }) => {
return await db.user.findUnique({ where: { id: params.uid } })
})
return await db.user.findUnique({ where: { id: params.uid } });
});
// Each call creates new object, never hits cache
getUser({ uid: 1 })
getUser({ uid: 1 }) // Cache miss, runs query again
getUser({ uid: 1 });
getUser({ uid: 1 }); // Cache miss, runs query again
```
**Correct (cache hit):**
```typescript
const getUser = cache(async (uid: number) => {
return await db.user.findUnique({ where: { id: uid } })
})
return await db.user.findUnique({ where: { id: uid } });
});
// Primitive args use value equality
getUser(1)
getUser(1) // Cache hit, returns cached result
getUser(1);
getUser(1); // Cache hit, returns cached result
```
If you must pass objects, pass the same reference:
```typescript
const params = { uid: 1 }
getUser(params) // Query runs
getUser(params) // Cache hit (same reference)
const params = { uid: 1 };
getUser(params); // Query runs
getUser(params); // Cache hit (same reference)
```
**Next.js-Specific Note:**
@@ -22,11 +22,11 @@ RSC→client serialization deduplicates by object reference, not value. Same ref
```tsx
// RSC: send once
<ClientList usernames={usernames} />
<ClientList usernames={usernames} />;
// Client: transform there
'use client'
const sorted = useMemo(() => [...usernames].sort(), [usernames])
('use client');
const sorted = useMemo(() => [...usernames].sort(), [usernames]);
```
**Nested deduplication behavior:**
@@ -97,35 +97,31 @@ export async function GET(request: Request) {
**Incorrect (reads config on every call):**
```typescript
import fs from 'node:fs/promises'
import fs from 'node:fs/promises';
export async function processRequest(data: Data) {
const config = JSON.parse(
await fs.readFile('./config.json', 'utf-8')
)
const template = await fs.readFile('./template.html', 'utf-8')
const config = JSON.parse(await fs.readFile('./config.json', 'utf-8'));
const template = await fs.readFile('./template.html', 'utf-8');
return render(template, data, config)
return render(template, data, config);
}
```
**Correct (hoists config and template to module level):**
```typescript
import fs from 'node:fs/promises'
import fs from 'node:fs/promises';
const configPromise = fs
.readFile('./config.json', 'utf-8')
.then(JSON.parse)
const templatePromise = fs.readFile('./template.html', 'utf-8')
const configPromise = fs.readFile('./config.json', 'utf-8').then(JSON.parse);
const templatePromise = fs.readFile('./template.html', 'utf-8');
export async function processRequest(data: Data) {
const [config, template] = await Promise.all([
configPromise,
templatePromise,
])
]);
return render(template, data, config)
return render(template, data, config);
}
```
@@ -14,15 +14,15 @@ Treat module scope on the server as process-wide shared memory, not request-loca
**Incorrect (request data leaks across concurrent renders):**
```tsx
let currentUser: User | null = null
let currentUser: User | null = null;
export default async function Page() {
currentUser = await auth()
return <Dashboard />
currentUser = await auth();
return <Dashboard />;
}
async function Dashboard() {
return <div>{currentUser?.name}</div>
return <div>{currentUser?.name}</div>;
}
```
@@ -32,12 +32,12 @@ If two requests overlap, request A can set `currentUser`, then request B overwri
```tsx
export default async function Page() {
const user = await auth()
return <Dashboard user={user} />
const user = await auth();
return <Dashboard user={user} />;
}
function Dashboard({ user }: { user: User | null }) {
return <div>{user?.name}</div>
return <div>{user?.name}</div>;
}
```
@@ -13,18 +13,18 @@ React Server Components execute sequentially within a tree. Restructure with com
```tsx
export default async function Page() {
const header = await fetchHeader()
const header = await fetchHeader();
return (
<div>
<div>{header}</div>
<Sidebar />
</div>
)
);
}
async function Sidebar() {
const items = await fetchSidebarItems()
return <nav>{items.map(renderItem)}</nav>
const items = await fetchSidebarItems();
return <nav>{items.map(renderItem)}</nav>;
}
```
@@ -32,13 +32,13 @@ async function Sidebar() {
```tsx
async function Header() {
const data = await fetchHeader()
return <div>{data}</div>
const data = await fetchHeader();
return <div>{data}</div>;
}
async function Sidebar() {
const items = await fetchSidebarItems()
return <nav>{items.map(renderItem)}</nav>
const items = await fetchSidebarItems();
return <nav>{items.map(renderItem)}</nav>;
}
export default function Page() {
@@ -47,7 +47,7 @@ export default function Page() {
<Header />
<Sidebar />
</div>
)
);
}
```
@@ -55,13 +55,13 @@ export default function Page() {
```tsx
async function Header() {
const data = await fetchHeader()
return <div>{data}</div>
const data = await fetchHeader();
return <div>{data}</div>;
}
async function Sidebar() {
const items = await fetchSidebarItems()
return <nav>{items.map(renderItem)}</nav>
const items = await fetchSidebarItems();
return <nav>{items.map(renderItem)}</nav>;
}
function Layout({ children }: { children: ReactNode }) {
@@ -70,7 +70,7 @@ function Layout({ children }: { children: ReactNode }) {
<Header />
{children}
</div>
)
);
}
export default function Page() {
@@ -78,6 +78,6 @@ export default function Page() {
<Layout>
<Sidebar />
</Layout>
)
);
}
```
@@ -12,13 +12,11 @@ When fetching nested data in parallel, chain dependent fetches within each item'
**Incorrect (a single slow item blocks all nested fetches):**
```tsx
const chats = await Promise.all(
chatIds.map(id => getChat(id))
)
const chats = await Promise.all(chatIds.map((id) => getChat(id)));
const chatAuthors = await Promise.all(
chats.map(chat => getUser(chat.author))
)
chats.map((chat) => getUser(chat.author)),
);
```
If one `getChat(id)` out of 100 is extremely slow, the authors of the other 99 chats can't start loading even though their data is ready.
@@ -27,8 +25,8 @@ If one `getChat(id)` out of 100 is extremely slow, the authors of the other 99 c
```tsx
const chatAuthors = await Promise.all(
chatIds.map(id => getChat(id).then(chat => getUser(chat.author)))
)
chatIds.map((id) => getChat(id).then((chat) => getUser(chat.author))),
);
```
Each item independently chains `getChat``getUser`, so a slow chat doesn't block author fetches for the others.
@@ -13,13 +13,13 @@ The React Server/Client boundary serializes all object properties into strings a
```tsx
async function Page() {
const user = await fetchUser() // 50 fields
return <Profile user={user} />
const user = await fetchUser(); // 50 fields
return <Profile user={user} />;
}
'use client'
('use client');
function Profile({ user }: { user: User }) {
return <div>{user.name}</div> // uses 1 field
return <div>{user.name}</div>; // uses 1 field
}
```
@@ -27,12 +27,12 @@ function Profile({ user }: { user: User }) {
```tsx
async function Page() {
const user = await fetchUser()
return <Profile name={user.name} />
const user = await fetchUser();
return <Profile name={user.name} />;
}
'use client'
('use client');
function Profile({ name }: { name: string }) {
return <div>{name}</div>
return <div>{name}</div>;
}
```
+1
View File
@@ -8,6 +8,7 @@
"name": "mcma-webui",
"version": "1.0.0",
"dependencies": {
"@phosphor-icons/react": "^2.1.10",
"@reduxjs/toolkit": "^2.12.0",
"modern-sk": "git+https://git.ollyhearn.ru/olly/modern-sk.git",
"react": "^19.2.6",
+1
View File
@@ -13,6 +13,7 @@
"test:watch": "rstest --watch"
},
"dependencies": {
"@phosphor-icons/react": "^2.1.10",
"@reduxjs/toolkit": "^2.12.0",
"modern-sk": "git+https://git.ollyhearn.ru/olly/modern-sk.git",
"react": "^19.2.6",
+30 -9
View File
@@ -1,4 +1,9 @@
import { fetchBaseQuery, type BaseQueryFn, type FetchArgs, type FetchBaseQueryError } from '@reduxjs/toolkit/query/react';
import {
fetchBaseQuery,
type BaseQueryFn,
type FetchArgs,
type FetchBaseQueryError,
} from '@reduxjs/toolkit/query/react';
import type { RootState } from '../store';
import { getApiBaseUrl } from '../config/runtime-config';
import { logout, setTokens } from '../store/slices/auth';
@@ -13,8 +18,11 @@ const rawBaseQuery = () =>
},
});
export const baseQueryWithReauth: BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryError> =
async (args, api, extraOptions) => {
export const baseQueryWithReauth: BaseQueryFn<
string | FetchArgs,
unknown,
FetchBaseQueryError
> = async (args, api, extraOptions) => {
let result = await rawBaseQuery()(args, api, extraOptions);
if (result.error?.status === 401) {
@@ -22,16 +30,29 @@ export const baseQueryWithReauth: BaseQueryFn<string | FetchArgs, unknown, Fetch
const refreshToken = state.auth.refreshToken;
if (refreshToken) {
const refreshResult = await rawBaseQuery()({
const refreshResult = await rawBaseQuery()(
{
url: '/auth/refresh',
method: 'POST',
body: { refreshToken },
}, api, extraOptions);
},
api,
extraOptions,
);
if (refreshResult.data) {
const { accessToken, refreshToken: newRefresh, expiresIn } =
(refreshResult.data as { accessToken: string; refreshToken: string; expiresIn: number });
api.dispatch(setTokens({ accessToken, refreshToken: newRefresh, expiresIn }));
const {
accessToken,
refreshToken: newRefresh,
expiresIn,
} = refreshResult.data as {
accessToken: string;
refreshToken: string;
expiresIn: number;
};
api.dispatch(
setTokens({ accessToken, refreshToken: newRefresh, expiresIn }),
);
result = await rawBaseQuery()(args, api, extraOptions);
} else {
api.dispatch(logout());
@@ -42,4 +63,4 @@ export const baseQueryWithReauth: BaseQueryFn<string | FetchArgs, unknown, Fetch
}
return result;
};
};
+24 -4
View File
@@ -7,12 +7,27 @@ export const adminApi = api.injectEndpoints({
query: () => '/admin/users',
providesTags: ['User'],
}),
createUser: build.mutation<User, { username: string; password: string; email?: string; role: 'admin' | 'user' }>({
createUser: build.mutation<
User,
{
username: string;
password: string;
email?: string;
role: 'admin' | 'user';
}
>({
query: (body) => ({ url: '/admin/users', method: 'POST', body }),
invalidatesTags: ['User'],
}),
updateUser: build.mutation<User, { id: string; role?: 'admin' | 'user'; email?: string }>({
query: ({ id, ...body }) => ({ url: `/admin/users/${id}`, method: 'PATCH', body }),
updateUser: build.mutation<
User,
{ id: string; role?: 'admin' | 'user'; email?: string }
>({
query: ({ id, ...body }) => ({
url: `/admin/users/${id}`,
method: 'PATCH',
body,
}),
invalidatesTags: ['User'],
}),
deleteUser: build.mutation<void, string>({
@@ -23,4 +38,9 @@ export const adminApi = api.injectEndpoints({
overrideExisting: false,
});
export const { useGetUsersQuery, useCreateUserMutation, useUpdateUserMutation, useDeleteUserMutation } = adminApi;
export const {
useGetUsersQuery,
useCreateUserMutation,
useUpdateUserMutation,
useDeleteUserMutation,
} = adminApi;
+4 -1
View File
@@ -9,7 +9,10 @@ export const authApi = api.injectEndpoints({
logout: build.mutation<void, void>({
query: () => ({ url: '/auth/logout', method: 'POST' }),
}),
refreshToken: build.mutation<{ accessToken: string; refreshToken: string; expiresIn: number }, { refreshToken: string }>({
refreshToken: build.mutation<
{ accessToken: string; refreshToken: string; expiresIn: number },
{ refreshToken: string }
>({
query: (body) => ({ url: '/auth/refresh', method: 'POST', body }),
}),
me: build.query<import('../types').User, void>({
+17 -3
View File
@@ -3,11 +3,20 @@ import type { DownloadJob } from '../types';
export const downloadsApi = api.injectEndpoints({
endpoints: (build) => ({
getDownloads: build.query<DownloadJob[], { status?: DownloadJob['status'] } | void>({
getDownloads: build.query<
DownloadJob[],
{ status?: DownloadJob['status'] } | void
>({
query: (params) => ({ url: '/downloads', params: params ?? {} }),
providesTags: ['Download'],
}),
addDownload: build.mutation<DownloadJob, { url: string; metadata?: { title?: string; artist?: string; album?: string } }>({
addDownload: build.mutation<
DownloadJob,
{
url: string;
metadata?: { title?: string; artist?: string; album?: string };
}
>({
query: (body) => ({ url: '/downloads', method: 'POST', body }),
invalidatesTags: ['Download'],
}),
@@ -23,4 +32,9 @@ export const downloadsApi = api.injectEndpoints({
overrideExisting: false,
});
export const { useGetDownloadsQuery, useAddDownloadMutation, useCancelDownloadMutation, useRetryDownloadMutation } = downloadsApi;
export const {
useGetDownloadsQuery,
useAddDownloadMutation,
useCancelDownloadMutation,
useRetryDownloadMutation,
} = downloadsApi;
+47 -9
View File
@@ -1,5 +1,11 @@
import { api } from '../index';
import type { Track, Album, Artist, PaginatedResponse, LibraryFilters } from '../types';
import type {
Track,
Album,
Artist,
PaginatedResponse,
LibraryFilters,
} from '../types';
export const libraryApi = api.injectEndpoints({
endpoints: (build) => ({
@@ -7,18 +13,32 @@ export const libraryApi = api.injectEndpoints({
query: (filters) => ({ url: '/library/tracks', params: filters ?? {} }),
providesTags: (result) =>
result
? [...result.items.map(({ id }) => ({ type: 'Track' as const, id })), 'Track']
? [
...result.items.map(({ id }) => ({ type: 'Track' as const, id })),
'Track',
]
: ['Track'],
}),
getTrack: build.query<Track, string>({
query: (id) => `/library/tracks/${id}`,
providesTags: (_r, _e, id) => [{ type: 'Track', id }],
}),
getAlbums: build.query<PaginatedResponse<Album>, { search?: string; artistId?: string; page?: number; pageSize?: number } | void>({
getAlbums: build.query<
PaginatedResponse<Album>,
{
search?: string;
artistId?: string;
page?: number;
pageSize?: number;
} | void
>({
query: (params) => ({ url: '/library/albums', params: params ?? {} }),
providesTags: (result) =>
result
? [...result.items.map(({ id }) => ({ type: 'Album' as const, id })), 'Album']
? [
...result.items.map(({ id }) => ({ type: 'Album' as const, id })),
'Album',
]
: ['Album'],
}),
getAlbum: build.query<Album, string>({
@@ -27,13 +47,25 @@ export const libraryApi = api.injectEndpoints({
}),
getAlbumTracks: build.query<Track[], string>({
query: (albumId) => `/library/albums/${albumId}/tracks`,
providesTags: (_r, _e, albumId) => [{ type: 'Album', id: albumId }, 'Track'],
providesTags: (_r, _e, albumId) => [
{ type: 'Album', id: albumId },
'Track',
],
}),
getArtists: build.query<PaginatedResponse<Artist>, { search?: string; page?: number; pageSize?: number } | void>({
getArtists: build.query<
PaginatedResponse<Artist>,
{ search?: string; page?: number; pageSize?: number } | void
>({
query: (params) => ({ url: '/library/artists', params: params ?? {} }),
providesTags: (result) =>
result
? [...result.items.map(({ id }) => ({ type: 'Artist' as const, id })), 'Artist']
? [
...result.items.map(({ id }) => ({
type: 'Artist' as const,
id,
})),
'Artist',
]
: ['Artist'],
}),
getArtist: build.query<Artist, string>({
@@ -42,9 +74,15 @@ export const libraryApi = api.injectEndpoints({
}),
getArtistAlbums: build.query<Album[], string>({
query: (artistId) => `/library/artists/${artistId}/albums`,
providesTags: (_r, _e, artistId) => [{ type: 'Artist', id: artistId }, 'Album'],
providesTags: (_r, _e, artistId) => [
{ type: 'Artist', id: artistId },
'Album',
],
}),
searchLibrary: build.query<{ tracks: Track[]; albums: Album[]; artists: Artist[] }, string>({
searchLibrary: build.query<
{ tracks: Track[]; albums: Album[]; artists: Artist[] },
string
>({
query: (q) => ({ url: '/library/search', params: { q } }),
providesTags: ['Track', 'Album', 'Artist'],
}),
+4 -1
View File
@@ -7,7 +7,10 @@ export const likesApi = api.injectEndpoints({
invalidatesTags: (_r, _e, id) => ['Like', { type: 'Track', id }],
}),
unlikeTrack: build.mutation<void, string>({
query: (trackId) => ({ url: `/likes/tracks/${trackId}`, method: 'DELETE' }),
query: (trackId) => ({
url: `/likes/tracks/${trackId}`,
method: 'DELETE',
}),
invalidatesTags: (_r, _e, id) => ['Like', { type: 'Track', id }],
}),
}),
+36 -9
View File
@@ -15,25 +15,52 @@ export const playlistsApi = api.injectEndpoints({
query: (id) => `/playlists/${id}/tracks`,
providesTags: (_r, _e, id) => [{ type: 'Playlist', id }, 'Track'],
}),
createPlaylist: build.mutation<Playlist, { name: string; description?: string; isPublic?: boolean }>({
createPlaylist: build.mutation<
Playlist,
{ name: string; description?: string; isPublic?: boolean }
>({
query: (body) => ({ url: '/playlists', method: 'POST', body }),
invalidatesTags: ['Playlist'],
}),
updatePlaylist: build.mutation<Playlist, { id: string; name?: string; description?: string; isPublic?: boolean }>({
query: ({ id, ...body }) => ({ url: `/playlists/${id}`, method: 'PATCH', body }),
updatePlaylist: build.mutation<
Playlist,
{ id: string; name?: string; description?: string; isPublic?: boolean }
>({
query: ({ id, ...body }) => ({
url: `/playlists/${id}`,
method: 'PATCH',
body,
}),
invalidatesTags: (_r, _e, { id }) => [{ type: 'Playlist', id }],
}),
deletePlaylist: build.mutation<void, string>({
query: (id) => ({ url: `/playlists/${id}`, method: 'DELETE' }),
invalidatesTags: ['Playlist'],
}),
addTrackToPlaylist: build.mutation<void, { playlistId: string; trackId: string }>({
query: ({ playlistId, trackId }) => ({ url: `/playlists/${playlistId}/tracks`, method: 'POST', body: { trackId } }),
invalidatesTags: (_r, _e, { playlistId }) => [{ type: 'Playlist', id: playlistId }],
addTrackToPlaylist: build.mutation<
void,
{ playlistId: string; trackId: string }
>({
query: ({ playlistId, trackId }) => ({
url: `/playlists/${playlistId}/tracks`,
method: 'POST',
body: { trackId },
}),
removeTrackFromPlaylist: build.mutation<void, { playlistId: string; trackId: string; position: number }>({
query: ({ playlistId, position }) => ({ url: `/playlists/${playlistId}/tracks/${position}`, method: 'DELETE' }),
invalidatesTags: (_r, _e, { playlistId }) => [{ type: 'Playlist', id: playlistId }],
invalidatesTags: (_r, _e, { playlistId }) => [
{ type: 'Playlist', id: playlistId },
],
}),
removeTrackFromPlaylist: build.mutation<
void,
{ playlistId: string; trackId: string; position: number }
>({
query: ({ playlistId, position }) => ({
url: `/playlists/${playlistId}/tracks/${position}`,
method: 'DELETE',
}),
invalidatesTags: (_r, _e, { playlistId }) => [
{ type: 'Playlist', id: playlistId },
],
}),
}),
overrideExisting: false,
+9 -2
View File
@@ -12,11 +12,18 @@ export const storageApi = api.injectEndpoints({
invalidatesTags: ['Storage', 'Track', 'Album', 'Artist'],
}),
deleteTrackFile: build.mutation<void, string>({
query: (trackId) => ({ url: `/storage/tracks/${trackId}`, method: 'DELETE' }),
query: (trackId) => ({
url: `/storage/tracks/${trackId}`,
method: 'DELETE',
}),
invalidatesTags: ['Storage', { type: 'Track', id: undefined }],
}),
}),
overrideExisting: false,
});
export const { useGetStorageStatsQuery, useScanStorageMutation, useDeleteTrackFileMutation } = storageApi;
export const {
useGetStorageStatsQuery,
useScanStorageMutation,
useDeleteTrackFileMutation,
} = storageApi;
+2 -1
View File
@@ -7,7 +7,8 @@ export function getStreamUrl(trackId: string, token: string): string {
export function getCoverUrl(artUrl: string | undefined): string | undefined {
if (!artUrl) return undefined;
if (artUrl.startsWith('http://') || artUrl.startsWith('https://')) return artUrl;
if (artUrl.startsWith('http://') || artUrl.startsWith('https://'))
return artUrl;
const base = getApiBaseUrl();
return `${base}${artUrl}`;
}
+10 -1
View File
@@ -4,6 +4,15 @@ import { baseQueryWithReauth } from './baseQuery';
export const api = createApi({
reducerPath: 'api',
baseQuery: baseQueryWithReauth,
tagTypes: ['Track', 'Album', 'Artist', 'Playlist', 'Download', 'Like', 'User', 'Storage'],
tagTypes: [
'Track',
'Album',
'Artist',
'Playlist',
'Download',
'Like',
'User',
'Storage',
],
endpoints: () => ({}),
});
+76
View File
@@ -0,0 +1,76 @@
/*
* Procedural cover-art tile: a folder-colour gloss + initials, picked
* deterministically from a seed string. Used for dense rows (player, queue)
* and as a stand-in while real album art is absent (stub mode). Mirrors the
* design reference's ArtTile.
*/
// jewel-tone palette nodding to the Ubuntu "folder colour" idea
const TILE_PALETTE: ReadonlyArray<readonly [string, string]> = [
['#bef264', '#5a7d1e'], // lime
['#e9572b', '#7a2410'], // ember
['#6db3f2', '#1d4d78'], // blue
['#b07bf2', '#48246e'], // purple
['#e6a93c', '#7a531a'], // amber
['#4fd1c5', '#155e57'], // teal
['#f26db3', '#7a2452'], // pink
];
function hashHue(str: string): number {
let h = 0;
for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) >>> 0;
return h;
}
interface Props {
seed: string;
size?: number;
radius?: number;
label?: string;
src?: string;
}
export function ArtTile({ seed, size = 40, radius, label, src }: Props) {
const r = radius ?? Math.max(4, Math.round(size * 0.16));
if (src) {
return (
<img
src={src}
alt=""
width={size}
height={size}
className="arttile"
style={{ borderRadius: r, objectFit: 'cover' }}
/>
);
}
const [a, b] = TILE_PALETTE[hashHue(seed || 'x') % TILE_PALETTE.length];
const initials = (label ?? '')
.split(/\s+/)
.slice(0, 2)
.map((w) => w[0] ?? '')
.join('')
.toUpperCase();
return (
<div
className="arttile"
style={{
width: size,
height: size,
borderRadius: r,
background: `linear-gradient(150deg, ${a}, ${b})`,
}}
>
<span className="arttile-sheen" style={{ borderRadius: r }} />
<span
className="arttile-initials"
style={{ fontSize: Math.max(9, size * 0.3) }}
>
{initials}
</span>
</div>
);
}
+26 -4
View File
@@ -7,13 +7,35 @@ interface EmptyStateProps {
action?: ReactNode;
}
export function EmptyState({ icon, title, description, action }: EmptyStateProps) {
export function EmptyState({
icon,
title,
description,
action,
}: EmptyStateProps) {
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '4rem 2rem', gap: '1rem', textAlign: 'center', color: 'var(--color-text-2)' }}>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '4rem 2rem',
gap: '1rem',
textAlign: 'center',
color: 'var(--color-text-2)',
}}
>
{icon && <div style={{ fontSize: '2.5rem', opacity: 0.5 }}>{icon}</div>}
<div>
<p style={{ margin: 0, fontWeight: 600, color: 'var(--color-text-1)' }}>{title}</p>
{description && <p style={{ margin: '0.25rem 0 0', fontSize: '0.875rem' }}>{description}</p>}
<p style={{ margin: 0, fontWeight: 600, color: 'var(--color-text-1)' }}>
{title}
</p>
{description && (
<p style={{ margin: '0.25rem 0 0', fontSize: '0.875rem' }}>
{description}
</p>
)}
</div>
{action}
</div>
+10 -2
View File
@@ -5,13 +5,21 @@ interface ErrorStateProps {
onRetry?: () => void;
}
export function ErrorState({ message = 'Something went wrong', onRetry }: ErrorStateProps) {
export function ErrorState({
message = 'Something went wrong',
onRetry,
}: ErrorStateProps) {
return (
<div style={{ padding: '2rem' }}>
<Callout variant="danger">
{message}
{onRetry && (
<Button variant="ghost" size="sm" onClick={onRetry} style={{ marginLeft: '1rem' }}>
<Button
variant="ghost"
size="sm"
onClick={onRetry}
style={{ marginLeft: '1rem' }}
>
Retry
</Button>
)}
+101
View File
@@ -0,0 +1,101 @@
/*
* Thin wrapper over @phosphor-icons/react so app code can reference icons by
* the kebab names used in the design reference (`<Icon name="vinyl-record" />`)
* instead of importing each component. modern-sk is intentionally icon-agnostic
* (its SearchField/MenuItem/Callout take an `icon` ReactNode), so the icon set
* is the app's concern.
*
* The rendered <svg> carries className "ph" and sizes to 1em, so the shell CSS
* controls size/colour via font-size + currentColor, exactly like the reference.
*/
import type { CSSProperties } from 'react';
import {
ArrowCircleDown,
ArrowsClockwise,
CheckCircle,
Cloud,
DotsSixVertical,
GearSix,
HardDrives,
Heart,
House,
MagnifyingGlass,
Pause,
Play,
Playlist,
Plus,
PushPin,
Queue,
Radio,
Repeat,
ShieldCheck,
Shuffle,
SignOut,
SkipBack,
SkipForward,
Sparkle,
SpeakerHigh,
SpeakerSimpleX,
ThumbsDown,
Trash,
UploadSimple,
VinylRecord,
WarningCircle,
X,
type IconProps,
} from '@phosphor-icons/react';
const ICONS = {
'vinyl-record': VinylRecord,
house: House,
'magnifying-glass': MagnifyingGlass,
'arrow-circle-down': ArrowCircleDown,
'upload-simple': UploadSimple,
'hard-drives': HardDrives,
'push-pin': PushPin,
playlist: Playlist,
plus: Plus,
'shield-check': ShieldCheck,
'gear-six': GearSix,
queue: Queue,
trash: Trash,
x: X,
radio: Radio,
sparkle: Sparkle,
'dots-six-vertical': DotsSixVertical,
shuffle: Shuffle,
'skip-back': SkipBack,
play: Play,
pause: Pause,
'skip-forward': SkipForward,
repeat: Repeat,
heart: Heart,
'thumbs-down': ThumbsDown,
'speaker-high': SpeakerHigh,
'speaker-x': SpeakerSimpleX,
cloud: Cloud,
'check-circle': CheckCircle,
'warning-circle': WarningCircle,
'sign-out': SignOut,
'arrows-clockwise': ArrowsClockwise,
} satisfies Record<string, React.ComponentType<IconProps>>;
export type IconName = keyof typeof ICONS;
interface Props {
name: IconName;
fill?: boolean;
style?: CSSProperties;
className?: string;
}
export function Icon({ name, fill, style, className }: Props) {
const Cmp = ICONS[name];
return (
<Cmp
weight={fill ? 'fill' : 'regular'}
className={className ? `ph ${className}` : 'ph'}
style={{ flexShrink: 0, ...style }}
/>
);
}
+12 -5
View File
@@ -5,17 +5,24 @@ import { QueuePanel } from '../player/QueuePanel';
export function AppShell() {
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden' }}>
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
<div
style={{
display: 'flex',
flexDirection: 'column',
height: '100vh',
overflow: 'hidden',
}}
>
<div className="app-body">
<Sidebar />
<main style={{ flex: 1, overflow: 'auto', display: 'flex', flexDirection: 'column' }}>
<main className="app-main">
<div className="app-screen">
<Outlet />
</div>
</main>
<QueuePanel />
</div>
<div style={{ position: 'relative', flexShrink: 0 }}>
<PersistentPlayer />
</div>
</div>
);
}
+116 -82
View File
@@ -1,113 +1,147 @@
import { Badge } from 'modern-sk';
import { NavLink, useNavigate } from 'react-router';
import { ConnectionStatus } from '../common/ConnectionStatus';
import { useAppSelector, useAppDispatch } from '../../hooks/useAppDispatch';
import { Icon, type IconName } from '../common/Icon';
import { useAppDispatch } from '../../hooks/useAppDispatch';
import { usePermissions } from '../../hooks/usePermissions';
import { useConnectionStatus } from '../../hooks/useConnectionStatus';
import { logout } from '../../store/slices/auth';
import { useGetPlaylistsQuery } from '../../api/endpoints/playlists';
import { getActiveInstance } from '../../config/instances';
const NAV_ITEMS = [
{ to: '/', label: 'Home', icon: '⌂' },
{ to: '/library', label: 'Library', icon: '♫' },
{ to: '/search', label: 'Search & Download', icon: '⊕' },
{ to: '/downloads', label: 'Downloads', icon: '↓' },
{ to: '/storage', label: 'Storage', icon: '⊞' },
{ to: '/settings', label: 'Settings', icon: '⚙' },
] as const;
interface NavDef {
to: string;
label: string;
icon: IconName;
end?: boolean;
}
const ADMIN_ITEMS = [
{ to: '/admin', label: 'Admin', icon: '🔑' },
] as const;
const MAIN_NAV: NavDef[] = [
{ to: '/', label: 'Home', icon: 'house', end: true },
{ to: '/library', label: 'Library', icon: 'vinyl-record' },
{ to: '/search', label: 'Search & download', icon: 'magnifying-glass' },
{ to: '/downloads', label: 'Downloads', icon: 'arrow-circle-down' },
{ to: '/storage', label: 'Storage', icon: 'hard-drives' },
];
const CONN_CLASS: Record<string, { cls: string; txt: string }> = {
connected: { cls: 'online', txt: 'Connected' },
connecting: { cls: 'syncing', txt: 'Connecting…' },
disconnected: { cls: 'offline', txt: 'Offline' },
error: { cls: 'error', txt: 'Unreachable' },
};
function navClass({ isActive }: { isActive: boolean }) {
return isActive ? 'nav-item active' : 'nav-item';
}
export function Sidebar() {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const { user, isAdmin } = usePermissions();
const collapsed = useAppSelector((s) => s.ui.sidebarCollapsed);
const status = useConnectionStatus();
const { data: playlists } = useGetPlaylistsQuery();
const instance = getActiveInstance();
const handleLogout = () => {
const conn = CONN_CLASS[status] ?? CONN_CLASS.connecting;
const online = status === 'connected';
const handleLogout = (e: React.MouseEvent) => {
e.stopPropagation();
dispatch(logout());
void navigate('/connect');
};
return (
<nav style={{ width: collapsed ? '3.5rem' : '14rem', flexShrink: 0, borderRight: '1px solid var(--color-border)', display: 'flex', flexDirection: 'column', padding: '0.75rem 0', background: 'var(--color-surface-1)', transition: 'width 0.2s', overflow: 'hidden' }}>
<div style={{ padding: collapsed ? '0 0.5rem' : '0 0.75rem', marginBottom: '1.5rem' }}>
<span style={{ fontWeight: 700, fontSize: '1.125rem', color: 'var(--color-accent)', whiteSpace: 'nowrap' }}>
{collapsed ? '♫' : '♫ MCMA'}
</span>
<aside className="sidebar">
<div className="sb-scroll">
<div className="sb-brand">
<Icon name="vinyl-record" fill />
<span>{instance?.name ?? 'MCMA'}</span>
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '2px' }}>
{NAV_ITEMS.map(({ to, label, icon }) => (
<NavLink
key={to}
to={to}
end={to === '/'}
style={({ isActive }) => ({
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
padding: collapsed ? '0.625rem 0.75rem' : '0.625rem 1rem',
borderRadius: 6,
margin: '0 0.375rem',
color: isActive ? 'var(--color-accent)' : 'var(--color-text-2)',
background: isActive ? 'var(--color-surface-2)' : undefined,
fontWeight: isActive ? 600 : 400,
textDecoration: 'none',
fontSize: '0.875rem',
whiteSpace: 'nowrap',
transition: 'background 0.1s, color 0.1s',
})}
>
<span style={{ flexShrink: 0, fontSize: '1rem' }}>{icon}</span>
{!collapsed && label}
<div className="sb-sec">
{MAIN_NAV.map(({ to, label, icon, end }) => (
<NavLink key={to} to={to} end={end} className={navClass}>
<Icon name={icon} />
<span>{label}</span>
</NavLink>
))}
</div>
{isAdmin && (
<>
<div style={{ height: 1, background: 'var(--color-border)', margin: '0.5rem 0.75rem' }} />
{ADMIN_ITEMS.map(({ to, label, icon }) => (
<div className="sb-sec">
<span className="msk-label">Playlists</span>
{(playlists?.items ?? []).map((pl) => (
<NavLink
key={to}
to={to}
style={({ isActive }) => ({
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
padding: collapsed ? '0.625rem 0.75rem' : '0.625rem 1rem',
borderRadius: 6,
margin: '0 0.375rem',
color: isActive ? 'var(--color-accent)' : 'var(--color-text-2)',
background: isActive ? 'var(--color-surface-2)' : undefined,
fontWeight: isActive ? 600 : 400,
textDecoration: 'none',
fontSize: '0.875rem',
whiteSpace: 'nowrap',
})}
key={pl.id}
to={`/library/playlists/${pl.id}`}
className={navClass}
>
<span style={{ flexShrink: 0 }}>{icon}</span>
{!collapsed && label}
<Icon name="playlist" />
<span className="pl-name">{pl.name}</span>
</NavLink>
))}
</>
)}
</div>
<div style={{ padding: collapsed ? '0 0.5rem' : '0 0.75rem', display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{!collapsed && <ConnectionStatus />}
{!collapsed && user && (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0.5rem 0.25rem' }}>
<div>
<div style={{ fontSize: '0.8125rem', fontWeight: 600, color: 'var(--color-text-1)' }}>{user.username}</div>
<Badge variant={user.role === 'admin' ? 'lime' : 'neutral'} style={{ marginTop: 2 }}>{user.role}</Badge>
</div>
<button onClick={handleLogout} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--color-text-3)', fontSize: '0.75rem', padding: '0.25rem' }}>
Sign out
<button
type="button"
className="pl-item"
onClick={() => void navigate('/library')}
>
<Icon name="plus" />
<span className="pl-name">New playlist</span>
</button>
</div>
{isAdmin ? (
<div className="sb-sec">
<span className="msk-label">Administration</span>
<NavLink to="/admin" className={navClass}>
<Icon name="shield-check" />
<span>Admin</span>
</NavLink>
<NavLink to="/settings" className={navClass}>
<Icon name="gear-six" />
<span>Settings</span>
</NavLink>
</div>
) : (
<div className="sb-sec">
<NavLink to="/settings" className={navClass}>
<Icon name="gear-six" />
<span>Settings</span>
</NavLink>
</div>
)}
</div>
</nav>
<div className="sb-foot">
<button
type="button"
className={`conn ${conn.cls}`}
onClick={() => void navigate('/connect')}
title="Connection — manage instances"
>
<span className="led" />
{conn.txt}
</button>
{user && (
<button
type="button"
className="user-chip"
onClick={() => void navigate('/settings')}
>
<div className="user-av">
{user.username.charAt(0).toUpperCase()}
</div>
<div className="user-meta">
<div className="nm">{user.username}</div>
<div className="rl">
{user.role} · {online ? 'online' : 'offline'}
</div>
</div>
<span className="uc-action" onClick={handleLogout} title="Sign out">
<Icon name="sign-out" />
</span>
</button>
)}
</div>
</aside>
);
}
+111 -54
View File
@@ -1,6 +1,17 @@
import { IconButton, Slider, Tooltip } from 'modern-sk';
import { Slider } from 'modern-sk';
import { Icon } from '../common/Icon';
import { ArtTile } from '../common/ArtTile';
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
import { pause, resume, toggleMute, setVolume, toggleNowPlaying, toggleQueue } from '../../store/slices/player';
import {
pause,
resume,
toggleMute,
setVolume,
toggleShuffle,
setRepeat,
toggleNowPlaying,
toggleQueue,
} from '../../store/slices/player';
import { useAudioPlayer } from '../../hooks/useAudioPlayer';
import { formatDuration } from '../../lib/format';
import { getCoverUrl } from '../../api/endpoints/streaming';
@@ -13,94 +24,140 @@ export function PersistentPlayer() {
const currentEntry = queue.entries[queue.currentIndex];
if (!currentEntry && !player.currentTrackId) {
return (
<div style={{ height: '4rem', borderTop: '1px solid var(--color-border)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '0 1.5rem', color: 'var(--color-text-3)', fontSize: '0.875rem' }}>
Nothing playing
</div>
);
return <div className="player empty">Nothing playing</div>;
}
const artUrl = currentEntry?.albumArtUrl ? getCoverUrl(currentEntry.albumArtUrl) : undefined;
const progressPercent = player.duration > 0 ? (player.position / player.duration) * 100 : 0;
const artUrl = getCoverUrl(currentEntry?.albumArtUrl);
const seedLabel = currentEntry?.albumTitle ?? currentEntry?.title ?? '';
// Streaming is the web default; local playback is a mobile-client concern.
const onStream = true;
return (
<div style={{ height: '4rem', borderTop: '1px solid var(--color-border)', display: 'grid', gridTemplateColumns: '1fr auto 1fr', alignItems: 'center', padding: '0 1rem', gap: '1rem', background: 'var(--color-surface-1)' }}>
{/* track info */}
<div className="player">
{/* now-playing identity */}
<div
style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', minWidth: 0, cursor: 'pointer' }}
className="pl-now"
onClick={() => dispatch(toggleNowPlaying())}
style={{ cursor: 'pointer' }}
>
{artUrl ? (
<img src={artUrl} alt="" width={40} height={40} style={{ borderRadius: 4, objectFit: 'cover', flexShrink: 0 }} />
) : (
<div style={{ width: 40, height: 40, borderRadius: 4, background: 'var(--color-surface-3)', flexShrink: 0 }} />
)}
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: '0.875rem' }}>
{currentEntry?.title ?? '—'}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{currentEntry?.artistName ?? ''}
<ArtTile seed={seedLabel} size={54} label={seedLabel} src={artUrl} />
<div className="pl-now-tt">
<div className="t">{currentEntry?.title ?? '—'}</div>
<div className="a">{currentEntry?.artistName ?? ''}</div>
<div
className="pl-srcbadge"
style={{ color: onStream ? 'var(--fg-3)' : 'var(--lime)' }}
>
<Icon name={onStream ? 'cloud' : 'check-circle'} fill={!onStream} />
{onStream ? 'Streaming · 320 kbps' : 'Local · FLAC'}
</div>
</div>
</div>
{/* controls + scrubber */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.25rem', minWidth: '20rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<IconButton variant="ghost" size="sm" onClick={playPrev} aria-label="Previous"></IconButton>
<IconButton
variant="primary"
onClick={() => player.isPlaying ? dispatch(pause()) : dispatch(resume())}
aria-label={player.isPlaying ? 'Pause' : 'Play'}
{/* transport + scrubber */}
<div className="pl-center">
<div className="pl-transport">
<button
type="button"
className={`pl-tbtn${player.shuffle ? ' on' : ''}`}
onClick={() => dispatch(toggleShuffle())}
title="Shuffle"
>
{player.isPlaying ? '⏸' : '▶'}
</IconButton>
<IconButton variant="ghost" size="sm" onClick={playNext} aria-label="Next"></IconButton>
<Icon name="shuffle" />
</button>
<button
type="button"
className="pl-tbtn"
onClick={playPrev}
title="Previous"
>
<Icon name="skip-back" fill />
</button>
<button
type="button"
className="pl-play"
onClick={() =>
player.isPlaying ? dispatch(pause()) : dispatch(resume())
}
title={player.isPlaying ? 'Pause' : 'Play'}
>
<Icon name={player.isPlaying ? 'pause' : 'play'} fill />
</button>
<button
type="button"
className="pl-tbtn"
onClick={playNext}
title="Next"
>
<Icon name="skip-forward" fill />
</button>
<button
type="button"
className={`pl-tbtn${player.repeat !== 'none' ? ' on' : ''}`}
onClick={() =>
dispatch(
setRepeat(
player.repeat === 'none'
? 'all'
: player.repeat === 'all'
? 'one'
: 'none',
),
)
}
title={`Repeat: ${player.repeat}`}
>
<Icon name="repeat" />
</button>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', width: '100%' }}>
<span style={{ fontSize: '0.7rem', color: 'var(--color-text-3)', minWidth: '2.5rem', textAlign: 'right' }}>
<div className="pl-seek">
<span className="pl-time">
{formatDuration(player.position * 1000)}
</span>
<Slider
className="pl-seek-slider"
min={0}
max={player.duration || 1}
step={1}
value={[player.position]}
onValueChange={([v]) => seek(v)}
style={{ flex: 1 }}
aria-label="Seek"
/>
<span style={{ fontSize: '0.7rem', color: 'var(--color-text-3)', minWidth: '2.5rem' }}>
<span className="pl-time">
{formatDuration(player.duration * 1000)}
</span>
</div>
</div>
{/* volume + queue */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '0.5rem' }}>
<Tooltip content={player.muted ? 'Unmute' : 'Mute'}>
<IconButton variant="ghost" size="sm" onClick={() => dispatch(toggleMute())} aria-label="Toggle mute">
{player.muted ? '🔇' : '🔊'}
</IconButton>
</Tooltip>
<div className="pl-right">
<button
type="button"
className="pl-tbtn"
onClick={() => dispatch(toggleMute())}
title={player.muted ? 'Unmute' : 'Mute'}
>
<Icon name={player.muted ? 'speaker-x' : 'speaker-high'} />
</button>
<div className="pl-vol">
<Slider
className="pl-vol-slider"
min={0}
max={1}
step={0.01}
value={[player.muted ? 0 : player.volume]}
onValueChange={([v]) => dispatch(setVolume(v))}
style={{ width: '6rem' }}
aria-label="Volume"
/>
<Tooltip content="Queue">
<IconButton variant={player.isQueueOpen ? 'primary' : 'ghost'} size="sm" onClick={() => dispatch(toggleQueue())} aria-label="Toggle queue">
</IconButton>
</Tooltip>
</div>
{/* progress bar at bottom */}
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: 2, background: 'var(--color-surface-3)' }}>
<div style={{ width: `${progressPercent}%`, height: '100%', background: 'var(--color-accent)', transition: 'width 0.5s linear' }} />
<button
type="button"
className={`iconbtn sm${player.isQueueOpen ? ' on' : ''}`}
onClick={() => dispatch(toggleQueue())}
title="Play queue"
>
<Icon name="queue" />
</button>
</div>
</div>
);
+144 -35
View File
@@ -1,60 +1,169 @@
import { ScrollArea, IconButton, Badge } from 'modern-sk';
import { Slider, Badge } from 'modern-sk';
import { Icon } from '../common/Icon';
import { ArtTile } from '../common/ArtTile';
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
import { goToIndex, removeFromQueue, clearQueue } from '../../store/slices/queue';
import {
goToIndex,
removeFromQueue,
clearQueue,
} from '../../store/slices/queue';
import { toggleQueue } from '../../store/slices/player';
import { formatDuration } from '../../lib/format';
export function QueuePanel() {
const dispatch = useAppDispatch();
const queue = useAppSelector((s) => s.queue);
const isOpen = useAppSelector((s) => s.player.isQueueOpen);
if (!isOpen) return null;
const now =
queue.currentIndex >= 0 ? queue.entries[queue.currentIndex] : undefined;
const upNext = queue.entries
.map((entry, index) => ({ entry, index }))
.filter(({ index }) => index > queue.currentIndex);
const isRadio = queue.source === 'radio';
const sourceLabel = queue.sourceName ?? queue.source;
return (
<div style={{ width: '20rem', borderLeft: '1px solid var(--color-border)', display: 'flex', flexDirection: 'column', background: 'var(--color-surface-1)', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0.75rem 1rem', borderBottom: '1px solid var(--color-border)' }}>
<span style={{ fontWeight: 600, fontSize: '0.875rem' }}>Queue</span>
<div style={{ display: 'flex', gap: '0.25rem' }}>
{queue.sourceName && <Badge variant="neutral">{queue.sourceName}</Badge>}
<IconButton variant="ghost" size="sm" onClick={() => dispatch(clearQueue())} aria-label="Clear queue"></IconButton>
<IconButton variant="ghost" size="sm" onClick={() => dispatch(toggleQueue())} aria-label="Close"></IconButton>
<aside className={`qd${isOpen ? '' : ' closed'}`} aria-hidden={!isOpen}>
<div className="qd-inner">
<div className="qd-head">
<div className="row">
<h3>Play queue</h3>
<div style={{ flex: 1 }} />
<button
type="button"
className="iconbtn sm"
onClick={() => dispatch(clearQueue())}
title="Clear queue"
>
<Icon name="trash" />
</button>
<button
type="button"
className="iconbtn sm"
onClick={() => dispatch(toggleQueue())}
title="Close"
>
<Icon name="x" />
</button>
</div>
</div>
<ScrollArea style={{ flex: 1 }}>
{queue.entries.length === 0 ? (
<p style={{ padding: '2rem', textAlign: 'center', color: 'var(--color-text-3)', fontSize: '0.875rem' }}>Queue is empty</p>
<div className="qd-src">
<Icon
name={isRadio ? 'radio' : 'playlist'}
style={{ color: isRadio ? 'var(--lime)' : 'var(--fg-3)' }}
/>
{isRadio ? (
<span style={{ color: 'var(--lime)' }}>
Radio · {sourceLabel}
</span>
) : (
queue.entries.map((entry, i) => (
<div
key={`${entry.trackId}-${i}`}
onDoubleClick={() => dispatch(goToIndex(i))}
<span>From {sourceLabel}</span>
)}
</div>
</div>
<div className="qd-scroll">
{now ? (
<>
<span
className="msk-label"
style={{ display: 'block', marginBottom: 8 }}
>
Now playing
</span>
<div className="qd-now">
<ArtTile
seed={now.albumTitle}
size={44}
label={now.albumTitle}
/>
<div className="qt">
<div className="t">{now.title}</div>
<div className="r">{now.artistName}</div>
</div>
<Icon name="cloud" style={{ color: 'var(--fg-3)' }} />
</div>
{isRadio && (
<div className="qd-radio">
<div className="row">
<Icon name="radio" />
<span
style={{
display: 'grid',
gridTemplateColumns: '1fr auto',
padding: '0.5rem 1rem',
gap: '0.5rem',
alignItems: 'center',
background: i === queue.currentIndex ? 'var(--color-surface-2)' : undefined,
cursor: 'default',
fontSize: 13,
fontWeight: 600,
color: 'var(--fg-1)',
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: '0.8125rem', fontWeight: i === queue.currentIndex ? 600 : 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: i === queue.currentIndex ? 'var(--color-accent)' : 'var(--color-text-1)' }}>
{entry.title}
Radio active
</span>
<div style={{ flex: 1 }} />
<Badge variant="neutral"> mixing</Badge>
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-3)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{entry.artistName}
{/* exploration balance — stub under the future ML contract */}
<div className="expl">
<span className="lab">Familiar</span>
<Slider
className="expl-slider"
min={0}
max={100}
step={1}
defaultValue={[42]}
aria-label="Exploration"
/>
<span className="lab">New</span>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', flexShrink: 0 }}>
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-3)' }}>{formatDuration(entry.durationMs)}</span>
<IconButton variant="ghost" size="sm" onClick={() => dispatch(removeFromQueue(i))} aria-label="Remove from queue"></IconButton>
)}
<span
className="msk-label"
style={{ display: 'block', margin: '4px 0 8px' }}
>
Next up
</span>
{upNext.length === 0 ? (
<div className="qd-empty">Nothing queued next</div>
) : (
upNext.map(({ entry, index }) => (
<div
key={`${entry.trackId}-${index}`}
className="qrow"
onDoubleClick={() => dispatch(goToIndex(index))}
title="Double-click to play"
>
<span className="grip">
<Icon name="dots-six-vertical" />
</span>
<ArtTile
seed={entry.albumTitle}
size={36}
label={entry.albumTitle}
/>
<div className="qt">
<div className="t">{entry.title}</div>
<div className="r">{entry.artistName}</div>
</div>
<button
type="button"
className="iconbtn sm"
onClick={() => dispatch(removeFromQueue(index))}
title="Remove from queue"
>
<Icon name="x" />
</button>
</div>
))
)}
</ScrollArea>
{isRadio && (
<div className="qd-loadmore">Loading more from radio</div>
)}
</>
) : (
<div className="qd-empty">Queue is empty</div>
)}
</div>
</div>
</aside>
);
}
+26 -5
View File
@@ -5,18 +5,39 @@ interface Props {
availability: TrackAvailability;
}
const CONFIG: Record<TrackAvailability, { label: string; variant: 'lime' | 'ember' | 'neutral' | 'outline'; tooltip: string }> = {
server: { label: 'On server', variant: 'lime', tooltip: 'File available on server' },
downloading: { label: 'Downloading', variant: 'neutral', tooltip: 'Currently downloading' },
const CONFIG: Record<
TrackAvailability,
{
label: string;
variant: 'lime' | 'ember' | 'neutral' | 'outline';
tooltip: string;
}
> = {
server: {
label: 'On server',
variant: 'lime',
tooltip: 'File available on server',
},
downloading: {
label: 'Downloading',
variant: 'neutral',
tooltip: 'Currently downloading',
},
error: { label: 'Error', variant: 'ember', tooltip: 'Download failed' },
missing: { label: 'Missing', variant: 'outline', tooltip: 'File not found on server' },
missing: {
label: 'Missing',
variant: 'outline',
tooltip: 'File not found on server',
},
};
export function AvailabilityBadge({ availability }: Props) {
const cfg = CONFIG[availability];
return (
<Tooltip content={cfg.tooltip}>
<Badge variant={cfg.variant} dot>{cfg.label}</Badge>
<Badge variant={cfg.variant} dot>
{cfg.label}
</Badge>
</Tooltip>
);
}
+55 -10
View File
@@ -1,4 +1,11 @@
import { Menu, MenuTrigger, MenuContent, MenuItem, MenuSeparator, IconButton } from 'modern-sk';
import {
Menu,
MenuTrigger,
MenuContent,
MenuItem,
MenuSeparator,
IconButton,
} from 'modern-sk';
import { useAppDispatch } from '../../hooks/useAppDispatch';
import { addToQueue, addNextInQueue } from '../../store/slices/queue';
import { play } from '../../store/slices/player';
@@ -12,7 +19,13 @@ interface Props {
onDownload?: (track: Track) => void;
}
export function TrackContextMenu({ track, onAddToPlaylist, onEditMetadata, onDelete, onDownload }: Props) {
export function TrackContextMenu({
track,
onAddToPlaylist,
onEditMetadata,
onDelete,
onDownload,
}: Props) {
const dispatch = useAppDispatch();
const entry = {
@@ -27,18 +40,50 @@ export function TrackContextMenu({ track, onAddToPlaylist, onEditMetadata, onDel
return (
<Menu>
<MenuTrigger asChild>
<IconButton variant="ghost" size="sm" aria-label="Track options"></IconButton>
<IconButton variant="ghost" size="sm" aria-label="Track options">
</IconButton>
</MenuTrigger>
<MenuContent>
<MenuItem onSelect={() => { dispatch(play(track.id)); }}>Play now</MenuItem>
<MenuItem onSelect={() => { dispatch(addNextInQueue(entry)); }}>Play next</MenuItem>
<MenuItem onSelect={() => { dispatch(addToQueue(entry)); }}>Add to queue</MenuItem>
<MenuItem
onSelect={() => {
dispatch(play(track.id));
}}
>
Play now
</MenuItem>
<MenuItem
onSelect={() => {
dispatch(addNextInQueue(entry));
}}
>
Play next
</MenuItem>
<MenuItem
onSelect={() => {
dispatch(addToQueue(entry));
}}
>
Add to queue
</MenuItem>
<MenuSeparator />
{onAddToPlaylist && <MenuItem onSelect={() => onAddToPlaylist(track)}>Add to playlist</MenuItem>}
{onAddToPlaylist && (
<MenuItem onSelect={() => onAddToPlaylist(track)}>
Add to playlist
</MenuItem>
)}
<MenuSeparator />
{onEditMetadata && <MenuItem onSelect={() => onEditMetadata(track)}>Edit metadata</MenuItem>}
{onDownload && <MenuItem onSelect={() => onDownload(track)}>Download</MenuItem>}
{onDelete && <MenuItem onSelect={() => onDelete(track)}>Delete</MenuItem>}
{onEditMetadata && (
<MenuItem onSelect={() => onEditMetadata(track)}>
Edit metadata
</MenuItem>
)}
{onDownload && (
<MenuItem onSelect={() => onDownload(track)}>Download</MenuItem>
)}
{onDelete && (
<MenuItem onSelect={() => onDelete(track)}>Delete</MenuItem>
)}
</MenuContent>
</Menu>
);
+67 -10
View File
@@ -16,7 +16,14 @@ interface Props {
onDelete?: (track: Track) => void;
}
export function TrackRow({ track, index, showAlbum = false, onAddToPlaylist, onEditMetadata, onDelete }: Props) {
export function TrackRow({
track,
index,
showAlbum = false,
onAddToPlaylist,
onEditMetadata,
onDelete,
}: Props) {
const dispatch = useAppDispatch();
const currentTrackId = useAppSelector((s) => s.player.currentTrackId);
const isPlaying = useAppSelector((s) => s.player.isPlaying);
@@ -27,27 +34,77 @@ export function TrackRow({ track, index, showAlbum = false, onAddToPlaylist, onE
<Row
selected={isActive}
onDoubleClick={() => dispatch(play(track.id))}
style={{ display: 'grid', gridTemplateColumns: '2rem 2.5rem 1fr auto auto', gap: '0.75rem', alignItems: 'center', padding: '0.375rem 0.75rem', cursor: 'default' }}
style={{
display: 'grid',
gridTemplateColumns: '2rem 2.5rem 1fr auto auto',
gap: '0.75rem',
alignItems: 'center',
padding: '0.375rem 0.75rem',
cursor: 'default',
}}
>
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-3)', textAlign: 'right' }}>
{isActive && isPlaying ? '▶' : (index !== undefined ? index + 1 : '')}
<span
style={{
fontSize: '0.75rem',
color: 'var(--color-text-3)',
textAlign: 'right',
}}
>
{isActive && isPlaying ? '▶' : index !== undefined ? index + 1 : ''}
</span>
{artUrl ? (
<img src={artUrl} alt="" width={36} height={36} style={{ borderRadius: 4, objectFit: 'cover' }} />
<img
src={artUrl}
alt=""
width={36}
height={36}
style={{ borderRadius: 4, objectFit: 'cover' }}
/>
) : (
<div style={{ width: 36, height: 36, borderRadius: 4, background: 'var(--color-surface-3)' }} />
<div
style={{
width: 36,
height: 36,
borderRadius: 4,
background: 'var(--color-surface-3)',
}}
/>
)}
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: isActive ? 600 : 400, color: isActive ? 'var(--color-accent)' : 'var(--color-text-1)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<div
style={{
fontWeight: isActive ? 600 : 400,
color: isActive ? 'var(--color-accent)' : 'var(--color-text-1)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{track.title}
</div>
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{track.artistName}{showAlbum && ` · ${track.albumTitle}`}
<div
style={{
fontSize: '0.8125rem',
color: 'var(--color-text-2)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{track.artistName}
{showAlbum && ` · ${track.albumTitle}`}
</div>
</div>
<AvailabilityBadge availability={track.availability} />
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)', minWidth: '3rem', textAlign: 'right' }}>
<span
style={{
fontSize: '0.8125rem',
color: 'var(--color-text-3)',
minWidth: '3rem',
textAlign: 'right',
}}
>
{formatDuration(track.durationMs)}
</span>
<TrackContextMenu
+2 -1
View File
@@ -1 +1,2 @@
export const DEFAULT_API_BASE_URL = import.meta.env.PUBLIC_API_BASE_URL ?? '/api/v1';
export const DEFAULT_API_BASE_URL =
import.meta.env.PUBLIC_API_BASE_URL ?? '/api/v1';
+186
View File
@@ -0,0 +1,186 @@
/*
* Multi-instance backend registry + per-backend scoped storage.
*
* The web UI is backend-agnostic: it can connect to any server speaking the
* `/api/v1` contract. Everything we persist (auth tokens, cached prefs, …) is
* therefore bound to the *instance it came from* — never global. Each saved
* backend gets a stable `id` derived from its base URL, and every persisted key
* lives under the `mcma:<id>:` namespace so switching backends can never mix
* one server's session/cache into another's.
*
* Layout in localStorage:
* mcma:instances -> Instance[] (the saved-backends registry)
* mcma:activeInstance -> <id> (which one is current)
* mcma:<id>:auth -> persisted auth slice (per-backend)
* mcma:<id>:<key> -> any other scoped value
*/
export interface Instance {
id: string;
baseUrl: string;
name: string;
lastUsedAt: number;
}
const REGISTRY_KEY = 'mcma:instances';
const ACTIVE_KEY = 'mcma:activeInstance';
// pre-multi-instance keys, migrated once on first load
const LEGACY_URL_KEY = 'mcma_api_base_url';
const LEGACY_AUTH_KEY = 'mcma_auth';
function normalizeUrl(url: string): string {
return url.trim().replace(/\/+$/, '');
}
/** Stable, readable id from a base URL — also serves as the storage namespace. */
export function instanceIdFromUrl(url: string): string {
const stripped = normalizeUrl(url)
.toLowerCase()
.replace(/^https?:\/\//, '');
const slug = stripped.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
return slug || 'default';
}
function defaultName(baseUrl: string): string {
try {
const u = new URL(
/^https?:\/\//.test(baseUrl) ? baseUrl : `http://${baseUrl}`,
);
return u.host || baseUrl;
} catch {
return baseUrl;
}
}
function readRegistry(): Instance[] {
try {
const raw = localStorage.getItem(REGISTRY_KEY);
return raw ? (JSON.parse(raw) as Instance[]) : [];
} catch {
return [];
}
}
function writeRegistry(list: Instance[]): void {
try {
localStorage.setItem(REGISTRY_KEY, JSON.stringify(list));
} catch {
/* storage unavailable */
}
}
/** Saved backends, most-recently-used first. */
export function listInstances(): Instance[] {
return readRegistry().sort((a, b) => b.lastUsedAt - a.lastUsedAt);
}
/** Add (or refresh) a backend in the registry and return its record. */
export function upsertInstance(url: string, name?: string): Instance {
const baseUrl = normalizeUrl(url);
const id = instanceIdFromUrl(baseUrl);
const list = readRegistry();
const existing = list.find((i) => i.id === id);
const inst: Instance = {
id,
baseUrl,
name: name ?? existing?.name ?? defaultName(baseUrl),
lastUsedAt: Date.now(),
};
writeRegistry(
existing ? list.map((i) => (i.id === id ? inst : i)) : [...list, inst],
);
return inst;
}
/** Remove a backend and wipe every scoped key it owns. */
export function removeInstance(id: string): void {
writeRegistry(readRegistry().filter((i) => i.id !== id));
clearScope(id);
if (getActiveInstanceId() === id) {
const next = listInstances()[0];
if (next) setActiveInstanceId(next.id);
else localStorage.removeItem(ACTIVE_KEY);
}
}
export function getActiveInstanceId(): string | null {
return localStorage.getItem(ACTIVE_KEY);
}
export function setActiveInstanceId(id: string): void {
localStorage.setItem(ACTIVE_KEY, id);
}
export function getActiveInstance(): Instance | null {
const id = getActiveInstanceId();
if (!id) return null;
return readRegistry().find((i) => i.id === id) ?? null;
}
/** Build the namespaced localStorage key for a value scoped to a backend. */
export function scopedKey(
key: string,
instanceId: string | null = getActiveInstanceId(),
): string {
return `mcma:${instanceId ?? 'none'}:${key}`;
}
/**
* Per-backend key/value store. Reads/writes are no-ops when no instance is
* active, so callers never have to special-case the unconnected state.
*/
export const instanceStorage = {
get(key: string): string | null {
const id = getActiveInstanceId();
return id ? localStorage.getItem(scopedKey(key, id)) : null;
},
set(key: string, value: string): void {
const id = getActiveInstanceId();
if (!id) return;
try {
localStorage.setItem(scopedKey(key, id), value);
} catch {
/* storage unavailable */
}
},
remove(key: string): void {
const id = getActiveInstanceId();
if (id) localStorage.removeItem(scopedKey(key, id));
},
};
function clearScope(id: string): void {
const prefix = `mcma:${id}:`;
const toRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (k && k.startsWith(prefix)) toRemove.push(k);
}
toRemove.forEach((k) => localStorage.removeItem(k));
}
/** One-time migration of the old single-backend keys into the namespaced model. */
function migrateLegacy(): void {
try {
const legacyUrl = localStorage.getItem(LEGACY_URL_KEY);
if (!legacyUrl || readRegistry().length > 0) return;
const inst = upsertInstance(legacyUrl);
const legacyAuth = localStorage.getItem(LEGACY_AUTH_KEY);
if (legacyAuth)
localStorage.setItem(scopedKey('auth', inst.id), legacyAuth);
setActiveInstanceId(inst.id);
localStorage.removeItem(LEGACY_URL_KEY);
localStorage.removeItem(LEGACY_AUTH_KEY);
} catch {
/* best-effort */
}
}
// Fold any pre-multi-instance keys into the namespaced model on first load.
// We deliberately do NOT seed an instance from the env default: before the
// user connects there is no active instance, getApiBaseUrl() falls back to the
// env default, and the connect flow registers the real backend.
if (typeof localStorage !== 'undefined') {
migrateLegacy();
}
+15 -8
View File
@@ -1,15 +1,22 @@
import { DEFAULT_API_BASE_URL } from './env';
import {
getActiveInstance,
upsertInstance,
setActiveInstanceId,
} from './instances';
const STORAGE_KEY = 'mcma_api_base_url';
/**
* Base-URL resolution. The active backend (chosen via ConnectPage) wins; if
* none is active we fall back to the env default. See `instances.ts` for the
* per-backend registry that backs this — every persisted value is namespaced
* to the instance it came from.
*/
export function getApiBaseUrl(): string {
return localStorage.getItem(STORAGE_KEY) ?? DEFAULT_API_BASE_URL;
return getActiveInstance()?.baseUrl ?? DEFAULT_API_BASE_URL;
}
/** Register a backend and make it the active one (used by the connect flow). */
export function setApiBaseUrl(url: string): void {
localStorage.setItem(STORAGE_KEY, url);
}
export function clearApiBaseUrl(): void {
localStorage.removeItem(STORAGE_KEY);
const inst = upsertInstance(url);
setActiveInstanceId(inst.id);
}
+7 -1
View File
@@ -1,4 +1,10 @@
import { Window } from 'modern-sk';
export function AdminPage() {
return <div style={{ padding: '1.5rem' }}><Window title="Admin"><p style={{ color: 'var(--color-text-2)' }}>Coming soon</p></Window></div>;
return (
<div style={{ padding: '1.5rem' }}>
<Window title="Admin">
<p style={{ color: 'var(--color-text-2)' }}>Coming soon</p>
</Window>
</div>
);
}
+117 -18
View File
@@ -1,6 +1,9 @@
import { useParams, useNavigate } from 'react-router';
import { ScrollArea, IconButton, Button } from 'modern-sk';
import { useGetAlbumQuery, useGetAlbumTracksQuery } from '../../api/endpoints/library';
import {
useGetAlbumQuery,
useGetAlbumTracksQuery,
} from '../../api/endpoints/library';
import { TrackRow } from '../../components/track/TrackRow';
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
import { ErrorState } from '../../components/common/ErrorState';
@@ -19,11 +22,20 @@ export function AlbumDetailPage() {
const tracksQuery = useGetAlbumTracksQuery(albumId ?? '', { skip: !albumId });
if (albumQuery.isLoading || tracksQuery.isLoading) {
return <div style={{ padding: '1.5rem' }}><LoadingSkeleton rows={10} /></div>;
return (
<div style={{ padding: '1.5rem' }}>
<LoadingSkeleton rows={10} />
</div>
);
}
if (albumQuery.isError) {
return <ErrorState message="Failed to load album" onRetry={() => albumQuery.refetch()} />;
return (
<ErrorState
message="Failed to load album"
onRetry={() => albumQuery.refetch()}
/>
);
}
const album = albumQuery.data;
@@ -32,7 +44,8 @@ export function AlbumDetailPage() {
const handlePlayAll = () => {
if (!tracks.length || !album) return;
dispatch(setQueue({
dispatch(
setQueue({
entries: tracks.map((t) => ({
trackId: t.id,
title: t.title,
@@ -44,39 +57,125 @@ export function AlbumDetailPage() {
source: 'album',
sourceId: album.id,
sourceName: album.title,
}));
}),
);
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* header */}
<div style={{ padding: '1.25rem 1.5rem', borderBottom: '1px solid var(--color-border)', display: 'flex', alignItems: 'center', gap: '1rem', flexShrink: 0 }}>
<IconButton variant="ghost" size="sm" onClick={() => navigate(-1)} aria-label="Back"></IconButton>
<div style={{ display: 'flex', gap: '1.5rem', alignItems: 'flex-end', flex: 1 }}>
<div
style={{
padding: '1.25rem 1.5rem',
borderBottom: '1px solid var(--color-border)',
display: 'flex',
alignItems: 'center',
gap: '1rem',
flexShrink: 0,
}}
>
<IconButton
variant="ghost"
size="sm"
onClick={() => navigate(-1)}
aria-label="Back"
>
</IconButton>
<div
style={{
display: 'flex',
gap: '1.5rem',
alignItems: 'flex-end',
flex: 1,
}}
>
{artUrl ? (
<img src={artUrl} alt={album?.title} width={96} height={96} style={{ borderRadius: 8, objectFit: 'cover', flexShrink: 0 }} />
<img
src={artUrl}
alt={album?.title}
width={96}
height={96}
style={{ borderRadius: 8, objectFit: 'cover', flexShrink: 0 }}
/>
) : (
<div style={{ width: 96, height: 96, borderRadius: 8, background: 'var(--color-surface-3)', flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '2.5rem' }}>💿</div>
<div
style={{
width: 96,
height: 96,
borderRadius: 8,
background: 'var(--color-surface-3)',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '2.5rem',
}}
>
💿
</div>
)}
<div>
<p style={{ margin: 0, fontSize: '0.75rem', color: 'var(--color-text-3)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Album</p>
<h1 style={{ margin: '0.25rem 0', fontSize: '1.5rem', fontWeight: 700 }}>{album?.title}</h1>
<p style={{ margin: 0, color: 'var(--color-text-2)', fontSize: '0.875rem' }}>
<p
style={{
margin: 0,
fontSize: '0.75rem',
color: 'var(--color-text-3)',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
Album
</p>
<h1
style={{
margin: '0.25rem 0',
fontSize: '1.5rem',
fontWeight: 700,
}}
>
{album?.title}
</h1>
<p
style={{
margin: 0,
color: 'var(--color-text-2)',
fontSize: '0.875rem',
}}
>
{album?.artistName}
{album?.year && ` · ${album.year}`}
{album && ` · ${album.trackCount} tracks · ${formatDuration(album.totalDurationMs)}`}
{album &&
` · ${album.trackCount} tracks · ${formatDuration(album.totalDurationMs)}`}
</p>
</div>
</div>
<Button variant="primary" onClick={handlePlayAll} disabled={!tracks.length}> Play</Button>
<Button
variant="primary"
onClick={handlePlayAll}
disabled={!tracks.length}
>
Play
</Button>
</div>
{/* tracks */}
<ScrollArea style={{ flex: 1 }}>
{tracksQuery.isLoading && <LoadingSkeleton rows={10} />}
{tracksQuery.isError && <ErrorState message="Failed to load tracks" onRetry={() => tracksQuery.refetch()} />}
{!tracksQuery.isLoading && !tracksQuery.isError && tracks.length === 0 && (
<EmptyState icon="♫" title="No tracks" description="This album has no tracks." />
{tracksQuery.isError && (
<ErrorState
message="Failed to load tracks"
onRetry={() => tracksQuery.refetch()}
/>
)}
{!tracksQuery.isLoading &&
!tracksQuery.isError &&
tracks.length === 0 && (
<EmptyState
icon="♫"
title="No tracks"
description="This album has no tracks."
/>
)}
{tracks.map((track, i) => (
<TrackRow key={track.id} track={track} index={i} />

Some files were not shown because too many files have changed in this diff Show More