179 lines
5.3 KiB
Markdown
179 lines
5.3 KiB
Markdown
# 03 - Component Patterns
|
|
|
|
Established patterns for React components in YouTube Studio Flow. Follow these consistently. For CSS rules, see [[02 - CSS Conventions]]. For backend patterns, see [[04 - Backend Architecture Patterns]].
|
|
|
|
---
|
|
|
|
## File structure
|
|
|
|
Each component lives in two co-located files:
|
|
|
|
```
|
|
ComponentName.tsx
|
|
ComponentName.module.css
|
|
```
|
|
|
|
For complex components with internal sub-components, keep everything in one file unless a sub-component is genuinely reused elsewhere. Sub-components that exist only to decompose a large render function are not worth extracting.
|
|
|
|
---
|
|
|
|
## forwardRef pattern
|
|
|
|
Components that expose an imperative API (save, reset, isDirty) use `forwardRef` with a typed handle interface. The primary example is `VideoConfigEditor`.
|
|
|
|
```typescript
|
|
export interface MyComponentHandle {
|
|
save(): Promise<void>;
|
|
isDirty(): boolean;
|
|
}
|
|
|
|
const MyComponent = forwardRef<MyComponentHandle, Props>((props, ref) => {
|
|
useImperativeHandle(ref, () => ({
|
|
save: async () => {
|
|
// ...
|
|
},
|
|
isDirty: () => isDirty,
|
|
}));
|
|
|
|
// ...
|
|
});
|
|
```
|
|
|
|
The parent calls `configRef.current.save()` from a unified "Save Changes" button. There is no separate save button per sub-component — config and video metadata save together through a single user action.
|
|
|
|
---
|
|
|
|
## TanStack Query
|
|
|
|
### Rules
|
|
|
|
- Call `useQueryClient()` at the component top level, never inside a callback or effect.
|
|
- Query keys are always arrays: `['videos']`, `['video', id]`, `['blocks']`.
|
|
- Always invalidate related queries in `onSuccess`.
|
|
- Use `placeholderData: (prev) => prev` to keep stale data visible during page transitions and prevent content flash.
|
|
|
|
### Standard mutation pattern
|
|
|
|
```typescript
|
|
const qc = useQueryClient();
|
|
|
|
const mut = useMutation({
|
|
mutationFn: () => updateVideo(id, form),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ['video', id] });
|
|
qc.invalidateQueries({ queryKey: ['videos'] });
|
|
},
|
|
});
|
|
```
|
|
|
|
### Query with stable placeholder
|
|
|
|
```typescript
|
|
const { data } = useQuery({
|
|
queryKey: ['videos', page, filters],
|
|
queryFn: () => fetchVideos({ page, ...filters }),
|
|
placeholderData: (prev) => prev,
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Modal pattern
|
|
|
|
Use the shared `<Modal>` component for all overlays. Do not implement custom dialog/overlay solutions.
|
|
|
|
```tsx
|
|
import Modal from '@/components/shared/Modal';
|
|
|
|
{isOpen && (
|
|
<Modal title="Edit Block" onClose={() => setIsOpen(false)} width={480}>
|
|
{/* modal content */}
|
|
</Modal>
|
|
)}
|
|
```
|
|
|
|
The `width` prop accepts a pixel number. Standard widths: `480` for forms, `600` for wider editors. The modal handles backdrop click, `Escape` key, and focus trap.
|
|
|
|
---
|
|
|
|
## Row-level navigation in tables
|
|
|
|
For table rows that must support both left-click (navigate via router) and middle-click (open in background tab), use an absolutely positioned anchor overlay inside the first cell:
|
|
|
|
```tsx
|
|
<tr className={styles.clickableRow}>
|
|
<td>
|
|
<a
|
|
href={`/videos/${id}`}
|
|
className={styles.rowOverlayLink}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
router.push(`/videos/${id}`);
|
|
}}
|
|
tabIndex={-1}
|
|
aria-hidden="true"
|
|
/>
|
|
{/* actual cell content */}
|
|
</td>
|
|
</tr>
|
|
```
|
|
|
|
```css
|
|
.clickableRow {
|
|
position: relative;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.rowOverlayLink {
|
|
position: absolute;
|
|
inset: 0;
|
|
z-index: 1;
|
|
}
|
|
```
|
|
|
|
The anchor covers the entire row. Left-click is intercepted by `onClick` and delegates to `router.push` for client-side navigation. Middle-click bypasses the handler entirely, which gives native browser behavior — the link opens in a background tab on Windows. Interactive elements within the row (buttons, checkboxes) sit above the overlay via their own stacking context or `z-index`.
|
|
|
|
---
|
|
|
|
## Icon libraries
|
|
|
|
| Library | Import | Use for |
|
|
|---|---|---|
|
|
| `lucide-react` | `import { Save, X, AlertCircle } from 'lucide-react'` | General UI icons |
|
|
| `react-icons/fa` | `import { FaYoutube } from 'react-icons/fa'` | YouTube icon only |
|
|
| `react-icons/si` | `import { SiTwitch, SiInstagram, SiTiktok, SiX, SiBluesky, SiDiscord } from 'react-icons/si'` | Platform brand icons |
|
|
|
|
**`SiYoutube` does not exist in `react-icons/si` v5.** Always use `FaYoutube` from `react-icons/fa` for the YouTube icon.
|
|
|
|
---
|
|
|
|
## Zustand stores
|
|
|
|
Read specific slices to avoid unnecessary re-renders:
|
|
|
|
```typescript
|
|
// Preferred — subscribe only to what you need:
|
|
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
|
|
const sidebarCollapsed = useUIStore((s) => s.sidebarCollapsed);
|
|
|
|
// Also acceptable when you need several values:
|
|
const { sidebarCollapsed, toggleSidebar } = useUIStore();
|
|
```
|
|
|
|
Auth state comes from `useAuthStore` (`src/store/useAuthStore.ts`). This store holds the current user, `teamId`, and JWT token. The Axios interceptor in `api-client.ts` reads the token from this store automatically — you do not need to attach it manually to requests.
|
|
|
|
---
|
|
|
|
## Sidebar collapse
|
|
|
|
`Sidebar.tsx` has two display states:
|
|
|
|
| State | Width | Content |
|
|
|---|---|---|
|
|
| Expanded | 280px (`--sidebar-width`) | Icon + label for each nav item |
|
|
| Collapsed | 64px (`--sidebar-width-collapsed`) | Icon only |
|
|
|
|
State is persisted to `localStorage` via Zustand's persist middleware under the key `ui-store`. The collapse toggle is a small circular button on the right border of the sidebar, visible only on hover.
|
|
|
|
When adding new navigation items, provide both the icon (always) and the label (hidden when collapsed via CSS, not conditional rendering).
|