feat: auth & admin
This commit is contained in:
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
|
- `area-description.md` - Individual rule files
|
||||||
- `src/` - Build scripts and utilities
|
- `src/` - Build scripts and utilities
|
||||||
- `metadata.json` - Document metadata (version, organization, abstract)
|
- `metadata.json` - Document metadata (version, organization, abstract)
|
||||||
- __`AGENTS.md`__ - Compiled output (generated)
|
- **`AGENTS.md`** - Compiled output (generated)
|
||||||
- __`test-cases.json`__ - Test cases for LLM evaluation (generated)
|
- **`test-cases.json`** - Test cases for LLM evaluation (generated)
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
1. Install dependencies:
|
1. Install dependencies:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install
|
pnpm install
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Build AGENTS.md from rules:
|
2. Build AGENTS.md from rules:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm build
|
pnpm build
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Validate rule files:
|
3. Validate rule files:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm validate
|
pnpm validate
|
||||||
```
|
```
|
||||||
@@ -55,7 +58,7 @@ A structured repository for creating and maintaining React Best Practices optimi
|
|||||||
|
|
||||||
Each rule file should follow this structure:
|
Each rule file should follow this structure:
|
||||||
|
|
||||||
```markdown
|
````markdown
|
||||||
---
|
---
|
||||||
title: Rule Title Here
|
title: Rule Title Here
|
||||||
impact: MEDIUM
|
impact: MEDIUM
|
||||||
@@ -72,6 +75,7 @@ Brief explanation of the rule and why it matters.
|
|||||||
```typescript
|
```typescript
|
||||||
// Bad code example
|
// Bad code example
|
||||||
```
|
```
|
||||||
|
````
|
||||||
|
|
||||||
**Correct (description of what's right):**
|
**Correct (description of what's right):**
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ description: React and Next.js performance optimization guidelines from Vercel E
|
|||||||
license: MIT
|
license: MIT
|
||||||
metadata:
|
metadata:
|
||||||
author: vercel
|
author: vercel
|
||||||
version: "1.0.0"
|
version: '1.0.0'
|
||||||
---
|
---
|
||||||
|
|
||||||
# Vercel React Best Practices
|
# Vercel React Best Practices
|
||||||
@@ -14,6 +14,7 @@ Comprehensive performance optimization guide for React and Next.js applications,
|
|||||||
## When to Apply
|
## When to Apply
|
||||||
|
|
||||||
Reference these guidelines when:
|
Reference these guidelines when:
|
||||||
|
|
||||||
- Writing new React components or Next.js pages
|
- Writing new React components or Next.js pages
|
||||||
- Implementing data fetching (client or server-side)
|
- Implementing data fetching (client or server-side)
|
||||||
- Reviewing code for performance issues
|
- Reviewing code for performance issues
|
||||||
@@ -22,16 +23,16 @@ Reference these guidelines when:
|
|||||||
|
|
||||||
## Rule Categories by Priority
|
## Rule Categories by Priority
|
||||||
|
|
||||||
| Priority | Category | Impact | Prefix |
|
| Priority | Category | Impact | Prefix |
|
||||||
|----------|----------|--------|--------|
|
| -------- | ------------------------- | ----------- | ------------ |
|
||||||
| 1 | Eliminating Waterfalls | CRITICAL | `async-` |
|
| 1 | Eliminating Waterfalls | CRITICAL | `async-` |
|
||||||
| 2 | Bundle Size Optimization | CRITICAL | `bundle-` |
|
| 2 | Bundle Size Optimization | CRITICAL | `bundle-` |
|
||||||
| 3 | Server-Side Performance | HIGH | `server-` |
|
| 3 | Server-Side Performance | HIGH | `server-` |
|
||||||
| 4 | Client-Side Data Fetching | MEDIUM-HIGH | `client-` |
|
| 4 | Client-Side Data Fetching | MEDIUM-HIGH | `client-` |
|
||||||
| 5 | Re-render Optimization | MEDIUM | `rerender-` |
|
| 5 | Re-render Optimization | MEDIUM | `rerender-` |
|
||||||
| 6 | Rendering Performance | MEDIUM | `rendering-` |
|
| 6 | Rendering Performance | MEDIUM | `rendering-` |
|
||||||
| 7 | JavaScript Performance | LOW-MEDIUM | `js-` |
|
| 7 | JavaScript Performance | LOW-MEDIUM | `js-` |
|
||||||
| 8 | Advanced Patterns | LOW | `advanced-` |
|
| 8 | Advanced Patterns | LOW | `advanced-` |
|
||||||
|
|
||||||
## Quick Reference
|
## Quick Reference
|
||||||
|
|
||||||
@@ -139,6 +140,7 @@ rules/bundle-barrel-imports.md
|
|||||||
```
|
```
|
||||||
|
|
||||||
Each rule file contains:
|
Each rule file contains:
|
||||||
|
|
||||||
- Brief explanation of why it matters
|
- Brief explanation of why it matters
|
||||||
- Incorrect code example with explanation
|
- Incorrect code example with explanation
|
||||||
- Correct 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
|
```typescript
|
||||||
// Bad code example here
|
// Bad code example here
|
||||||
const bad = example()
|
const bad = example();
|
||||||
```
|
```
|
||||||
|
|
||||||
**Correct (description of what's right):**
|
**Correct (description of what's right):**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Good code example here
|
// Good code example here
|
||||||
const good = example()
|
const good = example();
|
||||||
```
|
```
|
||||||
|
|
||||||
Reference: [Link to documentation or resource](https://example.com)
|
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):**
|
**Incorrect (Effect Event added as a dependency):**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { useEffect, useEffectEvent } from 'react'
|
import { useEffect, useEffectEvent } from 'react';
|
||||||
|
|
||||||
function ChatRoom({ roomId, onConnected }: {
|
function ChatRoom({
|
||||||
roomId: string
|
roomId,
|
||||||
onConnected: () => void
|
onConnected,
|
||||||
|
}: {
|
||||||
|
roomId: string;
|
||||||
|
onConnected: () => void;
|
||||||
}) {
|
}) {
|
||||||
const handleConnected = useEffectEvent(onConnected)
|
const handleConnected = useEffectEvent(onConnected);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const connection = createConnection(roomId)
|
const connection = createConnection(roomId);
|
||||||
connection.on('connected', handleConnected)
|
connection.on('connected', handleConnected);
|
||||||
connection.connect()
|
connection.connect();
|
||||||
|
|
||||||
return () => connection.disconnect()
|
return () => connection.disconnect();
|
||||||
}, [roomId, handleConnected])
|
}, [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):**
|
**Correct (depend on reactive values, not the Effect Event):**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { useEffect, useEffectEvent } from 'react'
|
import { useEffect, useEffectEvent } from 'react';
|
||||||
|
|
||||||
function ChatRoom({ roomId, onConnected }: {
|
function ChatRoom({
|
||||||
roomId: string
|
roomId,
|
||||||
onConnected: () => void
|
onConnected,
|
||||||
|
}: {
|
||||||
|
roomId: string;
|
||||||
|
onConnected: () => void;
|
||||||
}) {
|
}) {
|
||||||
const handleConnected = useEffectEvent(onConnected)
|
const handleConnected = useEffectEvent(onConnected);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const connection = createConnection(roomId)
|
const connection = createConnection(roomId);
|
||||||
connection.on('connected', handleConnected)
|
connection.on('connected', handleConnected);
|
||||||
connection.connect()
|
connection.connect();
|
||||||
|
|
||||||
return () => connection.disconnect()
|
return () => connection.disconnect();
|
||||||
}, [roomId])
|
}, [roomId]);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ Store callbacks in refs when used in effects that shouldn't re-subscribe on call
|
|||||||
```tsx
|
```tsx
|
||||||
function useWindowEvent(event: string, handler: (e) => void) {
|
function useWindowEvent(event: string, handler: (e) => void) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.addEventListener(event, handler)
|
window.addEventListener(event, handler);
|
||||||
return () => window.removeEventListener(event, handler)
|
return () => window.removeEventListener(event, handler);
|
||||||
}, [event, handler])
|
}, [event, handler]);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -24,31 +24,31 @@ function useWindowEvent(event: string, handler: (e) => void) {
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
function useWindowEvent(event: string, handler: (e) => void) {
|
function useWindowEvent(event: string, handler: (e) => void) {
|
||||||
const handlerRef = useRef(handler)
|
const handlerRef = useRef(handler);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handlerRef.current = handler
|
handlerRef.current = handler;
|
||||||
}, [handler])
|
}, [handler]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const listener = (e) => handlerRef.current(e)
|
const listener = (e) => handlerRef.current(e);
|
||||||
window.addEventListener(event, listener)
|
window.addEventListener(event, listener);
|
||||||
return () => window.removeEventListener(event, listener)
|
return () => window.removeEventListener(event, listener);
|
||||||
}, [event])
|
}, [event]);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Alternative: use `useEffectEvent` if you're on latest React:**
|
**Alternative: use `useEffectEvent` if you're on latest React:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { useEffectEvent } from 'react'
|
import { useEffectEvent } from 'react';
|
||||||
|
|
||||||
function useWindowEvent(event: string, handler: (e) => void) {
|
function useWindowEvent(event: string, handler: (e) => void) {
|
||||||
const onEvent = useEffectEvent(handler)
|
const onEvent = useEffectEvent(handler);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.addEventListener(event, onEvent)
|
window.addEventListener(event, onEvent);
|
||||||
return () => window.removeEventListener(event, onEvent)
|
return () => window.removeEventListener(event, onEvent);
|
||||||
}, [event])
|
}, [event]);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ Do not put app-wide initialization that must run once per app load inside `useEf
|
|||||||
```tsx
|
```tsx
|
||||||
function Comp() {
|
function Comp() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadFromStorage()
|
loadFromStorage();
|
||||||
checkAuthToken()
|
checkAuthToken();
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
// ...
|
// ...
|
||||||
}
|
}
|
||||||
@@ -25,15 +25,15 @@ function Comp() {
|
|||||||
**Correct (once per app load):**
|
**Correct (once per app load):**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
let didInit = false
|
let didInit = false;
|
||||||
|
|
||||||
function Comp() {
|
function Comp() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (didInit) return
|
if (didInit) return;
|
||||||
didInit = true
|
didInit = true;
|
||||||
loadFromStorage()
|
loadFromStorage();
|
||||||
checkAuthToken()
|
checkAuthToken();
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
// ...
|
// ...
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,12 +13,12 @@ Access latest values in callbacks without adding them to dependency arrays. Prev
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
|
function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeout = setTimeout(() => onSearch(query), 300)
|
const timeout = setTimeout(() => onSearch(query), 300);
|
||||||
return () => clearTimeout(timeout)
|
return () => clearTimeout(timeout);
|
||||||
}, [query, onSearch])
|
}, [query, onSearch]);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -28,12 +28,12 @@ function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
|
|||||||
import { useEffectEvent } from 'react';
|
import { useEffectEvent } from 'react';
|
||||||
|
|
||||||
function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
|
function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('');
|
||||||
const onSearchEvent = useEffectEvent(onSearch)
|
const onSearchEvent = useEffectEvent(onSearch);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeout = setTimeout(() => onSearchEvent(query), 300)
|
const timeout = setTimeout(() => onSearchEvent(query), 300);
|
||||||
return () => clearTimeout(timeout)
|
return () => clearTimeout(timeout);
|
||||||
}, [query])
|
}, [query]);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ In API routes and Server Actions, start independent operations immediately, even
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const session = await auth()
|
const session = await auth();
|
||||||
const config = await fetchConfig()
|
const config = await fetchConfig();
|
||||||
const data = await fetchData(session.user.id)
|
const data = await fetchData(session.user.id);
|
||||||
return Response.json({ data, config })
|
return Response.json({ data, config });
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -24,14 +24,14 @@ export async function GET(request: Request) {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const sessionPromise = auth()
|
const sessionPromise = auth();
|
||||||
const configPromise = fetchConfig()
|
const configPromise = fetchConfig();
|
||||||
const session = await sessionPromise
|
const session = await sessionPromise;
|
||||||
const [config, data] = await Promise.all([
|
const [config, data] = await Promise.all([
|
||||||
configPromise,
|
configPromise,
|
||||||
fetchData(session.user.id)
|
fetchData(session.user.id),
|
||||||
])
|
]);
|
||||||
return Response.json({ data, config })
|
return Response.json({ data, config });
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -14,7 +14,7 @@ This is a specialization of [Defer Await Until Needed](./async-defer-await.md) f
|
|||||||
**Incorrect:**
|
**Incorrect:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const someFlag = await getFlag()
|
const someFlag = await getFlag();
|
||||||
|
|
||||||
if (someFlag && someCondition) {
|
if (someFlag && someCondition) {
|
||||||
// ...
|
// ...
|
||||||
@@ -25,7 +25,7 @@ if (someFlag && someCondition) {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
if (someCondition) {
|
if (someCondition) {
|
||||||
const someFlag = await getFlag()
|
const someFlag = await getFlag();
|
||||||
if (someFlag) {
|
if (someFlag) {
|
||||||
// ...
|
// ...
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,15 +13,15 @@ Move `await` operations into the branches where they're actually used to avoid b
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
async function handleRequest(userId: string, skipProcessing: boolean) {
|
async function handleRequest(userId: string, skipProcessing: boolean) {
|
||||||
const userData = await fetchUserData(userId)
|
const userData = await fetchUserData(userId);
|
||||||
|
|
||||||
if (skipProcessing) {
|
if (skipProcessing) {
|
||||||
// Returns immediately but still waited for userData
|
// Returns immediately but still waited for userData
|
||||||
return { skipped: true }
|
return { skipped: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only this branch uses userData
|
// 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) {
|
async function handleRequest(userId: string, skipProcessing: boolean) {
|
||||||
if (skipProcessing) {
|
if (skipProcessing) {
|
||||||
// Returns immediately without waiting
|
// Returns immediately without waiting
|
||||||
return { skipped: true }
|
return { skipped: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch only when needed
|
// Fetch only when needed
|
||||||
const userData = await fetchUserData(userId)
|
const userData = await fetchUserData(userId);
|
||||||
return processUserData(userData)
|
return processUserData(userData);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -45,35 +45,35 @@ async function handleRequest(userId: string, skipProcessing: boolean) {
|
|||||||
```typescript
|
```typescript
|
||||||
// Incorrect: always fetches permissions
|
// Incorrect: always fetches permissions
|
||||||
async function updateResource(resourceId: string, userId: string) {
|
async function updateResource(resourceId: string, userId: string) {
|
||||||
const permissions = await fetchPermissions(userId)
|
const permissions = await fetchPermissions(userId);
|
||||||
const resource = await getResource(resourceId)
|
const resource = await getResource(resourceId);
|
||||||
|
|
||||||
if (!resource) {
|
if (!resource) {
|
||||||
return { error: 'Not found' }
|
return { error: 'Not found' };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!permissions.canEdit) {
|
if (!permissions.canEdit) {
|
||||||
return { error: 'Forbidden' }
|
return { error: 'Forbidden' };
|
||||||
}
|
}
|
||||||
|
|
||||||
return await updateResourceData(resource, permissions)
|
return await updateResourceData(resource, permissions);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Correct: fetches only when needed
|
// Correct: fetches only when needed
|
||||||
async function updateResource(resourceId: string, userId: string) {
|
async function updateResource(resourceId: string, userId: string) {
|
||||||
const resource = await getResource(resourceId)
|
const resource = await getResource(resourceId);
|
||||||
|
|
||||||
if (!resource) {
|
if (!resource) {
|
||||||
return { error: 'Not found' }
|
return { error: 'Not found' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const permissions = await fetchPermissions(userId)
|
const permissions = await fetchPermissions(userId);
|
||||||
|
|
||||||
if (!permissions.canEdit) {
|
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):**
|
**Incorrect (profile waits for config unnecessarily):**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const [user, config] = await Promise.all([
|
const [user, config] = await Promise.all([fetchUser(), fetchConfig()]);
|
||||||
fetchUser(),
|
const profile = await fetchProfile(user.id);
|
||||||
fetchConfig()
|
|
||||||
])
|
|
||||||
const profile = await fetchProfile(user.id)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Correct (config and profile run in parallel):**
|
**Correct (config and profile run in parallel):**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { all } from 'better-all'
|
import { all } from 'better-all';
|
||||||
|
|
||||||
const { user, config, profile } = await all({
|
const { user, config, profile } = await all({
|
||||||
async user() { return fetchUser() },
|
async user() {
|
||||||
async config() { return fetchConfig() },
|
return fetchUser();
|
||||||
|
},
|
||||||
|
async config() {
|
||||||
|
return fetchConfig();
|
||||||
|
},
|
||||||
async profile() {
|
async profile() {
|
||||||
return fetchProfile((await this.$.user).id)
|
return fetchProfile((await this.$.user).id);
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
**Alternative without extra dependencies:**
|
**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.
|
We can also create all the promises first, and do `Promise.all()` at the end.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const userPromise = fetchUser()
|
const userPromise = fetchUser();
|
||||||
const profilePromise = userPromise.then(user => fetchProfile(user.id))
|
const profilePromise = userPromise.then((user) => fetchProfile(user.id));
|
||||||
|
|
||||||
const [user, config, profile] = await Promise.all([
|
const [user, config, profile] = await Promise.all([
|
||||||
userPromise,
|
userPromise,
|
||||||
fetchConfig(),
|
fetchConfig(),
|
||||||
profilePromise
|
profilePromise,
|
||||||
])
|
]);
|
||||||
```
|
```
|
||||||
|
|
||||||
Reference: [https://github.com/shuding/better-all](https://github.com/shuding/better-all)
|
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):**
|
**Incorrect (sequential execution, 3 round trips):**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const user = await fetchUser()
|
const user = await fetchUser();
|
||||||
const posts = await fetchPosts()
|
const posts = await fetchPosts();
|
||||||
const comments = await fetchComments()
|
const comments = await fetchComments();
|
||||||
```
|
```
|
||||||
|
|
||||||
**Correct (parallel execution, 1 round trip):**
|
**Correct (parallel execution, 1 round trip):**
|
||||||
@@ -23,6 +23,6 @@ const comments = await fetchComments()
|
|||||||
const [user, posts, comments] = await Promise.all([
|
const [user, posts, comments] = await Promise.all([
|
||||||
fetchUser(),
|
fetchUser(),
|
||||||
fetchPosts(),
|
fetchPosts(),
|
||||||
fetchComments()
|
fetchComments(),
|
||||||
])
|
]);
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Instead of awaiting data in async components before returning JSX, use Suspense
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
async function Page() {
|
async function Page() {
|
||||||
const data = await fetchData() // Blocks entire page
|
const data = await fetchData(); // Blocks entire page
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -24,7 +24,7 @@ async function Page() {
|
|||||||
</div>
|
</div>
|
||||||
<div>Footer</div>
|
<div>Footer</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -45,12 +45,12 @@ function Page() {
|
|||||||
</div>
|
</div>
|
||||||
<div>Footer</div>
|
<div>Footer</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function DataDisplay() {
|
async function DataDisplay() {
|
||||||
const data = await fetchData() // Only blocks this component
|
const data = await fetchData(); // Only blocks this component
|
||||||
return <div>{data.content}</div>
|
return <div>{data.content}</div>;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ Sidebar, Header, and Footer render immediately. Only DataDisplay waits for data.
|
|||||||
```tsx
|
```tsx
|
||||||
function Page() {
|
function Page() {
|
||||||
// Start fetch immediately, but don't await
|
// Start fetch immediately, but don't await
|
||||||
const dataPromise = fetchData()
|
const dataPromise = fetchData();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -73,17 +73,17 @@ function Page() {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
<div>Footer</div>
|
<div>Footer</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DataDisplay({ dataPromise }: { dataPromise: Promise<Data> }) {
|
function DataDisplay({ dataPromise }: { dataPromise: Promise<Data> }) {
|
||||||
const data = use(dataPromise) // Unwraps the promise
|
const data = use(dataPromise); // Unwraps the promise
|
||||||
return <div>{data.content}</div>
|
return <div>{data.content}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DataSummary({ dataPromise }: { dataPromise: Promise<Data> }) {
|
function DataSummary({ dataPromise }: { dataPromise: Promise<Data> }) {
|
||||||
const data = use(dataPromise) // Reuses the same promise
|
const data = use(dataPromise); // Reuses the same promise
|
||||||
return <div>{data.summary}</div>
|
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.
|
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:
|
When analysis becomes too broad, the cost is real:
|
||||||
|
|
||||||
- Larger server bundles
|
- Larger server bundles
|
||||||
- Slower builds
|
- Slower builds
|
||||||
- Worse cold starts
|
- Worse cold starts
|
||||||
@@ -25,9 +26,9 @@ When analysis becomes too broad, the cost is real:
|
|||||||
const PAGE_MODULES = {
|
const PAGE_MODULES = {
|
||||||
home: './pages/home',
|
home: './pages/home',
|
||||||
settings: './pages/settings',
|
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):**
|
**Correct (use an explicit map of allowed modules):**
|
||||||
@@ -36,9 +37,9 @@ const Page = await import(PAGE_MODULES[pageName])
|
|||||||
const PAGE_MODULES = {
|
const PAGE_MODULES = {
|
||||||
home: () => import('./pages/home'),
|
home: () => import('./pages/home'),
|
||||||
settings: () => import('./pages/settings'),
|
settings: () => import('./pages/settings'),
|
||||||
} as const
|
} as const;
|
||||||
|
|
||||||
const Page = await PAGE_MODULES[pageName]()
|
const Page = await PAGE_MODULES[pageName]();
|
||||||
```
|
```
|
||||||
|
|
||||||
### File-System Paths
|
### 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):**
|
**Incorrect (a 2-value enum still hides the final path from static analysis):**
|
||||||
|
|
||||||
```ts
|
```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):**
|
**Correct (make each final path literal at the callsite):**
|
||||||
@@ -55,7 +56,7 @@ const baseDir = path.join(process.cwd(), 'content/' + contentKind)
|
|||||||
const baseDir =
|
const baseDir =
|
||||||
kind === ContentKind.Blog
|
kind === ContentKind.Blog
|
||||||
? path.join(process.cwd(), 'content/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.
|
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):**
|
**Incorrect (imports entire library):**
|
||||||
|
|
||||||
```tsx
|
```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
|
// Loads 1,583 modules, takes ~2.8s extra in dev
|
||||||
// Runtime cost: 200-800ms on every cold start
|
// 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
|
// 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
|
// next.config.js - automatically optimizes barrel imports at build time
|
||||||
module.exports = {
|
module.exports = {
|
||||||
experimental: {
|
experimental: {
|
||||||
optimizePackageImports: ['lucide-react', '@mui/material']
|
optimizePackageImports: ['lucide-react', '@mui/material'],
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// Keep the standard imports - Next.js transforms them to direct imports
|
// 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
|
// 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):**
|
**Correct - Direct imports (non-Next.js projects):**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import Button from '@mui/material/Button'
|
import Button from '@mui/material/Button';
|
||||||
import TextField from '@mui/material/TextField'
|
import TextField from '@mui/material/TextField';
|
||||||
// Loads only what you use
|
// 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):**
|
**Example (lazy-load animation frames):**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
function AnimationPlayer({ enabled, setEnabled }: { enabled: boolean; setEnabled: React.Dispatch<React.SetStateAction<boolean>> }) {
|
function AnimationPlayer({
|
||||||
const [frames, setFrames] = useState<Frame[] | null>(null)
|
enabled,
|
||||||
|
setEnabled,
|
||||||
|
}: {
|
||||||
|
enabled: boolean;
|
||||||
|
setEnabled: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
}) {
|
||||||
|
const [frames, setFrames] = useState<Frame[] | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (enabled && !frames && typeof window !== 'undefined') {
|
if (enabled && !frames && typeof window !== 'undefined') {
|
||||||
import('./animation-frames.js')
|
import('./animation-frames.js')
|
||||||
.then(mod => setFrames(mod.frames))
|
.then((mod) => setFrames(mod.frames))
|
||||||
.catch(() => setEnabled(false))
|
.catch(() => setEnabled(false));
|
||||||
}
|
}
|
||||||
}, [enabled, frames, setEnabled])
|
}, [enabled, frames, setEnabled]);
|
||||||
|
|
||||||
if (!frames) return <Skeleton />
|
if (!frames) return <Skeleton />;
|
||||||
return <Canvas frames={frames} />
|
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):**
|
**Incorrect (blocks initial bundle):**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Analytics } from '@vercel/analytics/react'
|
import { Analytics } from '@vercel/analytics/react';
|
||||||
|
|
||||||
export default function RootLayout({ children }) {
|
export default function RootLayout({ children }) {
|
||||||
return (
|
return (
|
||||||
@@ -22,19 +22,19 @@ export default function RootLayout({ children }) {
|
|||||||
<Analytics />
|
<Analytics />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Correct (loads after hydration):**
|
**Correct (loads after hydration):**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
const Analytics = dynamic(
|
const Analytics = dynamic(
|
||||||
() => import('@vercel/analytics/react').then(m => m.Analytics),
|
() => import('@vercel/analytics/react').then((m) => m.Analytics),
|
||||||
{ ssr: false }
|
{ ssr: false },
|
||||||
)
|
);
|
||||||
|
|
||||||
export default function RootLayout({ children }) {
|
export default function RootLayout({ children }) {
|
||||||
return (
|
return (
|
||||||
@@ -44,6 +44,6 @@ export default function RootLayout({ children }) {
|
|||||||
<Analytics />
|
<Analytics />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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):**
|
**Incorrect (Monaco bundles with main chunk ~300KB):**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { MonacoEditor } from './monaco-editor'
|
import { MonacoEditor } from './monaco-editor';
|
||||||
|
|
||||||
function CodePanel({ code }: { code: string }) {
|
function CodePanel({ code }: { code: string }) {
|
||||||
return <MonacoEditor value={code} />
|
return <MonacoEditor value={code} />;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Correct (Monaco loads on demand):**
|
**Correct (Monaco loads on demand):**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
const MonacoEditor = dynamic(
|
const MonacoEditor = dynamic(
|
||||||
() => import('./monaco-editor').then(m => m.MonacoEditor),
|
() => import('./monaco-editor').then((m) => m.MonacoEditor),
|
||||||
{ ssr: false }
|
{ ssr: false },
|
||||||
)
|
);
|
||||||
|
|
||||||
function CodePanel({ code }: { code: string }) {
|
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 }) {
|
function EditorButton({ onClick }: { onClick: () => void }) {
|
||||||
const preload = () => {
|
const preload = () => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
void import('./monaco-editor')
|
void import('./monaco-editor');
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button onMouseEnter={preload} onFocus={preload} onClick={onClick}>
|
||||||
onMouseEnter={preload}
|
|
||||||
onFocus={preload}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
Open Editor
|
Open Editor
|
||||||
</button>
|
</button>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -37,13 +33,13 @@ function EditorButton({ onClick }: { onClick: () => void }) {
|
|||||||
function FlagsProvider({ children, flags }: Props) {
|
function FlagsProvider({ children, flags }: Props) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (flags.editorEnabled && typeof window !== 'undefined') {
|
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}>
|
return (
|
||||||
{children}
|
<FlagsContext.Provider value={flags}>{children}</FlagsContext.Provider>
|
||||||
</FlagsContext.Provider>
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ function useKeyboardShortcut(key: string, callback: () => void) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => {
|
||||||
if (e.metaKey && e.key === key) {
|
if (e.metaKey && e.key === key) {
|
||||||
callback()
|
callback();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
window.addEventListener('keydown', handler)
|
window.addEventListener('keydown', handler);
|
||||||
return () => window.removeEventListener('keydown', handler)
|
return () => window.removeEventListener('keydown', handler);
|
||||||
}, [key, callback])
|
}, [key, callback]);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -30,45 +30,49 @@ When using the `useKeyboardShortcut` hook multiple times, each instance will reg
|
|||||||
**Correct (N instances = 1 listener):**
|
**Correct (N instances = 1 listener):**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import useSWRSubscription from 'swr/subscription'
|
import useSWRSubscription from 'swr/subscription';
|
||||||
|
|
||||||
// Module-level Map to track callbacks per key
|
// 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) {
|
function useKeyboardShortcut(key: string, callback: () => void) {
|
||||||
// Register this callback in the Map
|
// Register this callback in the Map
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!keyCallbacks.has(key)) {
|
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 () => {
|
return () => {
|
||||||
const set = keyCallbacks.get(key)
|
const set = keyCallbacks.get(key);
|
||||||
if (set) {
|
if (set) {
|
||||||
set.delete(callback)
|
set.delete(callback);
|
||||||
if (set.size === 0) {
|
if (set.size === 0) {
|
||||||
keyCallbacks.delete(key)
|
keyCallbacks.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
}, [key, callback])
|
}, [key, callback]);
|
||||||
|
|
||||||
useSWRSubscription('global-keydown', () => {
|
useSWRSubscription('global-keydown', () => {
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => {
|
||||||
if (e.metaKey && keyCallbacks.has(e.key)) {
|
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)
|
window.addEventListener('keydown', handler);
|
||||||
return () => window.removeEventListener('keydown', handler)
|
return () => window.removeEventListener('keydown', handler);
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function Profile() {
|
function Profile() {
|
||||||
// Multiple shortcuts will share the same listener
|
// Multiple shortcuts will share the same listener
|
||||||
useKeyboardShortcut('p', () => { /* ... */ })
|
useKeyboardShortcut('p', () => {
|
||||||
useKeyboardShortcut('k', () => { /* ... */ })
|
/* ... */
|
||||||
|
});
|
||||||
|
useKeyboardShortcut('k', () => {
|
||||||
|
/* ... */
|
||||||
|
});
|
||||||
// ...
|
// ...
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -13,18 +13,18 @@ Add version prefix to keys and store only needed fields. Prevents schema conflic
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// No version, stores everything, no error handling
|
// No version, stores everything, no error handling
|
||||||
localStorage.setItem('userConfig', JSON.stringify(fullUserObject))
|
localStorage.setItem('userConfig', JSON.stringify(fullUserObject));
|
||||||
const data = localStorage.getItem('userConfig')
|
const data = localStorage.getItem('userConfig');
|
||||||
```
|
```
|
||||||
|
|
||||||
**Correct:**
|
**Correct:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const VERSION = 'v2'
|
const VERSION = 'v2';
|
||||||
|
|
||||||
function saveConfig(config: { theme: string; language: string }) {
|
function saveConfig(config: { theme: string; language: string }) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(`userConfig:${VERSION}`, JSON.stringify(config))
|
localStorage.setItem(`userConfig:${VERSION}`, JSON.stringify(config));
|
||||||
} catch {
|
} catch {
|
||||||
// Throws in incognito/private browsing, quota exceeded, or disabled
|
// Throws in incognito/private browsing, quota exceeded, or disabled
|
||||||
}
|
}
|
||||||
@@ -32,21 +32,24 @@ function saveConfig(config: { theme: string; language: string }) {
|
|||||||
|
|
||||||
function loadConfig() {
|
function loadConfig() {
|
||||||
try {
|
try {
|
||||||
const data = localStorage.getItem(`userConfig:${VERSION}`)
|
const data = localStorage.getItem(`userConfig:${VERSION}`);
|
||||||
return data ? JSON.parse(data) : null
|
return data ? JSON.parse(data) : null;
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Migration from v1 to v2
|
// Migration from v1 to v2
|
||||||
function migrate() {
|
function migrate() {
|
||||||
try {
|
try {
|
||||||
const v1 = localStorage.getItem('userConfig:v1')
|
const v1 = localStorage.getItem('userConfig:v1');
|
||||||
if (v1) {
|
if (v1) {
|
||||||
const old = JSON.parse(v1)
|
const old = JSON.parse(v1);
|
||||||
saveConfig({ theme: old.darkMode ? 'dark' : 'light', language: old.lang })
|
saveConfig({
|
||||||
localStorage.removeItem('userConfig:v1')
|
theme: old.darkMode ? 'dark' : 'light',
|
||||||
|
language: old.lang,
|
||||||
|
});
|
||||||
|
localStorage.removeItem('userConfig:v1');
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
@@ -58,10 +61,13 @@ function migrate() {
|
|||||||
// User object has 20+ fields, only store what UI needs
|
// User object has 20+ fields, only store what UI needs
|
||||||
function cachePrefs(user: FullUser) {
|
function cachePrefs(user: FullUser) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('prefs:v1', JSON.stringify({
|
localStorage.setItem(
|
||||||
theme: user.preferences.theme,
|
'prefs:v1',
|
||||||
notifications: user.preferences.notifications
|
JSON.stringify({
|
||||||
}))
|
theme: user.preferences.theme,
|
||||||
|
notifications: user.preferences.notifications,
|
||||||
|
}),
|
||||||
|
);
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
+16
-16
@@ -13,34 +13,34 @@ Add `{ passive: true }` to touch and wheel event listeners to enable immediate s
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX)
|
const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX);
|
||||||
const handleWheel = (e: WheelEvent) => console.log(e.deltaY)
|
const handleWheel = (e: WheelEvent) => console.log(e.deltaY);
|
||||||
|
|
||||||
document.addEventListener('touchstart', handleTouch)
|
document.addEventListener('touchstart', handleTouch);
|
||||||
document.addEventListener('wheel', handleWheel)
|
document.addEventListener('wheel', handleWheel);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('touchstart', handleTouch)
|
document.removeEventListener('touchstart', handleTouch);
|
||||||
document.removeEventListener('wheel', handleWheel)
|
document.removeEventListener('wheel', handleWheel);
|
||||||
}
|
};
|
||||||
}, [])
|
}, []);
|
||||||
```
|
```
|
||||||
|
|
||||||
**Correct:**
|
**Correct:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX)
|
const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX);
|
||||||
const handleWheel = (e: WheelEvent) => console.log(e.deltaY)
|
const handleWheel = (e: WheelEvent) => console.log(e.deltaY);
|
||||||
|
|
||||||
document.addEventListener('touchstart', handleTouch, { passive: true })
|
document.addEventListener('touchstart', handleTouch, { passive: true });
|
||||||
document.addEventListener('wheel', handleWheel, { passive: true })
|
document.addEventListener('wheel', handleWheel, { passive: true });
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('touchstart', handleTouch)
|
document.removeEventListener('touchstart', handleTouch);
|
||||||
document.removeEventListener('wheel', handleWheel)
|
document.removeEventListener('wheel', handleWheel);
|
||||||
}
|
};
|
||||||
}, [])
|
}, []);
|
||||||
```
|
```
|
||||||
|
|
||||||
**Use passive when:** tracking/analytics, logging, any listener that doesn't call `preventDefault()`.
|
**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
|
```tsx
|
||||||
function UserList() {
|
function UserList() {
|
||||||
const [users, setUsers] = useState([])
|
const [users, setUsers] = useState([]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/users')
|
fetch('/api/users')
|
||||||
.then(r => r.json())
|
.then((r) => r.json())
|
||||||
.then(setUsers)
|
.then(setUsers);
|
||||||
}, [])
|
}, []);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Correct (multiple instances share one request):**
|
**Correct (multiple instances share one request):**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import useSWR from 'swr'
|
import useSWR from 'swr';
|
||||||
|
|
||||||
function UserList() {
|
function UserList() {
|
||||||
const { data: users } = useSWR('/api/users', fetcher)
|
const { data: users } = useSWR('/api/users', fetcher);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**For immutable data:**
|
**For immutable data:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { useImmutableSWR } from '@/lib/swr'
|
import { useImmutableSWR } from '@/lib/swr';
|
||||||
|
|
||||||
function StaticContent() {
|
function StaticContent() {
|
||||||
const { data } = useImmutableSWR('/api/config', fetcher)
|
const { data } = useImmutableSWR('/api/config', fetcher);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**For mutations:**
|
**For mutations:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { useSWRMutation } from 'swr/mutation'
|
import { useSWRMutation } from 'swr/mutation';
|
||||||
|
|
||||||
function UpdateButton() {
|
function UpdateButton() {
|
||||||
const { trigger } = useSWRMutation('/api/user', updateUser)
|
const { trigger } = useSWRMutation('/api/user', updateUser);
|
||||||
return <button onClick={() => trigger()}>Update</button>
|
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.
|
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):**
|
**This is OK (browser batches style changes):**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
function updateElementStyles(element: HTMLElement) {
|
function updateElementStyles(element: HTMLElement) {
|
||||||
// Each line invalidates style, but browser batches the recalculation
|
// Each line invalidates style, but browser batches the recalculation
|
||||||
element.style.width = '100px'
|
element.style.width = '100px';
|
||||||
element.style.height = '200px'
|
element.style.height = '200px';
|
||||||
element.style.backgroundColor = 'blue'
|
element.style.backgroundColor = 'blue';
|
||||||
element.style.border = '1px solid black'
|
element.style.border = '1px solid black';
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Incorrect (interleaved reads and writes force reflows):**
|
**Incorrect (interleaved reads and writes force reflows):**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
function layoutThrashing(element: HTMLElement) {
|
function layoutThrashing(element: HTMLElement) {
|
||||||
element.style.width = '100px'
|
element.style.width = '100px';
|
||||||
const width = element.offsetWidth // Forces reflow
|
const width = element.offsetWidth; // Forces reflow
|
||||||
element.style.height = '200px'
|
element.style.height = '200px';
|
||||||
const height = element.offsetHeight // Forces another reflow
|
const height = element.offsetHeight; // Forces another reflow
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Correct (batch writes, then read once):**
|
**Correct (batch writes, then read once):**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
function updateElementStyles(element: HTMLElement) {
|
function updateElementStyles(element: HTMLElement) {
|
||||||
// Batch all writes together
|
// Batch all writes together
|
||||||
element.style.width = '100px'
|
element.style.width = '100px';
|
||||||
element.style.height = '200px'
|
element.style.height = '200px';
|
||||||
element.style.backgroundColor = 'blue'
|
element.style.backgroundColor = 'blue';
|
||||||
element.style.border = '1px solid black'
|
element.style.border = '1px solid black';
|
||||||
|
|
||||||
// Read after all writes are done (single reflow)
|
// Read after all writes are done (single reflow)
|
||||||
const { width, height } = element.getBoundingClientRect()
|
const { width, height } = element.getBoundingClientRect();
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Correct (batch reads, then writes):**
|
**Correct (batch reads, then writes):**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
function avoidThrashing(element: HTMLElement) {
|
function avoidThrashing(element: HTMLElement) {
|
||||||
// Read phase - all layout queries first
|
// Read phase - all layout queries first
|
||||||
const rect1 = element.getBoundingClientRect()
|
const rect1 = element.getBoundingClientRect();
|
||||||
const offsetWidth = element.offsetWidth
|
const offsetWidth = element.offsetWidth;
|
||||||
const offsetHeight = element.offsetHeight
|
const offsetHeight = element.offsetHeight;
|
||||||
|
|
||||||
// Write phase - all style changes after
|
// Write phase - all style changes after
|
||||||
element.style.width = '100px'
|
element.style.width = '100px';
|
||||||
element.style.height = '200px'
|
element.style.height = '200px';
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Better: use CSS classes**
|
**Better: use CSS classes**
|
||||||
|
|
||||||
```css
|
```css
|
||||||
.highlighted-box {
|
.highlighted-box {
|
||||||
width: 100px;
|
width: 100px;
|
||||||
@@ -67,38 +72,36 @@ function avoidThrashing(element: HTMLElement) {
|
|||||||
border: 1px solid black;
|
border: 1px solid black;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
function updateElementStyles(element: HTMLElement) {
|
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:**
|
**React example:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// Incorrect: interleaving style changes with layout queries
|
// Incorrect: interleaving style changes with layout queries
|
||||||
function Box({ isHighlighted }: { isHighlighted: boolean }) {
|
function Box({ isHighlighted }: { isHighlighted: boolean }) {
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (ref.current && isHighlighted) {
|
if (ref.current && isHighlighted) {
|
||||||
ref.current.style.width = '100px'
|
ref.current.style.width = '100px';
|
||||||
const width = ref.current.offsetWidth // Forces layout
|
const width = ref.current.offsetWidth; // Forces layout
|
||||||
ref.current.style.height = '200px'
|
ref.current.style.height = '200px';
|
||||||
}
|
}
|
||||||
}, [isHighlighted])
|
}, [isHighlighted]);
|
||||||
|
|
||||||
return <div ref={ref}>Content</div>
|
return <div ref={ref}>Content</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Correct: toggle class
|
// Correct: toggle class
|
||||||
function Box({ isHighlighted }: { isHighlighted: boolean }) {
|
function Box({ isHighlighted }: { isHighlighted: boolean }) {
|
||||||
return (
|
return <div className={isHighlighted ? 'highlighted-box' : ''}>Content</div>;
|
||||||
<div className={isHighlighted ? 'highlighted-box' : ''}>
|
|
||||||
Content
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -58,20 +58,20 @@ function ProjectList({ projects }: { projects: Project[] }) {
|
|||||||
**Simpler pattern for single-value functions:**
|
**Simpler pattern for single-value functions:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
let isLoggedInCache: boolean | null = null
|
let isLoggedInCache: boolean | null = null;
|
||||||
|
|
||||||
function isLoggedIn(): boolean {
|
function isLoggedIn(): boolean {
|
||||||
if (isLoggedInCache !== null) {
|
if (isLoggedInCache !== null) {
|
||||||
return isLoggedInCache
|
return isLoggedInCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
isLoggedInCache = document.cookie.includes('auth=')
|
isLoggedInCache = document.cookie.includes('auth=');
|
||||||
return isLoggedInCache
|
return isLoggedInCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear cache when auth changes
|
// Clear cache when auth changes
|
||||||
function onAuthChange() {
|
function onAuthChange() {
|
||||||
isLoggedInCache = null
|
isLoggedInCache = null;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -13,16 +13,16 @@ Cache object property lookups in hot paths.
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
for (let i = 0; i < arr.length; i++) {
|
for (let i = 0; i < arr.length; i++) {
|
||||||
process(obj.config.settings.value)
|
process(obj.config.settings.value);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Correct (1 lookup total):**
|
**Correct (1 lookup total):**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const value = obj.config.settings.value
|
const value = obj.config.settings.value;
|
||||||
const len = arr.length
|
const len = arr.length;
|
||||||
for (let i = 0; i < len; i++) {
|
for (let i = 0; i < len; i++) {
|
||||||
process(value)
|
process(value);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ tags: javascript, localStorage, storage, caching, performance
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
function getTheme() {
|
function getTheme() {
|
||||||
return localStorage.getItem('theme') ?? 'light'
|
return localStorage.getItem('theme') ?? 'light';
|
||||||
}
|
}
|
||||||
// Called 10 times = 10 storage reads
|
// Called 10 times = 10 storage reads
|
||||||
```
|
```
|
||||||
@@ -21,18 +21,18 @@ function getTheme() {
|
|||||||
**Correct (Map cache):**
|
**Correct (Map cache):**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const storageCache = new Map<string, string | null>()
|
const storageCache = new Map<string, string | null>();
|
||||||
|
|
||||||
function getLocalStorage(key: string) {
|
function getLocalStorage(key: string) {
|
||||||
if (!storageCache.has(key)) {
|
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) {
|
function setLocalStorage(key: string, value: string) {
|
||||||
localStorage.setItem(key, value)
|
localStorage.setItem(key, value);
|
||||||
storageCache.set(key, value) // keep cache in sync
|
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:**
|
**Cookie caching:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
let cookieCache: Record<string, string> | null = null
|
let cookieCache: Record<string, string> | null = null;
|
||||||
|
|
||||||
function getCookie(name: string) {
|
function getCookie(name: string) {
|
||||||
if (!cookieCache) {
|
if (!cookieCache) {
|
||||||
cookieCache = Object.fromEntries(
|
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
|
```typescript
|
||||||
window.addEventListener('storage', (e) => {
|
window.addEventListener('storage', (e) => {
|
||||||
if (e.key) storageCache.delete(e.key)
|
if (e.key) storageCache.delete(e.key);
|
||||||
})
|
});
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
if (document.visibilityState === 'visible') {
|
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):**
|
**Incorrect (3 iterations):**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const admins = users.filter(u => u.isAdmin)
|
const admins = users.filter((u) => u.isAdmin);
|
||||||
const testers = users.filter(u => u.isTester)
|
const testers = users.filter((u) => u.isTester);
|
||||||
const inactive = users.filter(u => !u.isActive)
|
const inactive = users.filter((u) => !u.isActive);
|
||||||
```
|
```
|
||||||
|
|
||||||
**Correct (1 iteration):**
|
**Correct (1 iteration):**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const admins: User[] = []
|
const admins: User[] = [];
|
||||||
const testers: User[] = []
|
const testers: User[] = [];
|
||||||
const inactive: User[] = []
|
const inactive: User[] = [];
|
||||||
|
|
||||||
for (const user of users) {
|
for (const user of users) {
|
||||||
if (user.isAdmin) admins.push(user)
|
if (user.isAdmin) admins.push(user);
|
||||||
if (user.isTester) testers.push(user)
|
if (user.isTester) testers.push(user);
|
||||||
if (!user.isActive) inactive.push(user)
|
if (!user.isActive) inactive.push(user);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -13,22 +13,22 @@ Return early when result is determined to skip unnecessary processing.
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
function validateUsers(users: User[]) {
|
function validateUsers(users: User[]) {
|
||||||
let hasError = false
|
let hasError = false;
|
||||||
let errorMessage = ''
|
let errorMessage = '';
|
||||||
|
|
||||||
for (const user of users) {
|
for (const user of users) {
|
||||||
if (!user.email) {
|
if (!user.email) {
|
||||||
hasError = true
|
hasError = true;
|
||||||
errorMessage = 'Email required'
|
errorMessage = 'Email required';
|
||||||
}
|
}
|
||||||
if (!user.name) {
|
if (!user.name) {
|
||||||
hasError = true
|
hasError = true;
|
||||||
errorMessage = 'Name required'
|
errorMessage = 'Name required';
|
||||||
}
|
}
|
||||||
// Continues checking all users even after error found
|
// 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[]) {
|
function validateUsers(users: User[]) {
|
||||||
for (const user of users) {
|
for (const user of users) {
|
||||||
if (!user.email) {
|
if (!user.email) {
|
||||||
return { valid: false, error: 'Email required' }
|
return { valid: false, error: 'Email required' };
|
||||||
}
|
}
|
||||||
if (!user.name) {
|
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
|
```typescript
|
||||||
const userNames = users
|
const userNames = users
|
||||||
.map(user => user.isActive ? user.name : null)
|
.map((user) => (user.isActive ? user.name : null))
|
||||||
.filter(Boolean)
|
.filter(Boolean);
|
||||||
```
|
```
|
||||||
|
|
||||||
**Correct (1 iteration, no intermediate array):**
|
**Correct (1 iteration, no intermediate array):**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const userNames = users.flatMap(user =>
|
const userNames = users.flatMap((user) => (user.isActive ? [user.name] : []));
|
||||||
user.isActive ? [user.name] : []
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**More examples:**
|
**More examples:**
|
||||||
@@ -33,28 +31,25 @@ const userNames = users.flatMap(user =>
|
|||||||
// Extract valid emails from responses
|
// Extract valid emails from responses
|
||||||
// Before
|
// Before
|
||||||
const emails = responses
|
const emails = responses
|
||||||
.map(r => r.success ? r.data.email : null)
|
.map((r) => (r.success ? r.data.email : null))
|
||||||
.filter(Boolean)
|
.filter(Boolean);
|
||||||
|
|
||||||
// After
|
// After
|
||||||
const emails = responses.flatMap(r =>
|
const emails = responses.flatMap((r) => (r.success ? [r.data.email] : []));
|
||||||
r.success ? [r.data.email] : []
|
|
||||||
)
|
|
||||||
|
|
||||||
// Parse and filter valid numbers
|
// Parse and filter valid numbers
|
||||||
// Before
|
// Before
|
||||||
const numbers = strings
|
const numbers = strings.map((s) => parseInt(s, 10)).filter((n) => !isNaN(n));
|
||||||
.map(s => parseInt(s, 10))
|
|
||||||
.filter(n => !isNaN(n))
|
|
||||||
|
|
||||||
// After
|
// After
|
||||||
const numbers = strings.flatMap(s => {
|
const numbers = strings.flatMap((s) => {
|
||||||
const n = parseInt(s, 10)
|
const n = parseInt(s, 10);
|
||||||
return isNaN(n) ? [] : [n]
|
return isNaN(n) ? [] : [n];
|
||||||
})
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
**When to use:**
|
**When to use:**
|
||||||
|
|
||||||
- Transforming items while filtering some out
|
- Transforming items while filtering some out
|
||||||
- Conditional mapping where some inputs produce no output
|
- Conditional mapping where some inputs produce no output
|
||||||
- Parsing/validating where invalid inputs should be skipped
|
- Parsing/validating where invalid inputs should be skipped
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ function Highlighter({ text, query }: Props) {
|
|||||||
Global regex (`/g`) has mutable `lastIndex` state:
|
Global regex (`/g`) has mutable `lastIndex` state:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const regex = /foo/g
|
const regex = /foo/g;
|
||||||
regex.test('foo') // true, lastIndex = 3
|
regex.test('foo'); // true, lastIndex = 3
|
||||||
regex.test('foo') // false, lastIndex = 0
|
regex.test('foo'); // false, lastIndex = 0
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ Multiple `.find()` calls by the same key should use a Map.
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
function processOrders(orders: Order[], users: User[]) {
|
function processOrders(orders: Order[], users: User[]) {
|
||||||
return orders.map(order => ({
|
return orders.map((order) => ({
|
||||||
...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
|
```typescript
|
||||||
function processOrders(orders: Order[], users: User[]) {
|
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,
|
...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
|
```typescript
|
||||||
function hasChanges(current: string[], original: string[]) {
|
function hasChanges(current: string[], original: string[]) {
|
||||||
// Always sorts and joins, even when lengths differ
|
// 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[]) {
|
function hasChanges(current: string[], original: string[]) {
|
||||||
// Early return if lengths differ
|
// Early return if lengths differ
|
||||||
if (current.length !== original.length) {
|
if (current.length !== original.length) {
|
||||||
return true
|
return true;
|
||||||
}
|
}
|
||||||
// Only sort when lengths match
|
// Only sort when lengths match
|
||||||
const currentSorted = current.toSorted()
|
const currentSorted = current.toSorted();
|
||||||
const originalSorted = original.toSorted()
|
const originalSorted = original.toSorted();
|
||||||
for (let i = 0; i < currentSorted.length; i++) {
|
for (let i = 0; i < currentSorted.length; i++) {
|
||||||
if (currentSorted[i] !== originalSorted[i]) {
|
if (currentSorted[i] !== originalSorted[i]) {
|
||||||
return true
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
This new approach is more efficient because:
|
This new approach is more efficient because:
|
||||||
|
|
||||||
- It avoids the overhead of sorting and joining the arrays when lengths differ
|
- 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 consuming memory for the joined strings (especially important for large arrays)
|
||||||
- It avoids mutating the original 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
|
```typescript
|
||||||
interface Project {
|
interface Project {
|
||||||
id: string
|
id: string;
|
||||||
name: string
|
name: string;
|
||||||
updatedAt: number
|
updatedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLatestProject(projects: Project[]) {
|
function getLatestProject(projects: Project[]) {
|
||||||
const sorted = [...projects].sort((a, b) => b.updatedAt - a.updatedAt)
|
const sorted = [...projects].sort((a, b) => b.updatedAt - a.updatedAt);
|
||||||
return sorted[0]
|
return sorted[0];
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -30,8 +30,8 @@ Sorts the entire array just to find the maximum value.
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
function getOldestAndNewest(projects: Project[]) {
|
function getOldestAndNewest(projects: Project[]) {
|
||||||
const sorted = [...projects].sort((a, b) => a.updatedAt - b.updatedAt)
|
const sorted = [...projects].sort((a, b) => a.updatedAt - b.updatedAt);
|
||||||
return { oldest: sorted[0], newest: sorted[sorted.length - 1] }
|
return { oldest: sorted[0], newest: sorted[sorted.length - 1] };
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -41,31 +41,31 @@ Still sorts unnecessarily when only min/max are needed.
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
function getLatestProject(projects: Project[]) {
|
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++) {
|
for (let i = 1; i < projects.length; i++) {
|
||||||
if (projects[i].updatedAt > latest.updatedAt) {
|
if (projects[i].updatedAt > latest.updatedAt) {
|
||||||
latest = projects[i]
|
latest = projects[i];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return latest
|
return latest;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOldestAndNewest(projects: Project[]) {
|
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 oldest = projects[0];
|
||||||
let newest = projects[0]
|
let newest = projects[0];
|
||||||
|
|
||||||
for (let i = 1; i < projects.length; i++) {
|
for (let i = 1; i < projects.length; i++) {
|
||||||
if (projects[i].updatedAt < oldest.updatedAt) oldest = projects[i]
|
if (projects[i].updatedAt < oldest.updatedAt) oldest = projects[i];
|
||||||
if (projects[i].updatedAt > newest.updatedAt) newest = 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):**
|
**Alternative (Math.min/Math.max for small arrays):**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const numbers = [5, 2, 8, 1, 9]
|
const numbers = [5, 2, 8, 1, 9];
|
||||||
const min = Math.min(...numbers)
|
const min = Math.min(...numbers);
|
||||||
const max = Math.max(...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.
|
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
|
```typescript
|
||||||
function handleSearch(query: string) {
|
function handleSearch(query: string) {
|
||||||
const results = searchItems(query)
|
const results = searchItems(query);
|
||||||
setResults(results)
|
setResults(results);
|
||||||
|
|
||||||
// These block the main thread immediately
|
// These block the main thread immediately
|
||||||
analytics.track('search', { query })
|
analytics.track('search', { query });
|
||||||
saveToRecentSearches(query)
|
saveToRecentSearches(query);
|
||||||
prefetchTopResults(results.slice(0, 3))
|
prefetchTopResults(results.slice(0, 3));
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -29,21 +29,21 @@ function handleSearch(query: string) {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
function handleSearch(query: string) {
|
function handleSearch(query: string) {
|
||||||
const results = searchItems(query)
|
const results = searchItems(query);
|
||||||
setResults(results)
|
setResults(results);
|
||||||
|
|
||||||
// Defer non-critical work to idle periods
|
// Defer non-critical work to idle periods
|
||||||
requestIdleCallback(() => {
|
requestIdleCallback(() => {
|
||||||
analytics.track('search', { query })
|
analytics.track('search', { query });
|
||||||
})
|
});
|
||||||
|
|
||||||
requestIdleCallback(() => {
|
requestIdleCallback(() => {
|
||||||
saveToRecentSearches(query)
|
saveToRecentSearches(query);
|
||||||
})
|
});
|
||||||
|
|
||||||
requestIdleCallback(() => {
|
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
|
// Ensure analytics fires within 2 seconds even if browser stays busy
|
||||||
requestIdleCallback(
|
requestIdleCallback(
|
||||||
() => analytics.track('page_view', { path: location.pathname }),
|
() => analytics.track('page_view', { path: location.pathname }),
|
||||||
{ timeout: 2000 }
|
{ timeout: 2000 },
|
||||||
)
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
**Chunking large tasks:**
|
**Chunking large tasks:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
function processLargeDataset(items: Item[]) {
|
function processLargeDataset(items: Item[]) {
|
||||||
let index = 0
|
let index = 0;
|
||||||
|
|
||||||
function processChunk(deadline: IdleDeadline) {
|
function processChunk(deadline: IdleDeadline) {
|
||||||
// Process items while we have idle time (aim for <50ms chunks)
|
// Process items while we have idle time (aim for <50ms chunks)
|
||||||
while (index < items.length && deadline.timeRemaining() > 0) {
|
while (index < items.length && deadline.timeRemaining() > 0) {
|
||||||
processItem(items[index])
|
processItem(items[index]);
|
||||||
index++
|
index++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schedule next chunk if more items remain
|
// Schedule next chunk if more items remain
|
||||||
if (index < items.length) {
|
if (index < items.length) {
|
||||||
requestIdleCallback(processChunk)
|
requestIdleCallback(processChunk);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
requestIdleCallback(processChunk)
|
requestIdleCallback(processChunk);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**With fallback for unsupported browsers:**
|
**With fallback for unsupported browsers:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const scheduleIdleWork = window.requestIdleCallback ?? ((cb: () => void) => setTimeout(cb, 1))
|
const scheduleIdleWork =
|
||||||
|
window.requestIdleCallback ?? ((cb: () => void) => setTimeout(cb, 1));
|
||||||
|
|
||||||
scheduleIdleWork(() => {
|
scheduleIdleWork(() => {
|
||||||
// Non-critical work
|
// Non-critical work
|
||||||
})
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
**When to use:**
|
**When to use:**
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ function UserList({ users }: { users: User[] }) {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Fallback for older browsers
|
// 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:**
|
**Other immutable array methods:**
|
||||||
|
|||||||
@@ -12,14 +12,14 @@ Use React's `<Activity>` to preserve state/DOM for expensive components that fre
|
|||||||
**Usage:**
|
**Usage:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Activity } from 'react'
|
import { Activity } from 'react';
|
||||||
|
|
||||||
function Dropdown({ isOpen }: Props) {
|
function Dropdown({ isOpen }: Props) {
|
||||||
return (
|
return (
|
||||||
<Activity mode={isOpen ? 'visible' : 'hidden'}>
|
<Activity mode={isOpen ? 'visible' : 'hidden'}>
|
||||||
<ExpensiveMenu />
|
<ExpensiveMenu />
|
||||||
</Activity>
|
</Activity>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -14,15 +14,10 @@ Many browsers don't have hardware acceleration for CSS3 animations on SVG elemen
|
|||||||
```tsx
|
```tsx
|
||||||
function LoadingSpinner() {
|
function LoadingSpinner() {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg className="animate-spin" width="24" height="24" viewBox="0 0 24 24">
|
||||||
className="animate-spin"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="10" stroke="currentColor" />
|
<circle cx="12" cy="12" r="10" stroke="currentColor" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -32,15 +27,11 @@ function LoadingSpinner() {
|
|||||||
function LoadingSpinner() {
|
function LoadingSpinner() {
|
||||||
return (
|
return (
|
||||||
<div className="animate-spin">
|
<div className="animate-spin">
|
||||||
<svg
|
<svg width="24" height="24" viewBox="0 0 24 24">
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="10" stroke="currentColor" />
|
<circle cx="12" cy="12" r="10" stroke="currentColor" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,7 @@ Use explicit ternary operators (`? :`) instead of `&&` for conditional rendering
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
function Badge({ count }: { count: number }) {
|
function Badge({ count }: { count: number }) {
|
||||||
return (
|
return <div>{count && <span className="badge">{count}</span>}</div>;
|
||||||
<div>
|
|
||||||
{count && <span className="badge">{count}</span>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// When count = 0, renders: <div>0</div>
|
// When count = 0, renders: <div>0</div>
|
||||||
@@ -28,11 +24,7 @@ function Badge({ count }: { count: number }) {
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
function Badge({ count }: { count: number }) {
|
function Badge({ count }: { count: number }) {
|
||||||
return (
|
return <div>{count > 0 ? <span className="badge">{count}</span> : null}</div>;
|
||||||
<div>
|
|
||||||
{count > 0 ? <span className="badge">{count}</span> : null}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// When count = 0, renders: <div></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[] }) {
|
function MessageList({ messages }: { messages: Message[] }) {
|
||||||
return (
|
return (
|
||||||
<div className="overflow-y-auto h-screen">
|
<div className="overflow-y-auto h-screen">
|
||||||
{messages.map(msg => (
|
{messages.map((msg) => (
|
||||||
<div key={msg.id} className="message-item">
|
<div key={msg.id} className="message-item">
|
||||||
<Avatar user={msg.author} />
|
<Avatar user={msg.author} />
|
||||||
<div>{msg.content}</div>
|
<div>{msg.content}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -13,31 +13,21 @@ Extract static JSX outside components to avoid re-creation.
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
function LoadingSkeleton() {
|
function LoadingSkeleton() {
|
||||||
return <div className="animate-pulse h-20 bg-gray-200" />
|
return <div className="animate-pulse h-20 bg-gray-200" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Container() {
|
function Container() {
|
||||||
return (
|
return <div>{loading && <LoadingSkeleton />}</div>;
|
||||||
<div>
|
|
||||||
{loading && <LoadingSkeleton />}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Correct (reuses same element):**
|
**Correct (reuses same element):**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const loadingSkeleton = (
|
const loadingSkeleton = <div className="animate-pulse h-20 bg-gray-200" />;
|
||||||
<div className="animate-pulse h-20 bg-gray-200" />
|
|
||||||
)
|
|
||||||
|
|
||||||
function Container() {
|
function Container() {
|
||||||
return (
|
return <div>{loading && loadingSkeleton}</div>;
|
||||||
<div>
|
|
||||||
{loading && loadingSkeleton}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -14,13 +14,9 @@ When rendering content that depends on client-side storage (localStorage, cookie
|
|||||||
```tsx
|
```tsx
|
||||||
function ThemeWrapper({ children }: { children: ReactNode }) {
|
function ThemeWrapper({ children }: { children: ReactNode }) {
|
||||||
// localStorage is not available on server - throws error
|
// localStorage is not available on server - throws error
|
||||||
const theme = localStorage.getItem('theme') || 'light'
|
const theme = localStorage.getItem('theme') || 'light';
|
||||||
|
|
||||||
return (
|
return <div className={theme}>{children}</div>;
|
||||||
<div className={theme}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -30,21 +26,17 @@ Server-side rendering will fail because `localStorage` is undefined.
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
function ThemeWrapper({ children }: { children: ReactNode }) {
|
function ThemeWrapper({ children }: { children: ReactNode }) {
|
||||||
const [theme, setTheme] = useState('light')
|
const [theme, setTheme] = useState('light');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Runs after hydration - causes visible flash
|
// Runs after hydration - causes visible flash
|
||||||
const stored = localStorage.getItem('theme')
|
const stored = localStorage.getItem('theme');
|
||||||
if (stored) {
|
if (stored) {
|
||||||
setTheme(stored)
|
setTheme(stored);
|
||||||
}
|
}
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
return (
|
return <div className={theme}>{children}</div>;
|
||||||
<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 }) {
|
function ThemeWrapper({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div id="theme-wrapper">
|
<div id="theme-wrapper">{children}</div>
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
<script
|
<script
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: `
|
__html: `
|
||||||
@@ -73,7 +63,7 @@ function ThemeWrapper({ children }: { children: ReactNode }) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+3
-7
@@ -7,13 +7,13 @@ tags: rendering, hydration, ssr, nextjs
|
|||||||
|
|
||||||
## Suppress Expected Hydration Mismatches
|
## 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. Don’t 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. Don’t overuse it.
|
||||||
|
|
||||||
**Incorrect (known mismatch warnings):**
|
**Incorrect (known mismatch warnings):**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
function Timestamp() {
|
function Timestamp() {
|
||||||
return <span>{new Date().toLocaleString()}</span>
|
return <span>{new Date().toLocaleString()}</span>;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -21,10 +21,6 @@ function Timestamp() {
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
function Timestamp() {
|
function Timestamp() {
|
||||||
return (
|
return <span suppressHydrationWarning>{new Date().toLocaleString()}</span>;
|
||||||
<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):**
|
**Example (preconnect to third-party APIs):**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { preconnect, prefetchDNS } from 'react-dom'
|
import { preconnect, prefetchDNS } from 'react-dom';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
prefetchDNS('https://analytics.example.com')
|
prefetchDNS('https://analytics.example.com');
|
||||||
preconnect('https://api.example.com')
|
preconnect('https://api.example.com');
|
||||||
|
|
||||||
return <main>{/* content */}</main>
|
return <main>{/* content */}</main>;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Example (preload critical fonts and styles):**
|
**Example (preload critical fonts and styles):**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { preload, preinit } from 'react-dom'
|
import { preload, preinit } from 'react-dom';
|
||||||
|
|
||||||
export default function RootLayout({ children }) {
|
export default function RootLayout({ children }) {
|
||||||
// Preload font file
|
// 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
|
// Fetch and apply critical stylesheet immediately
|
||||||
preinit('/styles/critical.css', { as: 'style' })
|
preinit('/styles/critical.css', { as: 'style' });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html>
|
<html>
|
||||||
<body>{children}</body>
|
<body>{children}</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Example (preload modules for code-split routes):**
|
**Example (preload modules for code-split routes):**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { preloadModule, preinitModule } from 'react-dom'
|
import { preloadModule, preinitModule } from 'react-dom';
|
||||||
|
|
||||||
function Navigation() {
|
function Navigation() {
|
||||||
const preloadDashboard = () => {
|
const preloadDashboard = () => {
|
||||||
preloadModule('/dashboard.js', { as: 'script' })
|
preloadModule('/dashboard.js', { as: 'script' });
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav>
|
<nav>
|
||||||
@@ -67,19 +71,19 @@ function Navigation() {
|
|||||||
Dashboard
|
Dashboard
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**When to use each:**
|
**When to use each:**
|
||||||
|
|
||||||
| API | Use case |
|
| API | Use case |
|
||||||
|-----|----------|
|
| --------------- | ------------------------------------------- |
|
||||||
| `prefetchDNS` | Third-party domains you'll connect to later |
|
| `prefetchDNS` | Third-party domains you'll connect to later |
|
||||||
| `preconnect` | APIs or CDNs you'll fetch from immediately |
|
| `preconnect` | APIs or CDNs you'll fetch from immediately |
|
||||||
| `preload` | Critical resources needed for current page |
|
| `preload` | Critical resources needed for current page |
|
||||||
| `preloadModule` | JS modules for likely next navigation |
|
| `preloadModule` | JS modules for likely next navigation |
|
||||||
| `preinit` | Stylesheets/scripts that must execute early |
|
| `preinit` | Stylesheets/scripts that must execute early |
|
||||||
| `preinitModule` | ES modules that must execute early |
|
| `preinitModule` | ES modules that must execute early |
|
||||||
|
|
||||||
Reference: [React DOM Resource Preloading APIs](https://react.dev/reference/react-dom#resource-preloading-apis)
|
Reference: [React DOM Resource Preloading APIs](https://react.dev/reference/react-dom#resource-preloading-apis)
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export default function Document() {
|
|||||||
</head>
|
</head>
|
||||||
<body>{/* content */}</body>
|
<body>{/* content */}</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -46,22 +46,25 @@ export default function Document() {
|
|||||||
</head>
|
</head>
|
||||||
<body>{/* content */}</body>
|
<body>{/* content */}</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note:** In Next.js, prefer the `next/script` component with `strategy` prop instead of raw script tags:
|
**Note:** In Next.js, prefer the `next/script` component with `strategy` prop instead of raw script tags:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import Script from 'next/script'
|
import Script from 'next/script';
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
return (
|
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" />
|
<Script src="/scripts/utils.js" strategy="beforeInteractive" />
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+20
-20
@@ -13,17 +13,17 @@ Use `useTransition` instead of manual `useState` for loading states. This provid
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
function SearchResults() {
|
function SearchResults() {
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('');
|
||||||
const [results, setResults] = useState([])
|
const [results, setResults] = useState([]);
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const handleSearch = async (value: string) => {
|
const handleSearch = async (value: string) => {
|
||||||
setIsLoading(true)
|
setIsLoading(true);
|
||||||
setQuery(value)
|
setQuery(value);
|
||||||
const data = await fetchResults(value)
|
const data = await fetchResults(value);
|
||||||
setResults(data)
|
setResults(data);
|
||||||
setIsLoading(false)
|
setIsLoading(false);
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -31,29 +31,29 @@ function SearchResults() {
|
|||||||
{isLoading && <Spinner />}
|
{isLoading && <Spinner />}
|
||||||
<ResultsList results={results} />
|
<ResultsList results={results} />
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Correct (useTransition with built-in pending state):**
|
**Correct (useTransition with built-in pending state):**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { useTransition, useState } from 'react'
|
import { useTransition, useState } from 'react';
|
||||||
|
|
||||||
function SearchResults() {
|
function SearchResults() {
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('');
|
||||||
const [results, setResults] = useState([])
|
const [results, setResults] = useState([]);
|
||||||
const [isPending, startTransition] = useTransition()
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const handleSearch = (value: string) => {
|
const handleSearch = (value: string) => {
|
||||||
setQuery(value) // Update input immediately
|
setQuery(value); // Update input immediately
|
||||||
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
// Fetch and update results
|
// Fetch and update results
|
||||||
const data = await fetchResults(value)
|
const data = await fetchResults(value);
|
||||||
setResults(data)
|
setResults(data);
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -61,7 +61,7 @@ function SearchResults() {
|
|||||||
{isPending && <Spinner />}
|
{isPending && <Spinner />}
|
||||||
<ResultsList results={results} />
|
<ResultsList results={results} />
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -13,14 +13,14 @@ Don't subscribe to dynamic state (searchParams, localStorage) if you only read i
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
function ShareButton({ chatId }: { chatId: string }) {
|
function ShareButton({ chatId }: { chatId: string }) {
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
const handleShare = () => {
|
const handleShare = () => {
|
||||||
const ref = searchParams.get('ref')
|
const ref = searchParams.get('ref');
|
||||||
shareChat(chatId, { 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
|
```tsx
|
||||||
function ShareButton({ chatId }: { chatId: string }) {
|
function ShareButton({ chatId }: { chatId: string }) {
|
||||||
const handleShare = () => {
|
const handleShare = () => {
|
||||||
const params = new URLSearchParams(window.location.search)
|
const params = new URLSearchParams(window.location.search);
|
||||||
const ref = params.get('ref')
|
const ref = params.get('ref');
|
||||||
shareChat(chatId, { 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
|
```tsx
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log(user.id)
|
console.log(user.id);
|
||||||
}, [user])
|
}, [user]);
|
||||||
```
|
```
|
||||||
|
|
||||||
**Correct (re-runs only when id changes):**
|
**Correct (re-runs only when id changes):**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log(user.id)
|
console.log(user.id);
|
||||||
}, [user.id])
|
}, [user.id]);
|
||||||
```
|
```
|
||||||
|
|
||||||
**For derived state, compute outside effect:**
|
**For derived state, compute outside effect:**
|
||||||
@@ -31,15 +31,15 @@ useEffect(() => {
|
|||||||
// Incorrect: runs on width=767, 766, 765...
|
// Incorrect: runs on width=767, 766, 765...
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (width < 768) {
|
if (width < 768) {
|
||||||
enableMobileMode()
|
enableMobileMode();
|
||||||
}
|
}
|
||||||
}, [width])
|
}, [width]);
|
||||||
|
|
||||||
// Correct: runs only on boolean transition
|
// Correct: runs only on boolean transition
|
||||||
const isMobile = width < 768
|
const isMobile = width < 768;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
enableMobileMode()
|
enableMobileMode();
|
||||||
}
|
}
|
||||||
}, [isMobile])
|
}, [isMobile]);
|
||||||
```
|
```
|
||||||
|
|||||||
+10
-10
@@ -13,15 +13,15 @@ If a value can be computed from current props/state, do not store it in state or
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
function Form() {
|
function Form() {
|
||||||
const [firstName, setFirstName] = useState('First')
|
const [firstName, setFirstName] = useState('First');
|
||||||
const [lastName, setLastName] = useState('Last')
|
const [lastName, setLastName] = useState('Last');
|
||||||
const [fullName, setFullName] = useState('')
|
const [fullName, setFullName] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFullName(firstName + ' ' + lastName)
|
setFullName(firstName + ' ' + lastName);
|
||||||
}, [firstName, lastName])
|
}, [firstName, lastName]);
|
||||||
|
|
||||||
return <p>{fullName}</p>
|
return <p>{fullName}</p>;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -29,11 +29,11 @@ function Form() {
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
function Form() {
|
function Form() {
|
||||||
const [firstName, setFirstName] = useState('First')
|
const [firstName, setFirstName] = useState('First');
|
||||||
const [lastName, setLastName] = useState('Last')
|
const [lastName, setLastName] = useState('Last');
|
||||||
const fullName = firstName + ' ' + lastName
|
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
|
```tsx
|
||||||
function Sidebar() {
|
function Sidebar() {
|
||||||
const width = useWindowWidth() // updates continuously
|
const width = useWindowWidth(); // updates continuously
|
||||||
const isMobile = width < 768
|
const isMobile = width < 768;
|
||||||
return <nav className={isMobile ? 'mobile' : 'desktop'} />
|
return <nav className={isMobile ? 'mobile' : 'desktop'} />;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ function Sidebar() {
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
function Sidebar() {
|
function Sidebar() {
|
||||||
const isMobile = useMediaQuery('(max-width: 767px)')
|
const isMobile = useMediaQuery('(max-width: 767px)');
|
||||||
return <nav className={isMobile ? 'mobile' : 'desktop'} />
|
return <nav className={isMobile ? 'mobile' : 'desktop'} />;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -13,19 +13,22 @@ When updating state based on the current state value, use the functional update
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
function TodoList() {
|
function TodoList() {
|
||||||
const [items, setItems] = useState(initialItems)
|
const [items, setItems] = useState(initialItems);
|
||||||
|
|
||||||
// Callback must depend on items, recreated on every items change
|
// Callback must depend on items, recreated on every items change
|
||||||
const addItems = useCallback((newItems: Item[]) => {
|
const addItems = useCallback(
|
||||||
setItems([...items, ...newItems])
|
(newItems: Item[]) => {
|
||||||
}, [items]) // ❌ items dependency causes recreations
|
setItems([...items, ...newItems]);
|
||||||
|
},
|
||||||
|
[items],
|
||||||
|
); // ❌ items dependency causes recreations
|
||||||
|
|
||||||
// Risk of stale closure if dependency is forgotten
|
// Risk of stale closure if dependency is forgotten
|
||||||
const removeItem = useCallback((id: string) => {
|
const removeItem = useCallback((id: string) => {
|
||||||
setItems(items.filter(item => item.id !== id))
|
setItems(items.filter((item) => item.id !== id));
|
||||||
}, []) // ❌ Missing items dependency - will use stale items!
|
}, []); // ❌ 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
|
```tsx
|
||||||
function TodoList() {
|
function TodoList() {
|
||||||
const [items, setItems] = useState(initialItems)
|
const [items, setItems] = useState(initialItems);
|
||||||
|
|
||||||
// Stable callback, never recreated
|
// Stable callback, never recreated
|
||||||
const addItems = useCallback((newItems: Item[]) => {
|
const addItems = useCallback((newItems: Item[]) => {
|
||||||
setItems(curr => [...curr, ...newItems])
|
setItems((curr) => [...curr, ...newItems]);
|
||||||
}, []) // ✅ No dependencies needed
|
}, []); // ✅ No dependencies needed
|
||||||
|
|
||||||
// Always uses latest state, no stale closure risk
|
// Always uses latest state, no stale closure risk
|
||||||
const removeItem = useCallback((id: string) => {
|
const removeItem = useCallback((id: string) => {
|
||||||
setItems(curr => curr.filter(item => item.id !== id))
|
setItems((curr) => curr.filter((item) => item.id !== id));
|
||||||
}, []) // ✅ Safe and stable
|
}, []); // ✅ 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
|
```tsx
|
||||||
function FilteredList({ items }: { items: Item[] }) {
|
function FilteredList({ items }: { items: Item[] }) {
|
||||||
// buildSearchIndex() runs on EVERY render, even after initialization
|
// buildSearchIndex() runs on EVERY render, even after initialization
|
||||||
const [searchIndex, setSearchIndex] = useState(buildSearchIndex(items))
|
const [searchIndex, setSearchIndex] = useState(buildSearchIndex(items));
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('');
|
||||||
|
|
||||||
// When query changes, buildSearchIndex runs again unnecessarily
|
// When query changes, buildSearchIndex runs again unnecessarily
|
||||||
return <SearchResults index={searchIndex} query={query} />
|
return <SearchResults index={searchIndex} query={query} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function UserProfile() {
|
function UserProfile() {
|
||||||
// JSON.parse runs on every render
|
// JSON.parse runs on every render
|
||||||
const [settings, setSettings] = useState(
|
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
|
```tsx
|
||||||
function FilteredList({ items }: { items: Item[] }) {
|
function FilteredList({ items }: { items: Item[] }) {
|
||||||
// buildSearchIndex() runs ONLY on initial render
|
// buildSearchIndex() runs ONLY on initial render
|
||||||
const [searchIndex, setSearchIndex] = useState(() => buildSearchIndex(items))
|
const [searchIndex, setSearchIndex] = useState(() => buildSearchIndex(items));
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('');
|
||||||
|
|
||||||
return <SearchResults index={searchIndex} query={query} />
|
return <SearchResults index={searchIndex} query={query} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function UserProfile() {
|
function UserProfile() {
|
||||||
// JSON.parse runs only on initial render
|
// JSON.parse runs only on initial render
|
||||||
const [settings, setSettings] = useState(() => {
|
const [settings, setSettings] = useState(() => {
|
||||||
const stored = localStorage.getItem('settings')
|
const stored = localStorage.getItem('settings');
|
||||||
return stored ? JSON.parse(stored) : {}
|
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
|
title: Extract Default Non-primitive Parameter Value from Memoized Component to Constant
|
||||||
impact: MEDIUM
|
impact: MEDIUM
|
||||||
impactDescription: restores memoization by using a constant for default value
|
impactDescription: restores memoization by using a constant for default value
|
||||||
tags: rerender, memo, optimization
|
tags: rerender, memo, optimization
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Extract Default Non-primitive Parameter Value from Memoized Component to Constant
|
## 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
|
```tsx
|
||||||
function Profile({ user, loading }: Props) {
|
function Profile({ user, loading }: Props) {
|
||||||
const avatar = useMemo(() => {
|
const avatar = useMemo(() => {
|
||||||
const id = computeAvatarId(user)
|
const id = computeAvatarId(user);
|
||||||
return <Avatar id={id} />
|
return <Avatar id={id} />;
|
||||||
}, [user])
|
}, [user]);
|
||||||
|
|
||||||
if (loading) return <Skeleton />
|
if (loading) return <Skeleton />;
|
||||||
return <div>{avatar}</div>
|
return <div>{avatar}</div>;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -27,17 +27,17 @@ function Profile({ user, loading }: Props) {
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const UserAvatar = memo(function UserAvatar({ user }: { user: User }) {
|
const UserAvatar = memo(function UserAvatar({ user }: { user: User }) {
|
||||||
const id = useMemo(() => computeAvatarId(user), [user])
|
const id = useMemo(() => computeAvatarId(user), [user]);
|
||||||
return <Avatar id={id} />
|
return <Avatar id={id} />;
|
||||||
})
|
});
|
||||||
|
|
||||||
function Profile({ user, loading }: Props) {
|
function Profile({ user, loading }: Props) {
|
||||||
if (loading) return <Skeleton />
|
if (loading) return <Skeleton />;
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<UserAvatar user={user} />
|
<UserAvatar user={user} />
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -13,17 +13,17 @@ If a side effect is triggered by a specific user action (submit, click, drag), r
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
function Form() {
|
function Form() {
|
||||||
const [submitted, setSubmitted] = useState(false)
|
const [submitted, setSubmitted] = useState(false);
|
||||||
const theme = useContext(ThemeContext)
|
const theme = useContext(ThemeContext);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (submitted) {
|
if (submitted) {
|
||||||
post('/api/register')
|
post('/api/register');
|
||||||
showToast('Registered', theme)
|
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
|
```tsx
|
||||||
function Form() {
|
function Form() {
|
||||||
const theme = useContext(ThemeContext)
|
const theme = useContext(ThemeContext);
|
||||||
|
|
||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
post('/api/register')
|
post('/api/register');
|
||||||
showToast('Registered', theme)
|
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}
|
src={user.avatarUrl}
|
||||||
className={theme === 'dark' ? 'avatar-dark' : 'avatar-light'}
|
className={theme === 'dark' ? 'avatar-dark' : 'avatar-light'}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
|
|
||||||
// Defined inside to access `user` - BAD
|
// Defined inside to access `user` - BAD
|
||||||
const Stats = () => (
|
const Stats = () => (
|
||||||
@@ -31,14 +31,14 @@ function UserProfile({ user, theme }) {
|
|||||||
<span>{user.followers} followers</span>
|
<span>{user.followers} followers</span>
|
||||||
<span>{user.posts} posts</span>
|
<span>{user.posts} posts</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Avatar />
|
<Avatar />
|
||||||
<Stats />
|
<Stats />
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ function Avatar({ src, theme }: { src: string; theme: string }) {
|
|||||||
src={src}
|
src={src}
|
||||||
className={theme === 'dark' ? 'avatar-dark' : 'avatar-light'}
|
className={theme === 'dark' ? 'avatar-dark' : 'avatar-light'}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Stats({ followers, posts }: { followers: number; posts: number }) {
|
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>{followers} followers</span>
|
||||||
<span>{posts} posts</span>
|
<span>{posts} posts</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function UserProfile({ user, theme }) {
|
function UserProfile({ user, theme }) {
|
||||||
@@ -71,11 +71,12 @@ function UserProfile({ user, theme }) {
|
|||||||
<Avatar src={user.avatarUrl} theme={theme} />
|
<Avatar src={user.avatarUrl} theme={theme} />
|
||||||
<Stats followers={user.followers} posts={user.posts} />
|
<Stats followers={user.followers} posts={user.posts} />
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Symptoms of this bug:**
|
**Symptoms of this bug:**
|
||||||
|
|
||||||
- Input fields lose focus on every keystroke
|
- Input fields lose focus on every keystroke
|
||||||
- Animations restart unexpectedly
|
- Animations restart unexpectedly
|
||||||
- `useEffect` cleanup/setup runs on every parent render
|
- `useEffect` cleanup/setup runs on every parent render
|
||||||
|
|||||||
+5
-5
@@ -15,10 +15,10 @@ Calling `useMemo` and comparing hook dependencies may consume more resources tha
|
|||||||
```tsx
|
```tsx
|
||||||
function Header({ user, notifications }: Props) {
|
function Header({ user, notifications }: Props) {
|
||||||
const isLoading = useMemo(() => {
|
const isLoading = useMemo(() => {
|
||||||
return user.isLoading || notifications.isLoading
|
return user.isLoading || notifications.isLoading;
|
||||||
}, [user.isLoading, notifications.isLoading])
|
}, [user.isLoading, notifications.isLoading]);
|
||||||
|
|
||||||
if (isLoading) return <Skeleton />
|
if (isLoading) return <Skeleton />;
|
||||||
// return some markup
|
// return some markup
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -27,9 +27,9 @@ function Header({ user, notifications }: Props) {
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
function Header({ user, notifications }: Props) {
|
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
|
// return some markup
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -13,12 +13,12 @@ When a hook contains multiple independent tasks with different dependencies, spl
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const sortedProducts = useMemo(() => {
|
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) =>
|
const sorted = filtered.toSorted((a, b) =>
|
||||||
sortOrder === "asc" ? a.price - b.price : b.price - a.price
|
sortOrder === 'asc' ? a.price - b.price : b.price - a.price,
|
||||||
)
|
);
|
||||||
return sorted
|
return sorted;
|
||||||
}, [products, category, sortOrder])
|
}, [products, category, sortOrder]);
|
||||||
```
|
```
|
||||||
|
|
||||||
**Correct (filtering only recomputes when products or category change):**
|
**Correct (filtering only recomputes when products or category change):**
|
||||||
@@ -26,16 +26,16 @@ const sortedProducts = useMemo(() => {
|
|||||||
```tsx
|
```tsx
|
||||||
const filteredProducts = useMemo(
|
const filteredProducts = useMemo(
|
||||||
() => products.filter((p) => p.category === category),
|
() => products.filter((p) => p.category === category),
|
||||||
[products, category]
|
[products, category],
|
||||||
)
|
);
|
||||||
|
|
||||||
const sortedProducts = useMemo(
|
const sortedProducts = useMemo(
|
||||||
() =>
|
() =>
|
||||||
filteredProducts.toSorted((a, b) =>
|
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:
|
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
|
```tsx
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
analytics.trackPageView(pathname)
|
analytics.trackPageView(pathname);
|
||||||
document.title = `${pageTitle} | My App`
|
document.title = `${pageTitle} | My App`;
|
||||||
}, [pathname, pageTitle])
|
}, [pathname, pageTitle]);
|
||||||
```
|
```
|
||||||
|
|
||||||
**Correct (effects run independently):**
|
**Correct (effects run independently):**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
analytics.trackPageView(pathname)
|
analytics.trackPageView(pathname);
|
||||||
}, [pathname])
|
}, [pathname]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = `${pageTitle} | My App`
|
document.title = `${pageTitle} | My App`;
|
||||||
}, [pageTitle])
|
}, [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.
|
**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
|
```tsx
|
||||||
function ScrollTracker() {
|
function ScrollTracker() {
|
||||||
const [scrollY, setScrollY] = useState(0)
|
const [scrollY, setScrollY] = useState(0);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = () => setScrollY(window.scrollY)
|
const handler = () => setScrollY(window.scrollY);
|
||||||
window.addEventListener('scroll', handler, { passive: true })
|
window.addEventListener('scroll', handler, { passive: true });
|
||||||
return () => window.removeEventListener('scroll', handler)
|
return () => window.removeEventListener('scroll', handler);
|
||||||
}, [])
|
}, []);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Correct (non-blocking updates):**
|
**Correct (non-blocking updates):**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { startTransition } from 'react'
|
import { startTransition } from 'react';
|
||||||
|
|
||||||
function ScrollTracker() {
|
function ScrollTracker() {
|
||||||
const [scrollY, setScrollY] = useState(0)
|
const [scrollY, setScrollY] = useState(0);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = () => {
|
const handler = () => {
|
||||||
startTransition(() => setScrollY(window.scrollY))
|
startTransition(() => setScrollY(window.scrollY));
|
||||||
}
|
};
|
||||||
window.addEventListener('scroll', handler, { passive: true })
|
window.addEventListener('scroll', handler, { passive: true });
|
||||||
return () => window.removeEventListener('scroll', handler)
|
return () => window.removeEventListener('scroll', handler);
|
||||||
}, [])
|
}, []);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -13,15 +13,15 @@ When user input triggers expensive computations or renders, use `useDeferredValu
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
function Search({ items }: { items: Item[] }) {
|
function Search({ items }: { items: Item[] }) {
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('');
|
||||||
const filtered = items.filter(item => fuzzyMatch(item, query))
|
const filtered = items.filter((item) => fuzzyMatch(item, query));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<input value={query} onChange={e => setQuery(e.target.value)} />
|
<input value={query} onChange={(e) => setQuery(e.target.value)} />
|
||||||
<ResultsList results={filtered} />
|
<ResultsList results={filtered} />
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -29,22 +29,22 @@ function Search({ items }: { items: Item[] }) {
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
function Search({ items }: { items: Item[] }) {
|
function Search({ items }: { items: Item[] }) {
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('');
|
||||||
const deferredQuery = useDeferredValue(query)
|
const deferredQuery = useDeferredValue(query);
|
||||||
const filtered = useMemo(
|
const filtered = useMemo(
|
||||||
() => items.filter(item => fuzzyMatch(item, deferredQuery)),
|
() => items.filter((item) => fuzzyMatch(item, deferredQuery)),
|
||||||
[items, deferredQuery]
|
[items, deferredQuery],
|
||||||
)
|
);
|
||||||
const isStale = query !== deferredQuery
|
const isStale = query !== deferredQuery;
|
||||||
|
|
||||||
return (
|
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 }}>
|
<div style={{ opacity: isStale ? 0.7 : 1 }}>
|
||||||
<ResultsList results={filtered} />
|
<ResultsList results={filtered} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+16
-16
@@ -13,13 +13,13 @@ When a value changes frequently and you don't want a re-render on every update (
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
function Tracker() {
|
function Tracker() {
|
||||||
const [lastX, setLastX] = useState(0)
|
const [lastX, setLastX] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onMove = (e: MouseEvent) => setLastX(e.clientX)
|
const onMove = (e: MouseEvent) => setLastX(e.clientX);
|
||||||
window.addEventListener('mousemove', onMove)
|
window.addEventListener('mousemove', onMove);
|
||||||
return () => window.removeEventListener('mousemove', onMove)
|
return () => window.removeEventListener('mousemove', onMove);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -32,7 +32,7 @@ function Tracker() {
|
|||||||
background: 'black',
|
background: 'black',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -40,20 +40,20 @@ function Tracker() {
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
function Tracker() {
|
function Tracker() {
|
||||||
const lastXRef = useRef(0)
|
const lastXRef = useRef(0);
|
||||||
const dotRef = useRef<HTMLDivElement>(null)
|
const dotRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onMove = (e: MouseEvent) => {
|
const onMove = (e: MouseEvent) => {
|
||||||
lastXRef.current = e.clientX
|
lastXRef.current = e.clientX;
|
||||||
const node = dotRef.current
|
const node = dotRef.current;
|
||||||
if (node) {
|
if (node) {
|
||||||
node.style.transform = `translateX(${e.clientX}px)`
|
node.style.transform = `translateX(${e.clientX}px)`;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
window.addEventListener('mousemove', onMove)
|
window.addEventListener('mousemove', onMove);
|
||||||
return () => window.removeEventListener('mousemove', onMove)
|
return () => window.removeEventListener('mousemove', onMove);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -68,6 +68,6 @@ function Tracker() {
|
|||||||
transform: 'translateX(0px)',
|
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):**
|
**Incorrect (blocks response):**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { logUserAction } from '@/app/utils'
|
import { logUserAction } from '@/app/utils';
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
// Perform mutation
|
// Perform mutation
|
||||||
await updateDatabase(request)
|
await updateDatabase(request);
|
||||||
|
|
||||||
// Logging blocks the response
|
// Logging blocks the response
|
||||||
const userAgent = request.headers.get('user-agent') || 'unknown'
|
const userAgent = request.headers.get('user-agent') || 'unknown';
|
||||||
await logUserAction({ userAgent })
|
await logUserAction({ userAgent });
|
||||||
|
|
||||||
return new Response(JSON.stringify({ status: 'success' }), {
|
return new Response(JSON.stringify({ status: 'success' }), {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { 'Content-Type': 'application/json' },
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Correct (non-blocking):**
|
**Correct (non-blocking):**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { after } from 'next/server'
|
import { after } from 'next/server';
|
||||||
import { headers, cookies } from 'next/headers'
|
import { headers, cookies } from 'next/headers';
|
||||||
import { logUserAction } from '@/app/utils'
|
import { logUserAction } from '@/app/utils';
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
// Perform mutation
|
// Perform mutation
|
||||||
await updateDatabase(request)
|
await updateDatabase(request);
|
||||||
|
|
||||||
// Log after response is sent
|
// Log after response is sent
|
||||||
after(async () => {
|
after(async () => {
|
||||||
const userAgent = (await headers()).get('user-agent') || 'unknown'
|
const userAgent = (await headers()).get('user-agent') || 'unknown';
|
||||||
const sessionCookie = (await cookies()).get('session-id')?.value || 'anonymous'
|
const sessionCookie =
|
||||||
|
(await cookies()).get('session-id')?.value || 'anonymous';
|
||||||
|
|
||||||
logUserAction({ sessionCookie, userAgent })
|
logUserAction({ sessionCookie, userAgent });
|
||||||
})
|
});
|
||||||
|
|
||||||
return new Response(JSON.stringify({ status: 'success' }), {
|
return new Response(JSON.stringify({ status: 'success' }), {
|
||||||
status: 200,
|
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):**
|
**Incorrect (no authentication check):**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
'use server'
|
'use server';
|
||||||
|
|
||||||
export async function deleteUser(userId: string) {
|
export async function deleteUser(userId: string) {
|
||||||
// Anyone can call this! No auth check
|
// Anyone can call this! No auth check
|
||||||
await db.user.delete({ where: { id: userId } })
|
await db.user.delete({ where: { id: userId } });
|
||||||
return { success: true }
|
return { success: true };
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Correct (authentication inside the action):**
|
**Correct (authentication inside the action):**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
'use server'
|
'use server';
|
||||||
|
|
||||||
import { verifySession } from '@/lib/auth'
|
import { verifySession } from '@/lib/auth';
|
||||||
import { unauthorized } from '@/lib/errors'
|
import { unauthorized } from '@/lib/errors';
|
||||||
|
|
||||||
export async function deleteUser(userId: string) {
|
export async function deleteUser(userId: string) {
|
||||||
// Always check auth inside the action
|
// Always check auth inside the action
|
||||||
const session = await verifySession()
|
const session = await verifySession();
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw unauthorized('Must be logged in')
|
throw unauthorized('Must be logged in');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check authorization too
|
// Check authorization too
|
||||||
if (session.user.role !== 'admin' && session.user.id !== userId) {
|
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 } })
|
await db.user.delete({ where: { id: userId } });
|
||||||
return { success: true }
|
return { success: true };
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**With input validation:**
|
**With input validation:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
'use server'
|
'use server';
|
||||||
|
|
||||||
import { verifySession } from '@/lib/auth'
|
import { verifySession } from '@/lib/auth';
|
||||||
import { z } from 'zod'
|
import { z } from 'zod';
|
||||||
|
|
||||||
const updateProfileSchema = z.object({
|
const updateProfileSchema = z.object({
|
||||||
userId: z.string().uuid(),
|
userId: z.string().uuid(),
|
||||||
name: z.string().min(1).max(100),
|
name: z.string().min(1).max(100),
|
||||||
email: z.string().email()
|
email: z.string().email(),
|
||||||
})
|
});
|
||||||
|
|
||||||
export async function updateProfile(data: unknown) {
|
export async function updateProfile(data: unknown) {
|
||||||
// Validate input first
|
// Validate input first
|
||||||
const validated = updateProfileSchema.parse(data)
|
const validated = updateProfileSchema.parse(data);
|
||||||
|
|
||||||
// Then authenticate
|
// Then authenticate
|
||||||
const session = await verifySession()
|
const session = await verifySession();
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new Error('Unauthorized')
|
throw new Error('Unauthorized');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then authorize
|
// Then authorize
|
||||||
if (session.user.id !== validated.userId) {
|
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
|
// Finally perform the mutation
|
||||||
@@ -85,11 +85,11 @@ export async function updateProfile(data: unknown) {
|
|||||||
where: { id: validated.userId },
|
where: { id: validated.userId },
|
||||||
data: {
|
data: {
|
||||||
name: validated.name,
|
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:**
|
**Implementation:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { LRUCache } from 'lru-cache'
|
import { LRUCache } from 'lru-cache';
|
||||||
|
|
||||||
const cache = new LRUCache<string, any>({
|
const cache = new LRUCache<string, any>({
|
||||||
max: 1000,
|
max: 1000,
|
||||||
ttl: 5 * 60 * 1000 // 5 minutes
|
ttl: 5 * 60 * 1000, // 5 minutes
|
||||||
})
|
});
|
||||||
|
|
||||||
export async function getUser(id: string) {
|
export async function getUser(id: string) {
|
||||||
const cached = cache.get(id)
|
const cached = cache.get(id);
|
||||||
if (cached) return cached
|
if (cached) return cached;
|
||||||
|
|
||||||
const user = await db.user.findUnique({ where: { id } })
|
const user = await db.user.findUnique({ where: { id } });
|
||||||
cache.set(id, user)
|
cache.set(id, user);
|
||||||
return user
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request 1: DB query, result cached
|
// Request 1: DB query, result cached
|
||||||
|
|||||||
@@ -12,15 +12,15 @@ Use `React.cache()` for server-side request deduplication. Authentication and da
|
|||||||
**Usage:**
|
**Usage:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { cache } from 'react'
|
import { cache } from 'react';
|
||||||
|
|
||||||
export const getCurrentUser = cache(async () => {
|
export const getCurrentUser = cache(async () => {
|
||||||
const session = await auth()
|
const session = await auth();
|
||||||
if (!session?.user?.id) return null
|
if (!session?.user?.id) return null;
|
||||||
return await db.user.findUnique({
|
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.
|
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
|
```typescript
|
||||||
const getUser = cache(async (params: { uid: number }) => {
|
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
|
// Each call creates new object, never hits cache
|
||||||
getUser({ uid: 1 })
|
getUser({ uid: 1 });
|
||||||
getUser({ uid: 1 }) // Cache miss, runs query again
|
getUser({ uid: 1 }); // Cache miss, runs query again
|
||||||
```
|
```
|
||||||
|
|
||||||
**Correct (cache hit):**
|
**Correct (cache hit):**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const getUser = cache(async (uid: number) => {
|
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
|
// Primitive args use value equality
|
||||||
getUser(1)
|
getUser(1);
|
||||||
getUser(1) // Cache hit, returns cached result
|
getUser(1); // Cache hit, returns cached result
|
||||||
```
|
```
|
||||||
|
|
||||||
If you must pass objects, pass the same reference:
|
If you must pass objects, pass the same reference:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const params = { uid: 1 }
|
const params = { uid: 1 };
|
||||||
getUser(params) // Query runs
|
getUser(params); // Query runs
|
||||||
getUser(params) // Cache hit (same reference)
|
getUser(params); // Cache hit (same reference)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Next.js-Specific Note:**
|
**Next.js-Specific Note:**
|
||||||
|
|||||||
@@ -22,11 +22,11 @@ RSC→client serialization deduplicates by object reference, not value. Same ref
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// RSC: send once
|
// RSC: send once
|
||||||
<ClientList usernames={usernames} />
|
<ClientList usernames={usernames} />;
|
||||||
|
|
||||||
// Client: transform there
|
// Client: transform there
|
||||||
'use client'
|
('use client');
|
||||||
const sorted = useMemo(() => [...usernames].sort(), [usernames])
|
const sorted = useMemo(() => [...usernames].sort(), [usernames]);
|
||||||
```
|
```
|
||||||
|
|
||||||
**Nested deduplication behavior:**
|
**Nested deduplication behavior:**
|
||||||
|
|||||||
@@ -97,35 +97,31 @@ export async function GET(request: Request) {
|
|||||||
**Incorrect (reads config on every call):**
|
**Incorrect (reads config on every call):**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import fs from 'node:fs/promises'
|
import fs from 'node:fs/promises';
|
||||||
|
|
||||||
export async function processRequest(data: Data) {
|
export async function processRequest(data: Data) {
|
||||||
const config = JSON.parse(
|
const config = JSON.parse(await fs.readFile('./config.json', 'utf-8'));
|
||||||
await fs.readFile('./config.json', 'utf-8')
|
const template = await fs.readFile('./template.html', '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):**
|
**Correct (hoists config and template to module level):**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import fs from 'node:fs/promises'
|
import fs from 'node:fs/promises';
|
||||||
|
|
||||||
const configPromise = fs
|
const configPromise = fs.readFile('./config.json', 'utf-8').then(JSON.parse);
|
||||||
.readFile('./config.json', 'utf-8')
|
const templatePromise = fs.readFile('./template.html', 'utf-8');
|
||||||
.then(JSON.parse)
|
|
||||||
const templatePromise = fs.readFile('./template.html', 'utf-8')
|
|
||||||
|
|
||||||
export async function processRequest(data: Data) {
|
export async function processRequest(data: Data) {
|
||||||
const [config, template] = await Promise.all([
|
const [config, template] = await Promise.all([
|
||||||
configPromise,
|
configPromise,
|
||||||
templatePromise,
|
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):**
|
**Incorrect (request data leaks across concurrent renders):**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
let currentUser: User | null = null
|
let currentUser: User | null = null;
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
currentUser = await auth()
|
currentUser = await auth();
|
||||||
return <Dashboard />
|
return <Dashboard />;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function 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
|
```tsx
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
const user = await auth()
|
const user = await auth();
|
||||||
return <Dashboard user={user} />
|
return <Dashboard user={user} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Dashboard({ user }: { user: User | null }) {
|
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
|
```tsx
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
const header = await fetchHeader()
|
const header = await fetchHeader();
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div>{header}</div>
|
<div>{header}</div>
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function Sidebar() {
|
async function Sidebar() {
|
||||||
const items = await fetchSidebarItems()
|
const items = await fetchSidebarItems();
|
||||||
return <nav>{items.map(renderItem)}</nav>
|
return <nav>{items.map(renderItem)}</nav>;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -32,13 +32,13 @@ async function Sidebar() {
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
async function Header() {
|
async function Header() {
|
||||||
const data = await fetchHeader()
|
const data = await fetchHeader();
|
||||||
return <div>{data}</div>
|
return <div>{data}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function Sidebar() {
|
async function Sidebar() {
|
||||||
const items = await fetchSidebarItems()
|
const items = await fetchSidebarItems();
|
||||||
return <nav>{items.map(renderItem)}</nav>
|
return <nav>{items.map(renderItem)}</nav>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
@@ -47,7 +47,7 @@ export default function Page() {
|
|||||||
<Header />
|
<Header />
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -55,13 +55,13 @@ export default function Page() {
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
async function Header() {
|
async function Header() {
|
||||||
const data = await fetchHeader()
|
const data = await fetchHeader();
|
||||||
return <div>{data}</div>
|
return <div>{data}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function Sidebar() {
|
async function Sidebar() {
|
||||||
const items = await fetchSidebarItems()
|
const items = await fetchSidebarItems();
|
||||||
return <nav>{items.map(renderItem)}</nav>
|
return <nav>{items.map(renderItem)}</nav>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Layout({ children }: { children: ReactNode }) {
|
function Layout({ children }: { children: ReactNode }) {
|
||||||
@@ -70,7 +70,7 @@ function Layout({ children }: { children: ReactNode }) {
|
|||||||
<Header />
|
<Header />
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
@@ -78,6 +78,6 @@ export default function Page() {
|
|||||||
<Layout>
|
<Layout>
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
</Layout>
|
</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):**
|
**Incorrect (a single slow item blocks all nested fetches):**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const chats = await Promise.all(
|
const chats = await Promise.all(chatIds.map((id) => getChat(id)));
|
||||||
chatIds.map(id => getChat(id))
|
|
||||||
)
|
|
||||||
|
|
||||||
const chatAuthors = await Promise.all(
|
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.
|
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
|
```tsx
|
||||||
const chatAuthors = await Promise.all(
|
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.
|
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
|
```tsx
|
||||||
async function Page() {
|
async function Page() {
|
||||||
const user = await fetchUser() // 50 fields
|
const user = await fetchUser(); // 50 fields
|
||||||
return <Profile user={user} />
|
return <Profile user={user} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
'use client'
|
('use client');
|
||||||
function Profile({ user }: { user: User }) {
|
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
|
```tsx
|
||||||
async function Page() {
|
async function Page() {
|
||||||
const user = await fetchUser()
|
const user = await fetchUser();
|
||||||
return <Profile name={user.name} />
|
return <Profile name={user.name} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
'use client'
|
('use client');
|
||||||
function Profile({ name }: { name: string }) {
|
function Profile({ name }: { name: string }) {
|
||||||
return <div>{name}</div>
|
return <div>{name}</div>;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
Generated
+1
@@ -8,6 +8,7 @@
|
|||||||
"name": "mcma-webui",
|
"name": "mcma-webui",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
"@reduxjs/toolkit": "^2.12.0",
|
"@reduxjs/toolkit": "^2.12.0",
|
||||||
"modern-sk": "git+https://git.ollyhearn.ru/olly/modern-sk.git",
|
"modern-sk": "git+https://git.ollyhearn.ru/olly/modern-sk.git",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"test:watch": "rstest --watch"
|
"test:watch": "rstest --watch"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
"@reduxjs/toolkit": "^2.12.0",
|
"@reduxjs/toolkit": "^2.12.0",
|
||||||
"modern-sk": "git+https://git.ollyhearn.ru/olly/modern-sk.git",
|
"modern-sk": "git+https://git.ollyhearn.ru/olly/modern-sk.git",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
|
|||||||
+41
-20
@@ -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 type { RootState } from '../store';
|
||||||
import { getApiBaseUrl } from '../config/runtime-config';
|
import { getApiBaseUrl } from '../config/runtime-config';
|
||||||
import { logout, setTokens } from '../store/slices/auth';
|
import { logout, setTokens } from '../store/slices/auth';
|
||||||
@@ -13,33 +18,49 @@ const rawBaseQuery = () =>
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const baseQueryWithReauth: BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryError> =
|
export const baseQueryWithReauth: BaseQueryFn<
|
||||||
async (args, api, extraOptions) => {
|
string | FetchArgs,
|
||||||
let result = await rawBaseQuery()(args, api, extraOptions);
|
unknown,
|
||||||
|
FetchBaseQueryError
|
||||||
|
> = async (args, api, extraOptions) => {
|
||||||
|
let result = await rawBaseQuery()(args, api, extraOptions);
|
||||||
|
|
||||||
if (result.error?.status === 401) {
|
if (result.error?.status === 401) {
|
||||||
const state = api.getState() as RootState;
|
const state = api.getState() as RootState;
|
||||||
const refreshToken = state.auth.refreshToken;
|
const refreshToken = state.auth.refreshToken;
|
||||||
|
|
||||||
if (refreshToken) {
|
if (refreshToken) {
|
||||||
const refreshResult = await rawBaseQuery()({
|
const refreshResult = await rawBaseQuery()(
|
||||||
|
{
|
||||||
url: '/auth/refresh',
|
url: '/auth/refresh',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { refreshToken },
|
body: { refreshToken },
|
||||||
}, api, extraOptions);
|
},
|
||||||
|
api,
|
||||||
|
extraOptions,
|
||||||
|
);
|
||||||
|
|
||||||
if (refreshResult.data) {
|
if (refreshResult.data) {
|
||||||
const { accessToken, refreshToken: newRefresh, expiresIn } =
|
const {
|
||||||
(refreshResult.data as { accessToken: string; refreshToken: string; expiresIn: number });
|
accessToken,
|
||||||
api.dispatch(setTokens({ accessToken, refreshToken: newRefresh, expiresIn }));
|
refreshToken: newRefresh,
|
||||||
result = await rawBaseQuery()(args, api, extraOptions);
|
expiresIn,
|
||||||
} else {
|
} = refreshResult.data as {
|
||||||
api.dispatch(logout());
|
accessToken: string;
|
||||||
}
|
refreshToken: string;
|
||||||
|
expiresIn: number;
|
||||||
|
};
|
||||||
|
api.dispatch(
|
||||||
|
setTokens({ accessToken, refreshToken: newRefresh, expiresIn }),
|
||||||
|
);
|
||||||
|
result = await rawBaseQuery()(args, api, extraOptions);
|
||||||
} else {
|
} else {
|
||||||
api.dispatch(logout());
|
api.dispatch(logout());
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
api.dispatch(logout());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,12 +7,27 @@ export const adminApi = api.injectEndpoints({
|
|||||||
query: () => '/admin/users',
|
query: () => '/admin/users',
|
||||||
providesTags: ['User'],
|
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 }),
|
query: (body) => ({ url: '/admin/users', method: 'POST', body }),
|
||||||
invalidatesTags: ['User'],
|
invalidatesTags: ['User'],
|
||||||
}),
|
}),
|
||||||
updateUser: build.mutation<User, { id: string; role?: 'admin' | 'user'; email?: string }>({
|
updateUser: build.mutation<
|
||||||
query: ({ id, ...body }) => ({ url: `/admin/users/${id}`, method: 'PATCH', body }),
|
User,
|
||||||
|
{ id: string; role?: 'admin' | 'user'; email?: string }
|
||||||
|
>({
|
||||||
|
query: ({ id, ...body }) => ({
|
||||||
|
url: `/admin/users/${id}`,
|
||||||
|
method: 'PATCH',
|
||||||
|
body,
|
||||||
|
}),
|
||||||
invalidatesTags: ['User'],
|
invalidatesTags: ['User'],
|
||||||
}),
|
}),
|
||||||
deleteUser: build.mutation<void, string>({
|
deleteUser: build.mutation<void, string>({
|
||||||
@@ -23,4 +38,9 @@ export const adminApi = api.injectEndpoints({
|
|||||||
overrideExisting: false,
|
overrideExisting: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const { useGetUsersQuery, useCreateUserMutation, useUpdateUserMutation, useDeleteUserMutation } = adminApi;
|
export const {
|
||||||
|
useGetUsersQuery,
|
||||||
|
useCreateUserMutation,
|
||||||
|
useUpdateUserMutation,
|
||||||
|
useDeleteUserMutation,
|
||||||
|
} = adminApi;
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ export const authApi = api.injectEndpoints({
|
|||||||
logout: build.mutation<void, void>({
|
logout: build.mutation<void, void>({
|
||||||
query: () => ({ url: '/auth/logout', method: 'POST' }),
|
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 }),
|
query: (body) => ({ url: '/auth/refresh', method: 'POST', body }),
|
||||||
}),
|
}),
|
||||||
me: build.query<import('../types').User, void>({
|
me: build.query<import('../types').User, void>({
|
||||||
|
|||||||
@@ -3,11 +3,20 @@ import type { DownloadJob } from '../types';
|
|||||||
|
|
||||||
export const downloadsApi = api.injectEndpoints({
|
export const downloadsApi = api.injectEndpoints({
|
||||||
endpoints: (build) => ({
|
endpoints: (build) => ({
|
||||||
getDownloads: build.query<DownloadJob[], { status?: DownloadJob['status'] } | void>({
|
getDownloads: build.query<
|
||||||
|
DownloadJob[],
|
||||||
|
{ status?: DownloadJob['status'] } | void
|
||||||
|
>({
|
||||||
query: (params) => ({ url: '/downloads', params: params ?? {} }),
|
query: (params) => ({ url: '/downloads', params: params ?? {} }),
|
||||||
providesTags: ['Download'],
|
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 }),
|
query: (body) => ({ url: '/downloads', method: 'POST', body }),
|
||||||
invalidatesTags: ['Download'],
|
invalidatesTags: ['Download'],
|
||||||
}),
|
}),
|
||||||
@@ -23,4 +32,9 @@ export const downloadsApi = api.injectEndpoints({
|
|||||||
overrideExisting: false,
|
overrideExisting: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const { useGetDownloadsQuery, useAddDownloadMutation, useCancelDownloadMutation, useRetryDownloadMutation } = downloadsApi;
|
export const {
|
||||||
|
useGetDownloadsQuery,
|
||||||
|
useAddDownloadMutation,
|
||||||
|
useCancelDownloadMutation,
|
||||||
|
useRetryDownloadMutation,
|
||||||
|
} = downloadsApi;
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { api } from '../index';
|
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({
|
export const libraryApi = api.injectEndpoints({
|
||||||
endpoints: (build) => ({
|
endpoints: (build) => ({
|
||||||
@@ -7,18 +13,32 @@ export const libraryApi = api.injectEndpoints({
|
|||||||
query: (filters) => ({ url: '/library/tracks', params: filters ?? {} }),
|
query: (filters) => ({ url: '/library/tracks', params: filters ?? {} }),
|
||||||
providesTags: (result) =>
|
providesTags: (result) =>
|
||||||
result
|
result
|
||||||
? [...result.items.map(({ id }) => ({ type: 'Track' as const, id })), 'Track']
|
? [
|
||||||
|
...result.items.map(({ id }) => ({ type: 'Track' as const, id })),
|
||||||
|
'Track',
|
||||||
|
]
|
||||||
: ['Track'],
|
: ['Track'],
|
||||||
}),
|
}),
|
||||||
getTrack: build.query<Track, string>({
|
getTrack: build.query<Track, string>({
|
||||||
query: (id) => `/library/tracks/${id}`,
|
query: (id) => `/library/tracks/${id}`,
|
||||||
providesTags: (_r, _e, id) => [{ type: 'Track', 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 ?? {} }),
|
query: (params) => ({ url: '/library/albums', params: params ?? {} }),
|
||||||
providesTags: (result) =>
|
providesTags: (result) =>
|
||||||
result
|
result
|
||||||
? [...result.items.map(({ id }) => ({ type: 'Album' as const, id })), 'Album']
|
? [
|
||||||
|
...result.items.map(({ id }) => ({ type: 'Album' as const, id })),
|
||||||
|
'Album',
|
||||||
|
]
|
||||||
: ['Album'],
|
: ['Album'],
|
||||||
}),
|
}),
|
||||||
getAlbum: build.query<Album, string>({
|
getAlbum: build.query<Album, string>({
|
||||||
@@ -27,13 +47,25 @@ export const libraryApi = api.injectEndpoints({
|
|||||||
}),
|
}),
|
||||||
getAlbumTracks: build.query<Track[], string>({
|
getAlbumTracks: build.query<Track[], string>({
|
||||||
query: (albumId) => `/library/albums/${albumId}/tracks`,
|
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 ?? {} }),
|
query: (params) => ({ url: '/library/artists', params: params ?? {} }),
|
||||||
providesTags: (result) =>
|
providesTags: (result) =>
|
||||||
result
|
result
|
||||||
? [...result.items.map(({ id }) => ({ type: 'Artist' as const, id })), 'Artist']
|
? [
|
||||||
|
...result.items.map(({ id }) => ({
|
||||||
|
type: 'Artist' as const,
|
||||||
|
id,
|
||||||
|
})),
|
||||||
|
'Artist',
|
||||||
|
]
|
||||||
: ['Artist'],
|
: ['Artist'],
|
||||||
}),
|
}),
|
||||||
getArtist: build.query<Artist, string>({
|
getArtist: build.query<Artist, string>({
|
||||||
@@ -42,9 +74,15 @@ export const libraryApi = api.injectEndpoints({
|
|||||||
}),
|
}),
|
||||||
getArtistAlbums: build.query<Album[], string>({
|
getArtistAlbums: build.query<Album[], string>({
|
||||||
query: (artistId) => `/library/artists/${artistId}/albums`,
|
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 } }),
|
query: (q) => ({ url: '/library/search', params: { q } }),
|
||||||
providesTags: ['Track', 'Album', 'Artist'],
|
providesTags: ['Track', 'Album', 'Artist'],
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ export const likesApi = api.injectEndpoints({
|
|||||||
invalidatesTags: (_r, _e, id) => ['Like', { type: 'Track', id }],
|
invalidatesTags: (_r, _e, id) => ['Like', { type: 'Track', id }],
|
||||||
}),
|
}),
|
||||||
unlikeTrack: build.mutation<void, string>({
|
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 }],
|
invalidatesTags: (_r, _e, id) => ['Like', { type: 'Track', id }],
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -15,25 +15,52 @@ export const playlistsApi = api.injectEndpoints({
|
|||||||
query: (id) => `/playlists/${id}/tracks`,
|
query: (id) => `/playlists/${id}/tracks`,
|
||||||
providesTags: (_r, _e, id) => [{ type: 'Playlist', id }, 'Track'],
|
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 }),
|
query: (body) => ({ url: '/playlists', method: 'POST', body }),
|
||||||
invalidatesTags: ['Playlist'],
|
invalidatesTags: ['Playlist'],
|
||||||
}),
|
}),
|
||||||
updatePlaylist: build.mutation<Playlist, { id: string; name?: string; description?: string; isPublic?: boolean }>({
|
updatePlaylist: build.mutation<
|
||||||
query: ({ id, ...body }) => ({ url: `/playlists/${id}`, method: 'PATCH', body }),
|
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 }],
|
invalidatesTags: (_r, _e, { id }) => [{ type: 'Playlist', id }],
|
||||||
}),
|
}),
|
||||||
deletePlaylist: build.mutation<void, string>({
|
deletePlaylist: build.mutation<void, string>({
|
||||||
query: (id) => ({ url: `/playlists/${id}`, method: 'DELETE' }),
|
query: (id) => ({ url: `/playlists/${id}`, method: 'DELETE' }),
|
||||||
invalidatesTags: ['Playlist'],
|
invalidatesTags: ['Playlist'],
|
||||||
}),
|
}),
|
||||||
addTrackToPlaylist: build.mutation<void, { playlistId: string; trackId: string }>({
|
addTrackToPlaylist: build.mutation<
|
||||||
query: ({ playlistId, trackId }) => ({ url: `/playlists/${playlistId}/tracks`, method: 'POST', body: { trackId } }),
|
void,
|
||||||
invalidatesTags: (_r, _e, { playlistId }) => [{ type: 'Playlist', id: playlistId }],
|
{ playlistId: string; trackId: string }
|
||||||
|
>({
|
||||||
|
query: ({ playlistId, trackId }) => ({
|
||||||
|
url: `/playlists/${playlistId}/tracks`,
|
||||||
|
method: 'POST',
|
||||||
|
body: { trackId },
|
||||||
|
}),
|
||||||
|
invalidatesTags: (_r, _e, { playlistId }) => [
|
||||||
|
{ type: 'Playlist', id: playlistId },
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
removeTrackFromPlaylist: build.mutation<void, { playlistId: string; trackId: string; position: number }>({
|
removeTrackFromPlaylist: build.mutation<
|
||||||
query: ({ playlistId, position }) => ({ url: `/playlists/${playlistId}/tracks/${position}`, method: 'DELETE' }),
|
void,
|
||||||
invalidatesTags: (_r, _e, { playlistId }) => [{ type: 'Playlist', id: playlistId }],
|
{ 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,
|
overrideExisting: false,
|
||||||
|
|||||||
@@ -12,11 +12,18 @@ export const storageApi = api.injectEndpoints({
|
|||||||
invalidatesTags: ['Storage', 'Track', 'Album', 'Artist'],
|
invalidatesTags: ['Storage', 'Track', 'Album', 'Artist'],
|
||||||
}),
|
}),
|
||||||
deleteTrackFile: build.mutation<void, string>({
|
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 }],
|
invalidatesTags: ['Storage', { type: 'Track', id: undefined }],
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
overrideExisting: false,
|
overrideExisting: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const { useGetStorageStatsQuery, useScanStorageMutation, useDeleteTrackFileMutation } = storageApi;
|
export const {
|
||||||
|
useGetStorageStatsQuery,
|
||||||
|
useScanStorageMutation,
|
||||||
|
useDeleteTrackFileMutation,
|
||||||
|
} = storageApi;
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ export function getStreamUrl(trackId: string, token: string): string {
|
|||||||
|
|
||||||
export function getCoverUrl(artUrl: string | undefined): string | undefined {
|
export function getCoverUrl(artUrl: string | undefined): string | undefined {
|
||||||
if (!artUrl) return 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();
|
const base = getApiBaseUrl();
|
||||||
return `${base}${artUrl}`;
|
return `${base}${artUrl}`;
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-1
@@ -4,6 +4,15 @@ import { baseQueryWithReauth } from './baseQuery';
|
|||||||
export const api = createApi({
|
export const api = createApi({
|
||||||
reducerPath: 'api',
|
reducerPath: 'api',
|
||||||
baseQuery: baseQueryWithReauth,
|
baseQuery: baseQueryWithReauth,
|
||||||
tagTypes: ['Track', 'Album', 'Artist', 'Playlist', 'Download', 'Like', 'User', 'Storage'],
|
tagTypes: [
|
||||||
|
'Track',
|
||||||
|
'Album',
|
||||||
|
'Artist',
|
||||||
|
'Playlist',
|
||||||
|
'Download',
|
||||||
|
'Like',
|
||||||
|
'User',
|
||||||
|
'Storage',
|
||||||
|
],
|
||||||
endpoints: () => ({}),
|
endpoints: () => ({}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,13 +7,35 @@ interface EmptyStateProps {
|
|||||||
action?: ReactNode;
|
action?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EmptyState({ icon, title, description, action }: EmptyStateProps) {
|
export function EmptyState({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
action,
|
||||||
|
}: EmptyStateProps) {
|
||||||
return (
|
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>}
|
{icon && <div style={{ fontSize: '2.5rem', opacity: 0.5 }}>{icon}</div>}
|
||||||
<div>
|
<div>
|
||||||
<p style={{ margin: 0, fontWeight: 600, color: 'var(--color-text-1)' }}>{title}</p>
|
<p style={{ margin: 0, fontWeight: 600, color: 'var(--color-text-1)' }}>
|
||||||
{description && <p style={{ margin: '0.25rem 0 0', fontSize: '0.875rem' }}>{description}</p>}
|
{title}
|
||||||
|
</p>
|
||||||
|
{description && (
|
||||||
|
<p style={{ margin: '0.25rem 0 0', fontSize: '0.875rem' }}>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{action}
|
{action}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,13 +5,21 @@ interface ErrorStateProps {
|
|||||||
onRetry?: () => void;
|
onRetry?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorState({ message = 'Something went wrong', onRetry }: ErrorStateProps) {
|
export function ErrorState({
|
||||||
|
message = 'Something went wrong',
|
||||||
|
onRetry,
|
||||||
|
}: ErrorStateProps) {
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '2rem' }}>
|
<div style={{ padding: '2rem' }}>
|
||||||
<Callout variant="danger">
|
<Callout variant="danger">
|
||||||
{message}
|
{message}
|
||||||
{onRetry && (
|
{onRetry && (
|
||||||
<Button variant="ghost" size="sm" onClick={onRetry} style={{ marginLeft: '1rem' }}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onRetry}
|
||||||
|
style={{ marginLeft: '1rem' }}
|
||||||
|
>
|
||||||
Retry
|
Retry
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,17 +5,24 @@ import { QueuePanel } from '../player/QueuePanel';
|
|||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden' }}>
|
<div
|
||||||
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100vh',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="app-body">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main style={{ flex: 1, overflow: 'auto', display: 'flex', flexDirection: 'column' }}>
|
<main className="app-main">
|
||||||
<Outlet />
|
<div className="app-screen">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<QueuePanel />
|
<QueuePanel />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
<PersistentPlayer />
|
||||||
<PersistentPlayer />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,113 +1,147 @@
|
|||||||
import { Badge } from 'modern-sk';
|
|
||||||
import { NavLink, useNavigate } from 'react-router';
|
import { NavLink, useNavigate } from 'react-router';
|
||||||
import { ConnectionStatus } from '../common/ConnectionStatus';
|
import { Icon, type IconName } from '../common/Icon';
|
||||||
import { useAppSelector, useAppDispatch } from '../../hooks/useAppDispatch';
|
import { useAppDispatch } from '../../hooks/useAppDispatch';
|
||||||
import { usePermissions } from '../../hooks/usePermissions';
|
import { usePermissions } from '../../hooks/usePermissions';
|
||||||
|
import { useConnectionStatus } from '../../hooks/useConnectionStatus';
|
||||||
import { logout } from '../../store/slices/auth';
|
import { logout } from '../../store/slices/auth';
|
||||||
|
import { useGetPlaylistsQuery } from '../../api/endpoints/playlists';
|
||||||
|
import { getActiveInstance } from '../../config/instances';
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
interface NavDef {
|
||||||
{ to: '/', label: 'Home', icon: '⌂' },
|
to: string;
|
||||||
{ to: '/library', label: 'Library', icon: '♫' },
|
label: string;
|
||||||
{ to: '/search', label: 'Search & Download', icon: '⊕' },
|
icon: IconName;
|
||||||
{ to: '/downloads', label: 'Downloads', icon: '↓' },
|
end?: boolean;
|
||||||
{ to: '/storage', label: 'Storage', icon: '⊞' },
|
}
|
||||||
{ to: '/settings', label: 'Settings', icon: '⚙' },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const ADMIN_ITEMS = [
|
const MAIN_NAV: NavDef[] = [
|
||||||
{ to: '/admin', label: 'Admin', icon: '🔑' },
|
{ to: '/', label: 'Home', icon: 'house', end: true },
|
||||||
] as const;
|
{ 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() {
|
export function Sidebar() {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, isAdmin } = usePermissions();
|
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());
|
dispatch(logout());
|
||||||
void navigate('/connect');
|
void navigate('/connect');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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' }}>
|
<aside className="sidebar">
|
||||||
<div style={{ padding: collapsed ? '0 0.5rem' : '0 0.75rem', marginBottom: '1.5rem' }}>
|
<div className="sb-scroll">
|
||||||
<span style={{ fontWeight: 700, fontSize: '1.125rem', color: 'var(--color-accent)', whiteSpace: 'nowrap' }}>
|
<div className="sb-brand">
|
||||||
{collapsed ? '♫' : '♫ MCMA'}
|
<Icon name="vinyl-record" fill />
|
||||||
</span>
|
<span>{instance?.name ?? 'MCMA'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '2px' }}>
|
<div className="sb-sec">
|
||||||
{NAV_ITEMS.map(({ to, label, icon }) => (
|
{MAIN_NAV.map(({ to, label, icon, end }) => (
|
||||||
<NavLink
|
<NavLink key={to} to={to} end={end} className={navClass}>
|
||||||
key={to}
|
<Icon name={icon} />
|
||||||
to={to}
|
<span>{label}</span>
|
||||||
end={to === '/'}
|
</NavLink>
|
||||||
style={({ isActive }) => ({
|
))}
|
||||||
display: 'flex',
|
</div>
|
||||||
alignItems: 'center',
|
|
||||||
gap: '0.75rem',
|
<div className="sb-sec">
|
||||||
padding: collapsed ? '0.625rem 0.75rem' : '0.625rem 1rem',
|
<span className="msk-label">Playlists</span>
|
||||||
borderRadius: 6,
|
{(playlists?.items ?? []).map((pl) => (
|
||||||
margin: '0 0.375rem',
|
<NavLink
|
||||||
color: isActive ? 'var(--color-accent)' : 'var(--color-text-2)',
|
key={pl.id}
|
||||||
background: isActive ? 'var(--color-surface-2)' : undefined,
|
to={`/library/playlists/${pl.id}`}
|
||||||
fontWeight: isActive ? 600 : 400,
|
className={navClass}
|
||||||
textDecoration: 'none',
|
>
|
||||||
fontSize: '0.875rem',
|
<Icon name="playlist" />
|
||||||
whiteSpace: 'nowrap',
|
<span className="pl-name">{pl.name}</span>
|
||||||
transition: 'background 0.1s, color 0.1s',
|
</NavLink>
|
||||||
})}
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="pl-item"
|
||||||
|
onClick={() => void navigate('/library')}
|
||||||
>
|
>
|
||||||
<span style={{ flexShrink: 0, fontSize: '1rem' }}>{icon}</span>
|
<Icon name="plus" />
|
||||||
{!collapsed && label}
|
<span className="pl-name">New playlist</span>
|
||||||
</NavLink>
|
</button>
|
||||||
))}
|
</div>
|
||||||
|
|
||||||
{isAdmin && (
|
{isAdmin ? (
|
||||||
<>
|
<div className="sb-sec">
|
||||||
<div style={{ height: 1, background: 'var(--color-border)', margin: '0.5rem 0.75rem' }} />
|
<span className="msk-label">Administration</span>
|
||||||
{ADMIN_ITEMS.map(({ to, label, icon }) => (
|
<NavLink to="/admin" className={navClass}>
|
||||||
<NavLink
|
<Icon name="shield-check" />
|
||||||
key={to}
|
<span>Admin</span>
|
||||||
to={to}
|
</NavLink>
|
||||||
style={({ isActive }) => ({
|
<NavLink to="/settings" className={navClass}>
|
||||||
display: 'flex',
|
<Icon name="gear-six" />
|
||||||
alignItems: 'center',
|
<span>Settings</span>
|
||||||
gap: '0.75rem',
|
</NavLink>
|
||||||
padding: collapsed ? '0.625rem 0.75rem' : '0.625rem 1rem',
|
</div>
|
||||||
borderRadius: 6,
|
) : (
|
||||||
margin: '0 0.375rem',
|
<div className="sb-sec">
|
||||||
color: isActive ? 'var(--color-accent)' : 'var(--color-text-2)',
|
<NavLink to="/settings" className={navClass}>
|
||||||
background: isActive ? 'var(--color-surface-2)' : undefined,
|
<Icon name="gear-six" />
|
||||||
fontWeight: isActive ? 600 : 400,
|
<span>Settings</span>
|
||||||
textDecoration: 'none',
|
</NavLink>
|
||||||
fontSize: '0.875rem',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<span style={{ flexShrink: 0 }}>{icon}</span>
|
|
||||||
{!collapsed && label}
|
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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 { useAudioPlayer } from '../../hooks/useAudioPlayer';
|
||||||
import { formatDuration } from '../../lib/format';
|
import { formatDuration } from '../../lib/format';
|
||||||
import { getCoverUrl } from '../../api/endpoints/streaming';
|
import { getCoverUrl } from '../../api/endpoints/streaming';
|
||||||
@@ -13,94 +24,140 @@ export function PersistentPlayer() {
|
|||||||
const currentEntry = queue.entries[queue.currentIndex];
|
const currentEntry = queue.entries[queue.currentIndex];
|
||||||
|
|
||||||
if (!currentEntry && !player.currentTrackId) {
|
if (!currentEntry && !player.currentTrackId) {
|
||||||
return (
|
return <div className="player empty">Nothing playing</div>;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const artUrl = currentEntry?.albumArtUrl ? getCoverUrl(currentEntry.albumArtUrl) : undefined;
|
const artUrl = getCoverUrl(currentEntry?.albumArtUrl);
|
||||||
const progressPercent = player.duration > 0 ? (player.position / player.duration) * 100 : 0;
|
const seedLabel = currentEntry?.albumTitle ?? currentEntry?.title ?? '';
|
||||||
|
// Streaming is the web default; local playback is a mobile-client concern.
|
||||||
|
const onStream = true;
|
||||||
|
|
||||||
return (
|
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)' }}>
|
<div className="player">
|
||||||
{/* track info */}
|
{/* now-playing identity */}
|
||||||
<div
|
<div
|
||||||
style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', minWidth: 0, cursor: 'pointer' }}
|
className="pl-now"
|
||||||
onClick={() => dispatch(toggleNowPlaying())}
|
onClick={() => dispatch(toggleNowPlaying())}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
>
|
>
|
||||||
{artUrl ? (
|
<ArtTile seed={seedLabel} size={54} label={seedLabel} src={artUrl} />
|
||||||
<img src={artUrl} alt="" width={40} height={40} style={{ borderRadius: 4, objectFit: 'cover', flexShrink: 0 }} />
|
<div className="pl-now-tt">
|
||||||
) : (
|
<div className="t">{currentEntry?.title ?? '—'}</div>
|
||||||
<div style={{ width: 40, height: 40, borderRadius: 4, background: 'var(--color-surface-3)', flexShrink: 0 }} />
|
<div className="a">{currentEntry?.artistName ?? ''}</div>
|
||||||
)}
|
<div
|
||||||
<div style={{ minWidth: 0 }}>
|
className="pl-srcbadge"
|
||||||
<div style={{ fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: '0.875rem' }}>
|
style={{ color: onStream ? 'var(--fg-3)' : 'var(--lime)' }}
|
||||||
{currentEntry?.title ?? '—'}
|
>
|
||||||
</div>
|
<Icon name={onStream ? 'cloud' : 'check-circle'} fill={!onStream} />
|
||||||
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
{onStream ? 'Streaming · 320 kbps' : 'Local · FLAC'}
|
||||||
{currentEntry?.artistName ?? ''}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* controls + scrubber */}
|
{/* transport + scrubber */}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.25rem', minWidth: '20rem' }}>
|
<div className="pl-center">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
<div className="pl-transport">
|
||||||
<IconButton variant="ghost" size="sm" onClick={playPrev} aria-label="Previous">⏮</IconButton>
|
<button
|
||||||
<IconButton
|
type="button"
|
||||||
variant="primary"
|
className={`pl-tbtn${player.shuffle ? ' on' : ''}`}
|
||||||
onClick={() => player.isPlaying ? dispatch(pause()) : dispatch(resume())}
|
onClick={() => dispatch(toggleShuffle())}
|
||||||
aria-label={player.isPlaying ? 'Pause' : 'Play'}
|
title="Shuffle"
|
||||||
>
|
>
|
||||||
{player.isPlaying ? '⏸' : '▶'}
|
<Icon name="shuffle" />
|
||||||
</IconButton>
|
</button>
|
||||||
<IconButton variant="ghost" size="sm" onClick={playNext} aria-label="Next">⏭</IconButton>
|
<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>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', width: '100%' }}>
|
<div className="pl-seek">
|
||||||
<span style={{ fontSize: '0.7rem', color: 'var(--color-text-3)', minWidth: '2.5rem', textAlign: 'right' }}>
|
<span className="pl-time">
|
||||||
{formatDuration(player.position * 1000)}
|
{formatDuration(player.position * 1000)}
|
||||||
</span>
|
</span>
|
||||||
<Slider
|
<Slider
|
||||||
|
className="pl-seek-slider"
|
||||||
min={0}
|
min={0}
|
||||||
max={player.duration || 1}
|
max={player.duration || 1}
|
||||||
step={1}
|
step={1}
|
||||||
value={[player.position]}
|
value={[player.position]}
|
||||||
onValueChange={([v]) => seek(v)}
|
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)}
|
{formatDuration(player.duration * 1000)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* volume + queue */}
|
{/* volume + queue */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '0.5rem' }}>
|
<div className="pl-right">
|
||||||
<Tooltip content={player.muted ? 'Unmute' : 'Mute'}>
|
<button
|
||||||
<IconButton variant="ghost" size="sm" onClick={() => dispatch(toggleMute())} aria-label="Toggle mute">
|
type="button"
|
||||||
{player.muted ? '🔇' : '🔊'}
|
className="pl-tbtn"
|
||||||
</IconButton>
|
onClick={() => dispatch(toggleMute())}
|
||||||
</Tooltip>
|
title={player.muted ? 'Unmute' : 'Mute'}
|
||||||
<Slider
|
>
|
||||||
min={0}
|
<Icon name={player.muted ? 'speaker-x' : 'speaker-high'} />
|
||||||
max={1}
|
</button>
|
||||||
step={0.01}
|
<div className="pl-vol">
|
||||||
value={[player.muted ? 0 : player.volume]}
|
<Slider
|
||||||
onValueChange={([v]) => dispatch(setVolume(v))}
|
className="pl-vol-slider"
|
||||||
style={{ width: '6rem' }}
|
min={0}
|
||||||
/>
|
max={1}
|
||||||
<Tooltip content="Queue">
|
step={0.01}
|
||||||
<IconButton variant={player.isQueueOpen ? 'primary' : 'ghost'} size="sm" onClick={() => dispatch(toggleQueue())} aria-label="Toggle queue">
|
value={[player.muted ? 0 : player.volume]}
|
||||||
≡
|
onValueChange={([v]) => dispatch(setVolume(v))}
|
||||||
</IconButton>
|
aria-label="Volume"
|
||||||
</Tooltip>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
{/* progress bar at bottom */}
|
type="button"
|
||||||
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: 2, background: 'var(--color-surface-3)' }}>
|
className={`iconbtn sm${player.isQueueOpen ? ' on' : ''}`}
|
||||||
<div style={{ width: `${progressPercent}%`, height: '100%', background: 'var(--color-accent)', transition: 'width 0.5s linear' }} />
|
onClick={() => dispatch(toggleQueue())}
|
||||||
|
title="Play queue"
|
||||||
|
>
|
||||||
|
<Icon name="queue" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 { 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 { toggleQueue } from '../../store/slices/player';
|
||||||
import { formatDuration } from '../../lib/format';
|
|
||||||
|
|
||||||
export function QueuePanel() {
|
export function QueuePanel() {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const queue = useAppSelector((s) => s.queue);
|
const queue = useAppSelector((s) => s.queue);
|
||||||
const isOpen = useAppSelector((s) => s.player.isQueueOpen);
|
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 (
|
return (
|
||||||
<div style={{ width: '20rem', borderLeft: '1px solid var(--color-border)', display: 'flex', flexDirection: 'column', background: 'var(--color-surface-1)', flexShrink: 0 }}>
|
<aside className={`qd${isOpen ? '' : ' closed'}`} aria-hidden={!isOpen}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0.75rem 1rem', borderBottom: '1px solid var(--color-border)' }}>
|
<div className="qd-inner">
|
||||||
<span style={{ fontWeight: 600, fontSize: '0.875rem' }}>Queue</span>
|
<div className="qd-head">
|
||||||
<div style={{ display: 'flex', gap: '0.25rem' }}>
|
<div className="row">
|
||||||
{queue.sourceName && <Badge variant="neutral">{queue.sourceName}</Badge>}
|
<h3>Play queue</h3>
|
||||||
<IconButton variant="ghost" size="sm" onClick={() => dispatch(clearQueue())} aria-label="Clear queue">✕</IconButton>
|
<div style={{ flex: 1 }} />
|
||||||
<IconButton variant="ghost" size="sm" onClick={() => dispatch(toggleQueue())} aria-label="Close">✕</IconButton>
|
<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 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>
|
||||||
|
) : (
|
||||||
|
<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={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--fg-1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Radio active
|
||||||
|
</span>
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
<Badge variant="neutral">∞ mixing</Badge>
|
||||||
|
</div>
|
||||||
|
{/* 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isRadio && (
|
||||||
|
<div className="qd-loadmore">Loading more from radio…</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="qd-empty">Queue is empty</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ScrollArea style={{ flex: 1 }}>
|
</aside>
|
||||||
{queue.entries.length === 0 ? (
|
|
||||||
<p style={{ padding: '2rem', textAlign: 'center', color: 'var(--color-text-3)', fontSize: '0.875rem' }}>Queue is empty</p>
|
|
||||||
) : (
|
|
||||||
queue.entries.map((entry, i) => (
|
|
||||||
<div
|
|
||||||
key={`${entry.trackId}-${i}`}
|
|
||||||
onDoubleClick={() => dispatch(goToIndex(i))}
|
|
||||||
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',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-3)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
||||||
{entry.artistName}
|
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,18 +5,39 @@ interface Props {
|
|||||||
availability: TrackAvailability;
|
availability: TrackAvailability;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CONFIG: Record<TrackAvailability, { label: string; variant: 'lime' | 'ember' | 'neutral' | 'outline'; tooltip: string }> = {
|
const CONFIG: Record<
|
||||||
server: { label: 'On server', variant: 'lime', tooltip: 'File available on server' },
|
TrackAvailability,
|
||||||
downloading: { label: 'Downloading', variant: 'neutral', tooltip: 'Currently downloading' },
|
{
|
||||||
|
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' },
|
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) {
|
export function AvailabilityBadge({ availability }: Props) {
|
||||||
const cfg = CONFIG[availability];
|
const cfg = CONFIG[availability];
|
||||||
return (
|
return (
|
||||||
<Tooltip content={cfg.tooltip}>
|
<Tooltip content={cfg.tooltip}>
|
||||||
<Badge variant={cfg.variant} dot>{cfg.label}</Badge>
|
<Badge variant={cfg.variant} dot>
|
||||||
|
{cfg.label}
|
||||||
|
</Badge>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { useAppDispatch } from '../../hooks/useAppDispatch';
|
||||||
import { addToQueue, addNextInQueue } from '../../store/slices/queue';
|
import { addToQueue, addNextInQueue } from '../../store/slices/queue';
|
||||||
import { play } from '../../store/slices/player';
|
import { play } from '../../store/slices/player';
|
||||||
@@ -12,7 +19,13 @@ interface Props {
|
|||||||
onDownload?: (track: Track) => void;
|
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 dispatch = useAppDispatch();
|
||||||
|
|
||||||
const entry = {
|
const entry = {
|
||||||
@@ -27,18 +40,50 @@ export function TrackContextMenu({ track, onAddToPlaylist, onEditMetadata, onDel
|
|||||||
return (
|
return (
|
||||||
<Menu>
|
<Menu>
|
||||||
<MenuTrigger asChild>
|
<MenuTrigger asChild>
|
||||||
<IconButton variant="ghost" size="sm" aria-label="Track options">⋯</IconButton>
|
<IconButton variant="ghost" size="sm" aria-label="Track options">
|
||||||
|
⋯
|
||||||
|
</IconButton>
|
||||||
</MenuTrigger>
|
</MenuTrigger>
|
||||||
<MenuContent>
|
<MenuContent>
|
||||||
<MenuItem onSelect={() => { dispatch(play(track.id)); }}>Play now</MenuItem>
|
<MenuItem
|
||||||
<MenuItem onSelect={() => { dispatch(addNextInQueue(entry)); }}>Play next</MenuItem>
|
onSelect={() => {
|
||||||
<MenuItem onSelect={() => { dispatch(addToQueue(entry)); }}>Add to queue</MenuItem>
|
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 />
|
<MenuSeparator />
|
||||||
{onAddToPlaylist && <MenuItem onSelect={() => onAddToPlaylist(track)}>Add to playlist…</MenuItem>}
|
{onAddToPlaylist && (
|
||||||
|
<MenuItem onSelect={() => onAddToPlaylist(track)}>
|
||||||
|
Add to playlist…
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
<MenuSeparator />
|
<MenuSeparator />
|
||||||
{onEditMetadata && <MenuItem onSelect={() => onEditMetadata(track)}>Edit metadata</MenuItem>}
|
{onEditMetadata && (
|
||||||
{onDownload && <MenuItem onSelect={() => onDownload(track)}>Download</MenuItem>}
|
<MenuItem onSelect={() => onEditMetadata(track)}>
|
||||||
{onDelete && <MenuItem onSelect={() => onDelete(track)}>Delete</MenuItem>}
|
Edit metadata
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
{onDownload && (
|
||||||
|
<MenuItem onSelect={() => onDownload(track)}>Download</MenuItem>
|
||||||
|
)}
|
||||||
|
{onDelete && (
|
||||||
|
<MenuItem onSelect={() => onDelete(track)}>Delete</MenuItem>
|
||||||
|
)}
|
||||||
</MenuContent>
|
</MenuContent>
|
||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,7 +16,14 @@ interface Props {
|
|||||||
onDelete?: (track: Track) => void;
|
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 dispatch = useAppDispatch();
|
||||||
const currentTrackId = useAppSelector((s) => s.player.currentTrackId);
|
const currentTrackId = useAppSelector((s) => s.player.currentTrackId);
|
||||||
const isPlaying = useAppSelector((s) => s.player.isPlaying);
|
const isPlaying = useAppSelector((s) => s.player.isPlaying);
|
||||||
@@ -27,27 +34,77 @@ export function TrackRow({ track, index, showAlbum = false, onAddToPlaylist, onE
|
|||||||
<Row
|
<Row
|
||||||
selected={isActive}
|
selected={isActive}
|
||||||
onDoubleClick={() => dispatch(play(track.id))}
|
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' }}>
|
<span
|
||||||
{isActive && isPlaying ? '▶' : (index !== undefined ? index + 1 : '')}
|
style={{
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
textAlign: 'right',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isActive && isPlaying ? '▶' : index !== undefined ? index + 1 : ''}
|
||||||
</span>
|
</span>
|
||||||
{artUrl ? (
|
{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={{ 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}
|
{track.title}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
<div
|
||||||
{track.artistName}{showAlbum && ` · ${track.albumTitle}`}
|
style={{
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: 'var(--color-text-2)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{track.artistName}
|
||||||
|
{showAlbum && ` · ${track.albumTitle}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AvailabilityBadge availability={track.availability} />
|
<AvailabilityBadge availability={track.availability} />
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
<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)}
|
{formatDuration(track.durationMs)}
|
||||||
</span>
|
</span>
|
||||||
<TrackContextMenu
|
<TrackContextMenu
|
||||||
|
|||||||
+2
-1
@@ -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';
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -1,15 +1,22 @@
|
|||||||
import { DEFAULT_API_BASE_URL } from './env';
|
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 {
|
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 {
|
export function setApiBaseUrl(url: string): void {
|
||||||
localStorage.setItem(STORAGE_KEY, url);
|
const inst = upsertInstance(url);
|
||||||
}
|
setActiveInstanceId(inst.id);
|
||||||
|
|
||||||
export function clearApiBaseUrl(): void {
|
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { Window } from 'modern-sk';
|
import { Window } from 'modern-sk';
|
||||||
export function AdminPage() {
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { useParams, useNavigate } from 'react-router';
|
import { useParams, useNavigate } from 'react-router';
|
||||||
import { ScrollArea, IconButton, Button } from 'modern-sk';
|
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 { TrackRow } from '../../components/track/TrackRow';
|
||||||
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||||
import { ErrorState } from '../../components/common/ErrorState';
|
import { ErrorState } from '../../components/common/ErrorState';
|
||||||
@@ -19,11 +22,20 @@ export function AlbumDetailPage() {
|
|||||||
const tracksQuery = useGetAlbumTracksQuery(albumId ?? '', { skip: !albumId });
|
const tracksQuery = useGetAlbumTracksQuery(albumId ?? '', { skip: !albumId });
|
||||||
|
|
||||||
if (albumQuery.isLoading || tracksQuery.isLoading) {
|
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) {
|
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;
|
const album = albumQuery.data;
|
||||||
@@ -32,52 +44,139 @@ export function AlbumDetailPage() {
|
|||||||
|
|
||||||
const handlePlayAll = () => {
|
const handlePlayAll = () => {
|
||||||
if (!tracks.length || !album) return;
|
if (!tracks.length || !album) return;
|
||||||
dispatch(setQueue({
|
dispatch(
|
||||||
entries: tracks.map((t) => ({
|
setQueue({
|
||||||
trackId: t.id,
|
entries: tracks.map((t) => ({
|
||||||
title: t.title,
|
trackId: t.id,
|
||||||
artistName: t.artistName,
|
title: t.title,
|
||||||
albumTitle: t.albumTitle,
|
artistName: t.artistName,
|
||||||
durationMs: t.durationMs,
|
albumTitle: t.albumTitle,
|
||||||
albumArtUrl: t.albumArtUrl,
|
durationMs: t.durationMs,
|
||||||
})),
|
albumArtUrl: t.albumArtUrl,
|
||||||
source: 'album',
|
})),
|
||||||
sourceId: album.id,
|
source: 'album',
|
||||||
sourceName: album.title,
|
sourceId: album.id,
|
||||||
}));
|
sourceName: album.title,
|
||||||
|
}),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
{/* header */}
|
{/* header */}
|
||||||
<div style={{ padding: '1.25rem 1.5rem', borderBottom: '1px solid var(--color-border)', display: 'flex', alignItems: 'center', gap: '1rem', flexShrink: 0 }}>
|
<div
|
||||||
<IconButton variant="ghost" size="sm" onClick={() => navigate(-1)} aria-label="Back">←</IconButton>
|
style={{
|
||||||
<div style={{ display: 'flex', gap: '1.5rem', alignItems: 'flex-end', flex: 1 }}>
|
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 ? (
|
{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>
|
<div>
|
||||||
<p style={{ margin: 0, fontSize: '0.75rem', color: 'var(--color-text-3)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Album</p>
|
<p
|
||||||
<h1 style={{ margin: '0.25rem 0', fontSize: '1.5rem', fontWeight: 700 }}>{album?.title}</h1>
|
style={{
|
||||||
<p style={{ margin: 0, color: 'var(--color-text-2)', fontSize: '0.875rem' }}>
|
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?.artistName}
|
||||||
{album?.year && ` · ${album.year}`}
|
{album?.year && ` · ${album.year}`}
|
||||||
{album && ` · ${album.trackCount} tracks · ${formatDuration(album.totalDurationMs)}`}
|
{album &&
|
||||||
|
` · ${album.trackCount} tracks · ${formatDuration(album.totalDurationMs)}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="primary" onClick={handlePlayAll} disabled={!tracks.length}>▶ Play</Button>
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={handlePlayAll}
|
||||||
|
disabled={!tracks.length}
|
||||||
|
>
|
||||||
|
▶ Play
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* tracks */}
|
{/* tracks */}
|
||||||
<ScrollArea style={{ flex: 1 }}>
|
<ScrollArea style={{ flex: 1 }}>
|
||||||
{tracksQuery.isLoading && <LoadingSkeleton rows={10} />}
|
{tracksQuery.isLoading && <LoadingSkeleton rows={10} />}
|
||||||
{tracksQuery.isError && <ErrorState message="Failed to load tracks" onRetry={() => tracksQuery.refetch()} />}
|
{tracksQuery.isError && (
|
||||||
{!tracksQuery.isLoading && !tracksQuery.isError && tracks.length === 0 && (
|
<ErrorState
|
||||||
<EmptyState icon="♫" title="No tracks" description="This album has no tracks." />
|
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) => (
|
{tracks.map((track, i) => (
|
||||||
<TrackRow key={track.id} track={track} index={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
Reference in New Issue
Block a user