feat: migrate frontend to Astro + expand AI moderation + backend admin/runtime config
Frontend: - migrate from Vite to Astro (astro.config.mjs, pages/, layouts/) - add admin panel, settings page, command palette, error boundary - refactor App.tsx, MascotChatbot, Sidebar, Header, DashboardLayout - update API client, WebSocket, auth, dashboard features Backend: - add admin module and config routes - refactor middlewares, Redis connection, WebSocket server/bridge - add runtime config loader Discord Gateway: - refactor AI moderation: circuit breaker, concurrency limiter, fallback processor - add media analysis client, Seaxng search, user profile learner - add new drizzle migration Shared: - extend database schema, add new config fields
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
# Interaction Patterns — The Language of Touch
|
||||
|
||||
> *"Every interaction is a conversation between the user and the system."*
|
||||
> — Don Norman
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Filosofi Interaksi
|
||||
|
||||
Interaksi di BETE adalah **dialog** yang:
|
||||
1. **Predictable** — Pengguna tahu yang akan terjadi
|
||||
2. **Forgiving** — Kesalahan mudah diperbaiki (undo, confirm)
|
||||
3. **Feedback-rich** — Setiap aksi mendapat respons visual
|
||||
4. **Efficient** — Pengguna mahir bisa bergerak cepat (keyboard)
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Interaction Feedback Matrix
|
||||
|
||||
| Elemen | Hover | Click | Focus | Disabled |
|
||||
|--------|-------|-------|-------|----------|
|
||||
| Button | scale(1.02) + bg shift | scale(0.97) | ring-2 | opacity-50 |
|
||||
| Card | translateY(-2px) + shadow | — | ring-2 | opacity-50 |
|
||||
| Link | underline + opacity 0.8 | color shift | ring-2 | opacity-40 |
|
||||
| Input | border highlight | — | ring + border color | opacity-50 |
|
||||
| Toggle | cursor pointer | slide + color | ring-2 | opacity-50 |
|
||||
|
||||
### Timing Reference
|
||||
|
||||
| Interaksi | Durasi | Easing |
|
||||
|-----------|--------|--------|
|
||||
| Hover in | 150ms | ease-out |
|
||||
| Hover out | 200ms | ease-out |
|
||||
| Click press | 100ms | ease-out |
|
||||
| Click release | 150ms | ease-out |
|
||||
| Focus ring | 200ms | ease-out |
|
||||
| Tooltip show (after 300ms) | 200ms | ease-out |
|
||||
| Tooltip hide | 150ms | ease-out |
|
||||
|
||||
---
|
||||
|
||||
## 🎪 Interaction Pattern Catalog
|
||||
|
||||
### Pattern 1: Progressive Disclosure
|
||||
|
||||
Informasi kompleks diungkap bertahap:
|
||||
|
||||
```tsx
|
||||
<CollapsibleSection title="Advanced Filters" defaultOpen={false}>
|
||||
<FilterGroup label="Severity">
|
||||
<Checkbox label="Safe" />
|
||||
<Checkbox label="Low" />
|
||||
<Checkbox label="High" />
|
||||
</FilterGroup>
|
||||
</CollapsibleSection>
|
||||
```
|
||||
|
||||
**Rules:** Chevron rotate 180° saat open. Jangan nested > 2 level.
|
||||
|
||||
### Pattern 2: Optimistic UI
|
||||
|
||||
Untuk aksi yang hampir pasti berhasil:
|
||||
|
||||
```tsx
|
||||
async function handleDelete(messageId: string) {
|
||||
// 1. Update UI optimistis
|
||||
setMessages(prev => prev.filter(m => m.id !== messageId));
|
||||
addToast({
|
||||
type: 'info', title: 'Message deleted',
|
||||
action: { label: 'Undo', onClick: handleUndo }
|
||||
});
|
||||
try {
|
||||
await api.deleteMessage(messageId);
|
||||
} catch {
|
||||
// Rollback
|
||||
setMessages(prev => [...prev, deletedMessage]);
|
||||
addToast({ type: 'error', title: 'Failed to delete' });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 3: Infinite Scroll vs Pagination
|
||||
|
||||
| Context | Pattern | Rationale |
|
||||
|---------|---------|-----------|
|
||||
| Message feed | Infinite scroll | Real-time, chronological |
|
||||
| User list | Pagination | Bisa dicari, difilter |
|
||||
| Recordings | Infinite scroll | Timeline-based |
|
||||
| Analytics | Pagination | Butuh konteks halaman |
|
||||
|
||||
### Pattern 4: Keyboard Shortcuts
|
||||
|
||||
```tsx
|
||||
const SHORTCUTS = {
|
||||
'ctrl+k': 'Open command palette',
|
||||
'ctrl+1': 'Switch to Live tab',
|
||||
'ctrl+2': 'Switch to Messages tab',
|
||||
'ctrl+3': 'Switch to Settings tab',
|
||||
'escape': 'Close modal/panel',
|
||||
'?': 'Show keyboard shortcuts',
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔔 Notification Priority System
|
||||
|
||||
| Priority | Style | Duration | Stack |
|
||||
|----------|-------|----------|-------|
|
||||
| info | Blue border | 4s auto | Queue |
|
||||
| success | Green border | 4s auto | Queue |
|
||||
| warning | Amber border | Persistent | Stack |
|
||||
| error | Red border | Persistent | Stack + glow |
|
||||
|
||||
---
|
||||
|
||||
## 🖱️ Cursor Mapping
|
||||
|
||||
```css
|
||||
.clickable { cursor: pointer; }
|
||||
.draggable { cursor: grab; }
|
||||
.dragging { cursor: grabbing; }
|
||||
.disabled { cursor: not-allowed; }
|
||||
.text-select { cursor: text; }
|
||||
.launch { cursor: pointer; }
|
||||
.copy { cursor: copy; }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ♿ Interaction Accessibility
|
||||
|
||||
1. Semua interaktif reachable via Tab
|
||||
2. Focus order = visual order (DOM order)
|
||||
3. Hover-only → ada keyboard alternative
|
||||
4. Touch targets min 44x44px (WCAG 2.5.5)
|
||||
5. Undo untuk destructive actions
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Referensi
|
||||
|
||||
| Sumber | Konsep |
|
||||
|--------|--------|
|
||||
| [Material Interaction](https://m3.material.io/foundations/interaction) | Google patterns |
|
||||
| [NN Group](https://www.nngroup.com/) | UX research |
|
||||
| [Inclusive Components](https://inclusive-components.design/) | Accessible patterns |
|
||||
|
||||
---
|
||||
|
||||
*"Setiap sentuhan adalah dialog — interaksi adalah bahasa yang tak terucapkan."* ❄️🩵
|
||||
@@ -0,0 +1,171 @@
|
||||
# Data Visualization — Painting with Numbers
|
||||
|
||||
> *"The greatest value of a picture is when it forces us to notice what we never expected to see."*
|
||||
> — John Tukey
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Filosofi Data Visual
|
||||
|
||||
Data visualisasi di BETE adalah **cerita** tentang data yang:
|
||||
1. **Jujur** — Tidak memanipulasi sumbu atau skala
|
||||
2. **Kontekstual** — Setiap angka punya pembanding
|
||||
3. **Hierarkis** — Overview dulu, detail kemudian
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Chart Color Palette
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* Sequential (single hue) */
|
||||
--chart-blue-1: oklch(0.85 0.060 255);
|
||||
--chart-blue-2: oklch(0.70 0.100 255);
|
||||
--chart-blue-3: oklch(0.55 0.150 255);
|
||||
--chart-blue-4: oklch(0.40 0.150 255);
|
||||
--chart-blue-5: oklch(0.30 0.120 255);
|
||||
|
||||
/* Categorical */
|
||||
--chart-cat-1: oklch(0.55 0.175 255); /* Blue */
|
||||
--chart-cat-2: oklch(0.60 0.130 145); /* Green */
|
||||
--chart-cat-3: oklch(0.65 0.150 50); /* Orange */
|
||||
--chart-cat-4: oklch(0.55 0.165 25); /* Red */
|
||||
--chart-cat-5: oklch(0.50 0.100 285); /* Purple */
|
||||
--chart-cat-6: oklch(0.65 0.120 200); /* Cyan */
|
||||
--chart-cat-7: oklch(0.60 0.110 350); /* Pink */
|
||||
--chart-cat-8: oklch(0.70 0.100 85); /* Yellow */
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Chart Types & Usage
|
||||
|
||||
### 1. Stat Card (KPI)
|
||||
|
||||
```tsx
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: number | string;
|
||||
trend?: { direction: 'up' | 'down' | 'flat'; percentage: number; period: string; };
|
||||
icon: ReactNode;
|
||||
color?: 'primary' | 'success' | 'warning' | 'destructive';
|
||||
}
|
||||
```
|
||||
|
||||
**Layout:**
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ [icon] Label │
|
||||
│ 1,234 ▲ 12.3% │
|
||||
│ vs last wk │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
### 2. Line Chart (Trend)
|
||||
|
||||
**Use:** Message volume per day, moderation per hour
|
||||
**Rules:** Y-axis dari 0. Gradient subtle below line. Hover tooltip.
|
||||
|
||||
### 3. Bar Chart (Comparison)
|
||||
|
||||
**Use:** Top channels, severity distribution
|
||||
**Rules:** Horizontal untuk >5 kategori. Max 20 bars.
|
||||
|
||||
### 4. Donut Chart (Composition)
|
||||
|
||||
**Use:** Message type, severity breakdown
|
||||
**Rules:** Max 6 segmen. <3% collaps ke "Other". Center = total.
|
||||
|
||||
### 5. Heatmap Calendar (Activity)
|
||||
|
||||
**Use:** User activity by day/hour
|
||||
**Rules:** Sumbu X = hari, Y = jam. Satu warna accent.
|
||||
|
||||
---
|
||||
|
||||
## 📐 Chart Styling Tokens
|
||||
|
||||
```css
|
||||
.chart-container {
|
||||
--chart-padding: var(--sp-4);
|
||||
--chart-label-size: var(--fs-xs);
|
||||
--chart-tick-count: 5;
|
||||
--chart-grid-opacity: 0.1;
|
||||
--chart-line-width: 2px;
|
||||
}
|
||||
|
||||
.chart-tooltip {
|
||||
background: var(--clr-surface-overlay);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--clr-border);
|
||||
border-radius: var(--rd-md);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
font-size: var(--fs-sm);
|
||||
box-shadow: var(--sh-elevated);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Drill-down Pattern
|
||||
|
||||
```tsx
|
||||
function MessageTrendChart() {
|
||||
const [granularity, setGranularity] = useState<'daily' | 'hourly' | '15min'>('daily');
|
||||
|
||||
const handlePointClick = (date: Date) => {
|
||||
if (granularity === 'daily') setGranularity('hourly');
|
||||
else if (granularity === 'hourly') setGranularity('15min');
|
||||
};
|
||||
|
||||
return (
|
||||
<ChartCard title="Message Volume"
|
||||
onBack={granularity !== 'daily' ? () => setGranularity('daily') : undefined}>
|
||||
<LineChart data={data} granularity={granularity} onClick={handlePointClick} />
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Anti-Patterns
|
||||
|
||||
### ❌ Truncated Y-axis
|
||||
```tsx
|
||||
// ❌ Y axis mulai dari 50, memperbesar perbedaan
|
||||
const options = { yAxis: { min: 50 } };
|
||||
// ✅ Mulai dari 0
|
||||
const options = { yAxis: { min: 0 } };
|
||||
```
|
||||
|
||||
### ❌ Terlalu banyak warna
|
||||
```tsx
|
||||
// ❌ JANGAN — setiap bar beda warna
|
||||
<Bar data={data} fill={['#ff0000', '#00ff00', '#0000ff', ...]} />
|
||||
// ✅ Sequential scale
|
||||
<Bar data={data} colorScale="sequential" />
|
||||
```
|
||||
|
||||
### ❌ 3D charts — mendistorsi persepsi
|
||||
```tsx
|
||||
// ❌ JANGAN
|
||||
<PieChart><Pie data={data} style={{ filter: 'drop-shadow(...)' }} /></PieChart>
|
||||
// ✅ 2D
|
||||
<PieChart><Pie data={data} /></PieChart>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Referensi
|
||||
|
||||
| Sumber | Konsep |
|
||||
|--------|--------|
|
||||
| [Recharts](https://recharts.org/) | React chart library |
|
||||
| [Chartability](https://chartability.github.io/) | Accessible charts |
|
||||
| [Tufte](https://www.edwardtufte.com/tufte/) | Minimalist chart design |
|
||||
|
||||
---
|
||||
|
||||
*"Angka adalah ingatan yang terukur — setiap titik data adalah kisah yang menanti."* ❄️🩵
|
||||
@@ -0,0 +1,110 @@
|
||||
# Moderation UI Patterns — The Watchful Eye
|
||||
|
||||
> *"With great power comes great responsibility."*
|
||||
> — Adapted for content moderation interfaces.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Filosofi UI Moderasi
|
||||
|
||||
1. **At-a-glance severity** — Warna & label yang langsung terbaca
|
||||
2. **Context-rich** — Setiap keputusan disertai konteks
|
||||
3. **Non-destructive by default** — Flag dulu, action kemudian
|
||||
4. **Audit trail** — Setiap aksi tercatat
|
||||
|
||||
---
|
||||
|
||||
## 🏷️ Severity Scale
|
||||
|
||||
```css
|
||||
.severity--safe { background: oklch(0.60 0.130 145 / 0.15); color: oklch(0.60 0.130 145); }
|
||||
.severity--low { background: oklch(0.70 0.120 75 / 0.15); color: oklch(0.70 0.120 75); }
|
||||
.severity--medium { background: oklch(0.65 0.150 50 / 0.15); color: oklch(0.65 0.150 50); }
|
||||
.severity--high { background: oklch(0.60 0.150 30 / 0.15); color: oklch(0.60 0.150 30); }
|
||||
.severity--critical { background: oklch(0.55 0.165 25 / 0.15); color: oklch(0.55 0.165 25); }
|
||||
```
|
||||
|
||||
| Severity | Warna | Ikon | Action |
|
||||
|----------|-------|------|--------|
|
||||
| Safe | Emerald | ✅ Shield | None |
|
||||
| Low | Amber | ⚠️ | Review |
|
||||
| Medium | Orange | 🔶 | Alert + review |
|
||||
| High | Red-Orange | 🚫 | Notify + action |
|
||||
| Critical | Ruby | 🔴 | Immediate |
|
||||
|
||||
---
|
||||
|
||||
## 📋 Moderation Queue
|
||||
|
||||
```tsx
|
||||
interface ModerationQueueItem {
|
||||
id: string;
|
||||
message: { preview: string; author: { name: string; }; timestamp: number; channel: string; };
|
||||
analysis: { severity: Severity; categories: string[]; confidence: number; summary: string; };
|
||||
status: 'pending' | 'reviewed' | 'actioned' | 'dismissed';
|
||||
}
|
||||
```
|
||||
|
||||
**Layout per item:**
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ 🔴 CRITICAL │ [User]: "message preview..." │
|
||||
│ 🏷️ toxicity, │ in #general · 2m ago │
|
||||
│ harassment │ [Review] [Dismiss] [Action] │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Filter Bar
|
||||
```
|
||||
Severity: [All] [Safe] [Low] [Medium] [High] [Critical]
|
||||
Channel: [#general ▼]
|
||||
Date: [Last 24h ▼]
|
||||
Search: [.................. 🔍]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Action Confirmation
|
||||
|
||||
| Action | Confirm | Duration | Undo |
|
||||
|--------|---------|----------|------|
|
||||
| Dismiss | No | 2s toast | Yes (5s) |
|
||||
| Warn | No | 3s toast | No |
|
||||
| Delete | Yes (modal) | 4s toast | No |
|
||||
| Ban | Yes (modal + reason) | — | Manual |
|
||||
|
||||
---
|
||||
|
||||
## 📊 Moderation Metrics
|
||||
|
||||
| Metric | Format | Frequency |
|
||||
|--------|--------|-----------|
|
||||
| Messages analyzed | Number | Real-time |
|
||||
| Flag rate | % | Hourly |
|
||||
| Response time | ms avg | Real-time |
|
||||
| False positive rate | % | Daily |
|
||||
| Queue depth | Number | Real-time |
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Anti-Patterns
|
||||
|
||||
### ❌ Ambiguous severity
|
||||
```tsx
|
||||
// ❌ Warna tanpa label
|
||||
<div className="bg-red-200">...</div>
|
||||
// ✅ Color + icon + text
|
||||
<SeverityBadge severity="critical" />
|
||||
```
|
||||
|
||||
### ❌ One-click destructive
|
||||
```tsx
|
||||
// ❌ Delete tanpa konfirmasi
|
||||
<Button onClick={handleDelete}>Delete</Button>
|
||||
// ✅ Confirm dialog
|
||||
<ConfirmDialog variant="destructive" ... />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*"Mata waspada adalah penjaga ingatan — setiap flag adalah catatan sejarah."* ❄️🩵
|
||||
@@ -0,0 +1,307 @@
|
||||
# State Machines — The Flow of Data
|
||||
|
||||
> *"All happy families are alike; each unhappy family is unhappy in its own way."*
|
||||
> — Tolstoy, adapted for component states.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Filosofi State Machine
|
||||
|
||||
Setiap komponen data-driven di BETE memiliki **4 state fundamental**:
|
||||
|
||||
```
|
||||
IDLE → LOADING → SUCCESS
|
||||
↘ ERROR
|
||||
↘ EMPTY (conditional, jika data.length === 0)
|
||||
```
|
||||
|
||||
State machine memastikan **tidak ada kondisi yang terlewat** — setiap kemungkinan state visual memiliki representasi.
|
||||
|
||||
---
|
||||
|
||||
## 🎮 The Quad-State Pattern
|
||||
|
||||
```tsx
|
||||
type DataState<T> =
|
||||
| { status: 'idle' }
|
||||
| { status: 'loading'; progress?: number }
|
||||
| { status: 'success'; data: T; timestamp: number }
|
||||
| { status: 'error'; error: Error; retryCount?: number }
|
||||
| { status: 'empty'; message?: string };
|
||||
```
|
||||
|
||||
### Generic State Machine Hook
|
||||
|
||||
```tsx
|
||||
// shared/hooks/useDataState.ts
|
||||
function useDataState<T>(
|
||||
fetcher: () => Promise<T>,
|
||||
options?: {
|
||||
onSuccess?: (data: T) => void;
|
||||
onError?: (error: Error) => void;
|
||||
retry?: number;
|
||||
cacheKey?: string;
|
||||
}
|
||||
): {
|
||||
state: DataState<T>;
|
||||
execute: () => Promise<void>;
|
||||
reset: () => void;
|
||||
retry: () => Promise<void>;
|
||||
setData: (data: T) => void;
|
||||
} {
|
||||
const [state, setState] = useState<DataState<T>>({ status: 'idle' });
|
||||
|
||||
const execute = useCallback(async () => {
|
||||
setState({ status: 'loading' });
|
||||
try {
|
||||
const data = await fetcher();
|
||||
if (Array.isArray(data) && data.length === 0) {
|
||||
setState({ status: 'empty', message: 'No data available' });
|
||||
} else {
|
||||
setState({ status: 'success', data, timestamp: Date.now() });
|
||||
options?.onSuccess?.(data);
|
||||
}
|
||||
} catch (error) {
|
||||
setState({ status: 'error', error: error as Error });
|
||||
options?.onError?.(error as Error);
|
||||
}
|
||||
}, [fetcher]);
|
||||
|
||||
return { state, execute, reset, retry: execute, setData };
|
||||
}
|
||||
```
|
||||
|
||||
### Component Rendering
|
||||
|
||||
```tsx
|
||||
function DataPanel() {
|
||||
const { state, execute, retry } = useDataState(fetchMessages);
|
||||
|
||||
useEffect(() => { execute(); }, []);
|
||||
|
||||
switch (state.status) {
|
||||
case 'idle':
|
||||
case 'loading':
|
||||
return <LoadingSkeleton />;
|
||||
|
||||
case 'error':
|
||||
return (
|
||||
<ErrorState
|
||||
message={state.error.message}
|
||||
onRetry={retry}
|
||||
retryCount={state.retryCount}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'empty':
|
||||
return <EmptyState message={state.message ?? 'Nothing here'} />;
|
||||
|
||||
case 'success':
|
||||
return <DataView data={state.data} />;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🖼️ Visual Representations
|
||||
|
||||
### Loading State
|
||||
|
||||
```tsx
|
||||
interface LoadingSkeletonProps {
|
||||
variant?: 'card' | 'list' | 'detail' | 'table' | 'chart';
|
||||
count?: number; // Jumlah skeleton items
|
||||
}
|
||||
|
||||
/* Contoh variant 'card' */
|
||||
function CardSkeleton() {
|
||||
return (
|
||||
<div className="card animate-shimmer" aria-busy="true" aria-label="Loading...">
|
||||
<Skeleton className="h-4 w-3/4 mb-3" />
|
||||
<Skeleton className="h-3 w-1/2 mb-2" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Error State
|
||||
|
||||
```tsx
|
||||
interface ErrorStateProps {
|
||||
error: Error;
|
||||
onRetry: () => void;
|
||||
retryCount?: number;
|
||||
variant?: 'inline' | 'full-page' | 'toast';
|
||||
}
|
||||
|
||||
function ErrorState({ error, onRetry, retryCount }: ErrorStateProps) {
|
||||
const isRetryExhausted = (retryCount ?? 0) >= 3;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 py-12" role="alert">
|
||||
<div className="rounded-full bg-destructive/10 p-3">
|
||||
<AlertTriangle className="h-6 w-6 text-destructive" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-foreground">Something went wrong</p>
|
||||
<p className="text-xs text-muted-foreground">{error.message}</p>
|
||||
{!isRetryExhausted ? (
|
||||
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||
Try Again
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Still failing after multiple attempts. Please try again later.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Empty State
|
||||
|
||||
```tsx
|
||||
interface EmptyStateProps {
|
||||
icon?: ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: { label: string; onClick: () => void };
|
||||
variant?: 'mascot' | 'icon' | 'minimal';
|
||||
}
|
||||
|
||||
const EMPTY_STATES = {
|
||||
messages: { icon: MessageSquare, title: 'No messages yet', description: 'Messages will appear here once they are captured.' },
|
||||
speakers: { icon: Mic, title: 'No active speakers', description: 'Quiet in here...' },
|
||||
recordings: { icon: Radio, title: 'No recordings', description: 'Join a voice channel to start recording.' },
|
||||
analytics: { icon: BarChart3, title: 'Not enough data', description: 'Analytics will populate as data accumulates.' },
|
||||
users: { icon: Users, title: 'No users found', description: 'Try adjusting your filters.' },
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ♻️ State Transition Diagram
|
||||
|
||||
```
|
||||
┌──────────┐
|
||||
│ IDLE │
|
||||
└────┬─────┘
|
||||
│ execute()
|
||||
↓
|
||||
┌──────────┐
|
||||
│ LOADING │◄────── retry()
|
||||
└────┬─────┘
|
||||
│
|
||||
┌───────┴───────────┐
|
||||
│ │
|
||||
↓ ↓
|
||||
┌──────────┐ ┌──────────┐
|
||||
│ SUCCESS │ │ ERROR │
|
||||
│ data: T │ │ err: E │
|
||||
└────┬─────┘ └────┬─────┘
|
||||
│ │
|
||||
│ (data.length │ retry()
|
||||
│ === 0) │
|
||||
↓ │
|
||||
┌──────────┐ │
|
||||
│ EMPTY │ │
|
||||
│ msg: str │ │
|
||||
└──────────┘ │
|
||||
│ │
|
||||
└──────┬───────────┘
|
||||
│ reset()
|
||||
↓
|
||||
┌──────────┐
|
||||
│ IDLE │
|
||||
└──────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Retry Strategy
|
||||
|
||||
```typescript
|
||||
const RETRY_CONFIG = {
|
||||
maxAttempts: 3,
|
||||
baseDelay: 1000, // 1s
|
||||
maxDelay: 10000, // 10s
|
||||
backoff: 'exponential' as const,
|
||||
onRetry: (attempt: number, error: Error) => {
|
||||
logger.warn(`Retry attempt ${attempt}`, { error: error.message });
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Exponential Backoff
|
||||
|
||||
```typescript
|
||||
function calculateDelay(attempt: number): number {
|
||||
return Math.min(
|
||||
1000 * Math.pow(2, attempt - 1), // 1s, 2s, 4s
|
||||
10000 // cap at 10s
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 Component State Map
|
||||
|
||||
| Component | Loading | Error | Empty | Success |
|
||||
|-----------|---------|-------|-------|---------|
|
||||
| MessageFeed | Card skeletons | ErrorState + retry | Mascot "No messages" | Message list |
|
||||
| VoiceCards | Card skeletons | ErrorState | "No connected channels" | VoiceCard list |
|
||||
| ActiveSpeakers | Dot skeletons | Silent fallback | "No speakers" | Speaker list |
|
||||
| Analytics | Skeleton grid | ErrorState | "Not enough data" | Charts |
|
||||
| Recordings | List skeletons | ErrorState | "No recordings" | Recording list |
|
||||
| UserList | List skeletons | ErrorState + retry | "No users found" | User list |
|
||||
| DashboardStats | Stat skeletons | ErrorState | "No data available" | Stat grid |
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Anti-Patterns State
|
||||
|
||||
### ❌ Missing state handling
|
||||
```tsx
|
||||
// ❌ JANGAN — hanya handle SUCCESS
|
||||
function Panel() {
|
||||
const { data, isLoading } = useQuery(...);
|
||||
if (isLoading) return <Spinner />;
|
||||
return <DataView data={data} />; // ERROR? EMPTY?
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ Loading state after error
|
||||
```tsx
|
||||
// ❌ JANGAN — loading infinite loop setelah error
|
||||
function Panel() {
|
||||
const { data, isLoading } = useQuery(..., { retry: true });
|
||||
if (isLoading) return <Spinner />;
|
||||
// ERROR: retry=true + error = loading terus
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ Empty state default terlalu generic
|
||||
```tsx
|
||||
// ❌ JANGAN — tidak helpful
|
||||
<div>No data</div>
|
||||
|
||||
// ✅ Kontekstual dengan action
|
||||
<EmptyState icon={MessageSquare} title="No messages" action={{ label: "Refresh", onClick: refetch }} />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Referensi
|
||||
|
||||
| Sumber | Konsep |
|
||||
|--------|--------|
|
||||
| [State Reducer Pattern](https://kentcdodds.com/blog/state-reducer-pattern) | Advanced state management |
|
||||
| [XState](https://stately.ai/docs/xstate) | Visual state machines |
|
||||
| [React useReducer](https://react.dev/reference/react/useReducer) | Built-in state management |
|
||||
|
||||
---
|
||||
|
||||
*"Setiap state adalah babak dalam cerita data — dari sunyi hingga berbicara."* ❄️🩵
|
||||
@@ -0,0 +1,288 @@
|
||||
# Responsive System — Shapeshifting Glass
|
||||
|
||||
> *"Content is like water — it should flow into whatever container it's poured into."*
|
||||
> — Ethan Marcotte
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Filosofi Responsif
|
||||
|
||||
BETE menggunakan pendekatan **mobile-first** dengan tiga prinsip:
|
||||
|
||||
1. **Content parity** — Konten yang sama di semua ukuran, layout yang berbeda
|
||||
2. **Touch-optimized** — Target 44×44px minimum di mobile
|
||||
3. **Progressive enhancement** — Desktop mendapat fitur tambahan (hover, sidebar, multi-column)
|
||||
|
||||
---
|
||||
|
||||
## 📐 Breakpoint System
|
||||
|
||||
```css
|
||||
:root {
|
||||
--bp-sm: 640px; /* Mobile landscape */
|
||||
--bp-md: 768px; /* Tablet portrait */
|
||||
--bp-lg: 1024px; /* Tablet landscape / small desktop */
|
||||
--bp-xl: 1280px; /* Desktop */
|
||||
--bp-2xl: 1536px; /* Wide desktop */
|
||||
}
|
||||
```
|
||||
|
||||
### Layout Behavior Matrix
|
||||
|
||||
| Viewport | Sidebar | Header | Content Grid | Font Size |
|
||||
|----------|---------|--------|-------------|-----------|
|
||||
| < 640px | Bottom tab (56px) | Compact | 1 col | sm |
|
||||
| 640-768 | Bottom tab | Compact | 1-2 col | sm |
|
||||
| 768-1024 | Icon 64px | Standard | 2 col | base |
|
||||
| 1024-1280 | Full 256px | Standard | 2-3 col | base |
|
||||
| > 1280px | Full 256px | Full | 3-4 col | base+ |
|
||||
|
||||
---
|
||||
|
||||
## 📱 Mobile Adaptations
|
||||
|
||||
### Navigation
|
||||
- **< 768px:** Bottom tab bar menggantikan sidebar
|
||||
- **Tab icons:** Home, Live, Messages, Settings (maks 5 tabs)
|
||||
- **Tab bar height:** 56px (dengan safe area padding untuk notched phones)
|
||||
|
||||
### Content
|
||||
- **Cards:** Full-width (margin 16px), stacked vertical
|
||||
- **Tables:** Horizontal scroll atau card view alternatif
|
||||
- **Charts:** Simplified (less data points, larger labels)
|
||||
- **Modals:** Full-screen drawer dari bawah (bottom sheet)
|
||||
|
||||
### Touch Targets
|
||||
```css
|
||||
/* Minimum 44×44px untuk semua interactive elements */
|
||||
.button, .nav-item, .tab-item {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
/* Forms on mobile */
|
||||
.input, .select {
|
||||
height: 48px; /* Larger tap target */
|
||||
font-size: 16px; /* Prevent iOS zoom on focus */
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💻 Desktop Adaptations
|
||||
|
||||
### Navigation
|
||||
- **≥ 1024px:** Full sidebar (256px) dengan label teks
|
||||
- **Sidebar states:** Collapsed (icon-only, 64px) ↔ Expanded (256px)
|
||||
- **Keyboard shortcuts:** Didokumentasikan di help panel
|
||||
|
||||
### Content
|
||||
- **Multi-column grids:** 2-4 columns depending on container width
|
||||
- **Sticky elements:** Sidebar, header, filter bars
|
||||
- **Hover previews:** Tooltips, popovers untuk informasi tambahan
|
||||
- **Drag & drop:** Dukungan untuk reorder, upload area
|
||||
|
||||
---
|
||||
|
||||
## 🧩 Responsive Component Patterns
|
||||
|
||||
### Pattern 1: Responsive Card Grid
|
||||
|
||||
```css
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.card-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.card-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.card-grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: Responsive Sidebar + Content
|
||||
|
||||
```tsx
|
||||
function DashboardLayout() {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const isMobile = useMediaQuery('(max-width: 767px)');
|
||||
|
||||
return (
|
||||
<div className="page-layout">
|
||||
{/* Mobile: slide-in drawer */}
|
||||
{isMobile && (
|
||||
<MobileTabBar activeTab={activeTab} onTabChange={setActiveTab} />
|
||||
)}
|
||||
|
||||
{/* Desktop: persistent sidebar */}
|
||||
{!isMobile && (
|
||||
<Sidebar collapsed={sidebarCollapsed} onToggle={() => setSidebarCollapsed(!sidebarCollapsed)} />
|
||||
)}
|
||||
|
||||
<main className="main-area">
|
||||
<Header onMenuClick={() => setMobileMenuOpen(true)} />
|
||||
<div className="content-area">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 3: Responsive Typography (Fluid)
|
||||
|
||||
```css
|
||||
/* Fluid type scale — sudah didefinisikan di core/02-typography.md */
|
||||
--fs-body: clamp(0.94rem, 0.94rem + 0.03vw, 1.00rem);
|
||||
--fs-h2: clamp(1.50rem, 1.50rem + 0.12vw, 1.88rem);
|
||||
```
|
||||
|
||||
### Pattern 4: Container Queries (for reusable components)
|
||||
|
||||
```css
|
||||
.card-grid-component {
|
||||
container-type: inline-size;
|
||||
container-name: card-list;
|
||||
}
|
||||
|
||||
@container card-list (max-width: 400px) {
|
||||
.card-item { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@container card-list (min-width: 401px) {
|
||||
.card-item { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Responsive Decision Tree
|
||||
|
||||
```
|
||||
Layout component →
|
||||
├── Apakah ini navigasi?
|
||||
│ ├── Mobile → Bottom tab bar (56px)
|
||||
│ ├── Tablet → Icon sidebar (64px) + hamburger
|
||||
│ └── Desktop → Full sidebar (256px)
|
||||
│
|
||||
├── Apakah ini konten list/grid?
|
||||
│ ├── 1 item → Single column
|
||||
│ ├── 2-4 items → 2 col (tablet), 3-4 col (desktop)
|
||||
│ └── > 4 items → auto-fill grid with minmax
|
||||
│
|
||||
├── Apakah ini modal/dialog?
|
||||
│ ├── Mobile → Bottom sheet (full width, 80% height)
|
||||
│ └── Desktop → Centered modal (max-w-lg)
|
||||
│
|
||||
└── Apakah ini form?
|
||||
├── Mobile → Stacked, full-width, larger inputs
|
||||
└── Desktop → Multi-column, side labels
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📏 Responsive Spacing Scale
|
||||
|
||||
```css
|
||||
.content-padding {
|
||||
padding: var(--sp-3); /* Mobile: 16px */
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.content-padding { padding: var(--sp-4); } /* Tablet: 24px */
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.content-padding { padding: var(--sp-5); } /* Desktop: 32px */
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing Responsive Design
|
||||
|
||||
```typescript
|
||||
// Test utility untuk responsive behavior
|
||||
const VIEWPORTS = {
|
||||
mobile: { width: 375, height: 667 },
|
||||
tablet: { width: 768, height: 1024 },
|
||||
desktop: { width: 1280, height: 800 },
|
||||
wide: { width: 1920, height: 1080 },
|
||||
};
|
||||
|
||||
describe('DashboardLayout', () => {
|
||||
it('shows MobileTabBar on mobile', () => {
|
||||
cy.viewport(VIEWPORTS.mobile);
|
||||
cy.get('[data-testid="mobile-tab-bar"]').should('be.visible');
|
||||
cy.get('[data-testid="sidebar"]').should('not.be.visible');
|
||||
});
|
||||
|
||||
it('shows sidebar on desktop', () => {
|
||||
cy.viewport(VIEWPORTS.desktop);
|
||||
cy.get('[data-testid="sidebar"]').should('be.visible');
|
||||
cy.get('[data-testid="mobile-tab-bar"]').should('not.be.visible');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Anti-Patterns Responsive
|
||||
|
||||
### ❌ Hanya media query untuk satu breakpoint
|
||||
```css
|
||||
/* ❌ JANGAN — hanya mobile dan desktop */
|
||||
.panel { padding: 16px; }
|
||||
@media (min-width: 1024px) { .panel { padding: 32px; } }
|
||||
|
||||
/* ✅ Gunakan fluid atau multiple breakpoints */
|
||||
.panel { padding: clamp(16px, 3vw, 32px); }
|
||||
```
|
||||
|
||||
### ❌ Hidden content on mobile
|
||||
```tsx
|
||||
// ❌ JANGAN — "out of sight, out of mind" tapi konten hilang
|
||||
{isMobile ? null : <ExpensiveChart />}
|
||||
|
||||
// ✅ Simplified version untuk mobile
|
||||
<Chart variant={isMobile ? 'compact' : 'full'} />
|
||||
```
|
||||
|
||||
### ❌ Fixed width containers
|
||||
```css
|
||||
/* ❌ JANGAN — overflow on smaller screens */
|
||||
.container { width: 1200px; }
|
||||
|
||||
/* ✅ Gunakan max-width + padding */
|
||||
.container { max-width: 1200px; margin: 0 auto; padding: 0 var(--sp-4); }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Referensi
|
||||
|
||||
| Sumber | Konsep |
|
||||
|--------|--------|
|
||||
| [Every Layout](https://every-layout.dev/) | Reusable layout patterns |
|
||||
| [Container Queries](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_container_queries) | CSS container queries |
|
||||
| [Utopia.fyi](https://utopia.fyi/) | Fluid type & space calculator |
|
||||
|
||||
---
|
||||
|
||||
*"Layout adalah air yang mengalir — ia mengambil bentuk wadahnya tanpa kehilangan esensi."* ❄️🩵
|
||||
Reference in New Issue
Block a user