Initial commit: YouTube Studio Flow (backend, frontend, infrastructure, docs)
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
# 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).
|
||||
Reference in New Issue
Block a user