Barazo default frontend barazo.forum
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(frontend): add community rules admin and public pages (#214)

Admin page (/admin/rules):
- CRUD for community rules with inline form
- Drag-free reorder via up/down arrows with save button
- Version history note on edits
- Soft-delete (archive) with confirmation

Public page (/rules):
- Server-rendered page listing active rules in order
- Accessible to all visitors (no auth required)

Also adds:
- API client functions for all 6 rules endpoints
- CommunityRule types and related interfaces
- Rules nav item in admin sidebar

Closes singi-labs/barazo-workspace#97

authored by

Guido X Jansen and committed by
GitHub
0d70f390 7268bb1f

+556
+372
src/app/admin/rules/page.tsx
··· 1 + /** 2 + * Admin community rules management page. 3 + * URL: /admin/rules 4 + * CRUD for named, ordered rules with version history. 5 + */ 6 + 7 + 'use client' 8 + // Needs useState, useEffect, useCallback for data management and user interactions. 9 + 10 + import { useState, useEffect, useCallback } from 'react' 11 + import { Plus, ArrowUp, ArrowDown, Pencil, Trash, Clock } from '@phosphor-icons/react' 12 + import { AdminLayout } from '@/components/admin/admin-layout' 13 + import { ErrorAlert } from '@/components/error-alert' 14 + import { SaveButton } from '@/components/admin/save-button' 15 + import { useSaveState } from '@/hooks/use-save-state' 16 + import { useAuth } from '@/hooks/use-auth' 17 + import { useToast } from '@/hooks/use-toast' 18 + import { 19 + getCommunityRules, 20 + createCommunityRule, 21 + updateCommunityRule, 22 + deleteCommunityRule, 23 + reorderCommunityRules, 24 + getCommunitySettings, 25 + } from '@/lib/api/client' 26 + import type { CommunityRule } from '@/lib/api/types' 27 + import { FormLabel } from '@/components/ui/form-label' 28 + 29 + export default function AdminRulesPage() { 30 + const { getAccessToken } = useAuth() 31 + const { toast } = useToast() 32 + const [rules, setRules] = useState<CommunityRule[]>([]) 33 + const [communityDid, setCommunityDid] = useState<string | null>(null) 34 + const [loading, setLoading] = useState(true) 35 + const [loadError, setLoadError] = useState<string | null>(null) 36 + const [actionError, setActionError] = useState<string | null>(null) 37 + 38 + // Edit/create state 39 + const [editingRule, setEditingRule] = useState<CommunityRule | null>(null) 40 + const [isCreating, setIsCreating] = useState(false) 41 + const [formTitle, setFormTitle] = useState('') 42 + const [formDescription, setFormDescription] = useState('') 43 + const saveSt = useSaveState() 44 + 45 + // Reorder state 46 + const [hasReorderChanges, setHasReorderChanges] = useState(false) 47 + const reorderSaveSt = useSaveState() 48 + 49 + const fetchData = useCallback(async () => { 50 + setLoadError(null) 51 + try { 52 + const token = getAccessToken() ?? '' 53 + const settings = await getCommunitySettings(token) 54 + const did = settings.communityDid 55 + if (!did) { 56 + setLoadError('Community not initialized.') 57 + return 58 + } 59 + setCommunityDid(did) 60 + const response = await getCommunityRules(did) 61 + setRules(response.data) 62 + } catch { 63 + setLoadError('Failed to load rules. The API may be unreachable.') 64 + } finally { 65 + setLoading(false) 66 + } 67 + }, [getAccessToken]) 68 + 69 + useEffect(() => { 70 + void fetchData() 71 + }, [fetchData]) 72 + 73 + const handleCreate = async () => { 74 + if (!communityDid || !formTitle.trim() || !formDescription.trim()) return 75 + setActionError(null) 76 + saveSt.startSaving() 77 + try { 78 + await createCommunityRule( 79 + communityDid, 80 + { 81 + title: formTitle.trim(), 82 + description: formDescription.trim(), 83 + }, 84 + getAccessToken() ?? '' 85 + ) 86 + saveSt.onSaved() 87 + setIsCreating(false) 88 + setFormTitle('') 89 + setFormDescription('') 90 + toast({ title: 'Rule created' }) 91 + void fetchData() 92 + } catch { 93 + saveSt.reset() 94 + setActionError('Failed to create rule.') 95 + } 96 + } 97 + 98 + const handleUpdate = async () => { 99 + if (!communityDid || !editingRule || !formTitle.trim() || !formDescription.trim()) return 100 + setActionError(null) 101 + saveSt.startSaving() 102 + try { 103 + await updateCommunityRule( 104 + communityDid, 105 + editingRule.id, 106 + { 107 + title: formTitle.trim(), 108 + description: formDescription.trim(), 109 + }, 110 + getAccessToken() ?? '' 111 + ) 112 + saveSt.onSaved() 113 + setEditingRule(null) 114 + setFormTitle('') 115 + setFormDescription('') 116 + toast({ title: 'Rule updated (new version created)' }) 117 + void fetchData() 118 + } catch { 119 + saveSt.reset() 120 + setActionError('Failed to update rule.') 121 + } 122 + } 123 + 124 + const handleDelete = async (ruleId: number) => { 125 + if (!communityDid) return 126 + const confirmed = window.confirm( 127 + 'Are you sure you want to archive this rule? Historical references will be preserved.' 128 + ) 129 + if (!confirmed) return 130 + setActionError(null) 131 + try { 132 + await deleteCommunityRule(communityDid, ruleId, getAccessToken() ?? '') 133 + toast({ title: 'Rule archived' }) 134 + void fetchData() 135 + } catch { 136 + setActionError('Failed to archive rule.') 137 + } 138 + } 139 + 140 + const handleMoveUp = (index: number) => { 141 + if (index === 0) return 142 + const updated = [...rules] 143 + const prev = updated[index - 1] 144 + const curr = updated[index] 145 + if (!prev || !curr) return 146 + updated[index - 1] = curr 147 + updated[index] = prev 148 + setRules(updated) 149 + setHasReorderChanges(true) 150 + } 151 + 152 + const handleMoveDown = (index: number) => { 153 + if (index >= rules.length - 1) return 154 + const updated = [...rules] 155 + const next = updated[index + 1] 156 + const curr = updated[index] 157 + if (!next || !curr) return 158 + updated[index + 1] = curr 159 + updated[index] = next 160 + setRules(updated) 161 + setHasReorderChanges(true) 162 + } 163 + 164 + const handleSaveOrder = async () => { 165 + if (!communityDid) return 166 + reorderSaveSt.startSaving() 167 + try { 168 + await reorderCommunityRules( 169 + communityDid, 170 + { 171 + order: rules.map((rule, idx) => ({ id: rule.id, displayOrder: idx })), 172 + }, 173 + getAccessToken() ?? '' 174 + ) 175 + reorderSaveSt.onSaved() 176 + setHasReorderChanges(false) 177 + toast({ title: 'Rules reordered' }) 178 + } catch { 179 + reorderSaveSt.reset() 180 + setActionError('Failed to save order.') 181 + } 182 + } 183 + 184 + const startEditing = (rule: CommunityRule) => { 185 + setEditingRule(rule) 186 + setIsCreating(false) 187 + setFormTitle(rule.title) 188 + setFormDescription(rule.description) 189 + saveSt.reset() 190 + } 191 + 192 + const startCreating = () => { 193 + setEditingRule(null) 194 + setIsCreating(true) 195 + setFormTitle('') 196 + setFormDescription('') 197 + saveSt.reset() 198 + } 199 + 200 + const cancelForm = () => { 201 + setEditingRule(null) 202 + setIsCreating(false) 203 + setFormTitle('') 204 + setFormDescription('') 205 + saveSt.reset() 206 + } 207 + 208 + const isFormOpen = isCreating || editingRule !== null 209 + 210 + return ( 211 + <AdminLayout> 212 + <div className="space-y-6"> 213 + <div className="flex items-center justify-between"> 214 + <h1 className="text-2xl font-bold text-foreground">Community Rules</h1> 215 + {!isFormOpen && ( 216 + <button 217 + type="button" 218 + onClick={startCreating} 219 + className="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90" 220 + > 221 + <Plus size={16} aria-hidden="true" /> 222 + Add Rule 223 + </button> 224 + )} 225 + </div> 226 + 227 + {actionError && <ErrorAlert message={actionError} onDismiss={() => setActionError(null)} />} 228 + {loadError && ( 229 + <ErrorAlert message={loadError} variant="page" onRetry={() => void fetchData()} /> 230 + )} 231 + 232 + {/* Create/Edit form */} 233 + {isFormOpen && ( 234 + <div className="rounded-lg border border-border bg-card p-4 space-y-4"> 235 + <h2 className="text-lg font-semibold text-foreground"> 236 + {isCreating ? 'New Rule' : 'Edit Rule'} 237 + </h2> 238 + {editingRule && ( 239 + <p className="text-xs text-muted-foreground flex items-center gap-1"> 240 + <Clock size={12} aria-hidden="true" /> 241 + Editing creates a new version. Historical references are preserved. 242 + </p> 243 + )} 244 + <div> 245 + <FormLabel htmlFor="rule-title" required> 246 + Title 247 + </FormLabel> 248 + <input 249 + id="rule-title" 250 + type="text" 251 + value={formTitle} 252 + onChange={(e) => setFormTitle(e.target.value)} 253 + maxLength={200} 254 + required 255 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 256 + placeholder="e.g., Be respectful" 257 + /> 258 + </div> 259 + <div> 260 + <FormLabel htmlFor="rule-description" required> 261 + Description 262 + </FormLabel> 263 + <textarea 264 + id="rule-description" 265 + value={formDescription} 266 + onChange={(e) => setFormDescription(e.target.value)} 267 + required 268 + rows={4} 269 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 270 + placeholder="Full rule text visible to members..." 271 + /> 272 + </div> 273 + <div className="flex items-center gap-2"> 274 + <SaveButton 275 + status={saveSt.status} 276 + onClick={() => void (isCreating ? handleCreate() : handleUpdate())} 277 + label={isCreating ? 'Create Rule' : 'Save Changes'} 278 + /> 279 + <button 280 + type="button" 281 + onClick={cancelForm} 282 + className="rounded-md border border-border px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-muted" 283 + > 284 + Cancel 285 + </button> 286 + </div> 287 + </div> 288 + )} 289 + 290 + {loading && <p className="text-sm text-muted-foreground">Loading rules...</p>} 291 + 292 + {!loading && rules.length === 0 && !isFormOpen && ( 293 + <p className="py-8 text-center text-muted-foreground"> 294 + No community rules yet. Rules help members understand expected behavior and give 295 + moderators structured reasons for actions. 296 + </p> 297 + )} 298 + 299 + {/* Rules list */} 300 + {!loading && rules.length > 0 && ( 301 + <div className="space-y-2"> 302 + {rules.map((rule, index) => ( 303 + <div 304 + key={rule.id} 305 + className="flex items-start gap-3 rounded-lg border border-border bg-card p-4" 306 + > 307 + <div className="flex flex-col gap-1"> 308 + <button 309 + type="button" 310 + onClick={() => handleMoveUp(index)} 311 + disabled={index === 0} 312 + className="rounded p-1 text-muted-foreground transition-colors hover:bg-muted disabled:opacity-30" 313 + aria-label={`Move "${rule.title}" up`} 314 + > 315 + <ArrowUp size={16} /> 316 + </button> 317 + <button 318 + type="button" 319 + onClick={() => handleMoveDown(index)} 320 + disabled={index === rules.length - 1} 321 + className="rounded p-1 text-muted-foreground transition-colors hover:bg-muted disabled:opacity-30" 322 + aria-label={`Move "${rule.title}" down`} 323 + > 324 + <ArrowDown size={16} /> 325 + </button> 326 + </div> 327 + <div className="flex-1 min-w-0"> 328 + <div className="flex items-center gap-2"> 329 + <span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground"> 330 + {index + 1} 331 + </span> 332 + <h3 className="font-semibold text-foreground">{rule.title}</h3> 333 + </div> 334 + <p className="mt-1 text-sm text-muted-foreground line-clamp-2"> 335 + {rule.description} 336 + </p> 337 + </div> 338 + <div className="flex items-center gap-1"> 339 + <button 340 + type="button" 341 + onClick={() => startEditing(rule)} 342 + className="rounded p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" 343 + aria-label={`Edit "${rule.title}"`} 344 + > 345 + <Pencil size={16} /> 346 + </button> 347 + <button 348 + type="button" 349 + onClick={() => void handleDelete(rule.id)} 350 + className="rounded p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive" 351 + aria-label={`Archive "${rule.title}"`} 352 + > 353 + <Trash size={16} /> 354 + </button> 355 + </div> 356 + </div> 357 + ))} 358 + </div> 359 + )} 360 + 361 + {/* Reorder save button */} 362 + {hasReorderChanges && ( 363 + <SaveButton 364 + status={reorderSaveSt.status} 365 + onClick={() => void handleSaveOrder()} 366 + label="Save Order" 367 + /> 368 + )} 369 + </div> 370 + </AdminLayout> 371 + ) 372 + }
+57
src/app/rules/page.tsx
··· 1 + /** 2 + * Public community rules page. 3 + * URL: /rules 4 + * Displays active community rules visible to all members. 5 + */ 6 + 7 + import { getCommunityRules, getPublicSettings } from '@/lib/api/client' 8 + import type { Metadata } from 'next' 9 + 10 + export const dynamic = 'force-dynamic' 11 + 12 + export const metadata: Metadata = { 13 + title: 'Community Rules', 14 + description: 'Community rules and guidelines for participation.', 15 + } 16 + 17 + export default async function RulesPage() { 18 + const settings = await getPublicSettings() 19 + const communityDid = settings.communityDid 20 + 21 + if (!communityDid) { 22 + return ( 23 + <main className="mx-auto max-w-2xl px-4 py-12"> 24 + <h1 className="text-2xl font-bold text-foreground">Community Rules</h1> 25 + <p className="mt-4 text-muted-foreground">Community not yet configured.</p> 26 + </main> 27 + ) 28 + } 29 + 30 + const response = await getCommunityRules(communityDid) 31 + const rules = response.data 32 + 33 + return ( 34 + <main className="mx-auto max-w-2xl px-4 py-12"> 35 + <h1 className="text-2xl font-bold text-foreground">Community Rules</h1> 36 + {rules.length === 0 ? ( 37 + <p className="mt-4 text-muted-foreground">No community rules have been defined yet.</p> 38 + ) : ( 39 + <ol className="mt-6 space-y-6"> 40 + {rules.map((rule, index) => ( 41 + <li key={rule.id} className="rounded-lg border border-border bg-card p-4"> 42 + <div className="flex items-center gap-2"> 43 + <span className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary"> 44 + {index + 1} 45 + </span> 46 + <h2 className="text-lg font-semibold text-foreground">{rule.title}</h2> 47 + </div> 48 + <p className="mt-2 text-sm text-muted-foreground whitespace-pre-wrap"> 49 + {rule.description} 50 + </p> 51 + </li> 52 + ))} 53 + </ol> 54 + )} 55 + </main> 56 + ) 57 + }
+2
src/components/admin/admin-layout.tsx
··· 26 26 ShieldWarning, 27 27 SealCheck, 28 28 List, 29 + ListNumbers, 29 30 X, 30 31 } from '@phosphor-icons/react' 31 32 import { cn } from '@/lib/utils' ··· 39 40 { href: '/admin/categories', label: 'Categories', icon: FolderSimple }, 40 41 { href: '/admin/pages', label: 'Pages', icon: Article }, 41 42 { href: '/admin/moderation', label: 'Moderation', icon: ShieldCheck }, 43 + { href: '/admin/rules', label: 'Rules', icon: ListNumbers }, 42 44 { href: '/admin/sybil-detection', label: 'Sybil Detection', icon: ShieldWarning }, 43 45 { href: '/admin/trust-seeds', label: 'Trust Seeds', icon: SealCheck }, 44 46 { href: '/admin/settings', label: 'Settings', icon: Gear },
+82
src/lib/api/client.ts
··· 74 74 BehavioralFlagsResponse, 75 75 BehavioralFlag, 76 76 ReactionsResponse, 77 + CommunityRule, 78 + CommunityRulesResponse, 79 + CreateRuleInput, 80 + UpdateRuleInput, 81 + ReorderRulesInput, 77 82 } from './types' 78 83 79 84 /** Client: relative URLs (empty string). Server: internal Docker network URL. */ ··· 1489 1494 headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 1490 1495 body: params, 1491 1496 }) 1497 + } 1498 + 1499 + // --- Community Rules endpoints --- 1500 + 1501 + export function getCommunityRules( 1502 + communityDid: string, 1503 + options?: FetchOptions 1504 + ): Promise<CommunityRulesResponse> { 1505 + return apiFetch<CommunityRulesResponse>( 1506 + `/api/communities/${encodeURIComponent(communityDid)}/rules`, 1507 + options 1508 + ) 1509 + } 1510 + 1511 + export function createCommunityRule( 1512 + communityDid: string, 1513 + input: CreateRuleInput, 1514 + accessToken: string, 1515 + options?: FetchOptions 1516 + ): Promise<CommunityRule> { 1517 + return apiFetch<CommunityRule>(`/api/communities/${encodeURIComponent(communityDid)}/rules`, { 1518 + ...options, 1519 + method: 'POST', 1520 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 1521 + body: input, 1522 + }) 1523 + } 1524 + 1525 + export function updateCommunityRule( 1526 + communityDid: string, 1527 + ruleId: number, 1528 + input: UpdateRuleInput, 1529 + accessToken: string, 1530 + options?: FetchOptions 1531 + ): Promise<CommunityRule> { 1532 + return apiFetch<CommunityRule>( 1533 + `/api/communities/${encodeURIComponent(communityDid)}/rules/${ruleId}`, 1534 + { 1535 + ...options, 1536 + method: 'PUT', 1537 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 1538 + body: input, 1539 + } 1540 + ) 1541 + } 1542 + 1543 + export function deleteCommunityRule( 1544 + communityDid: string, 1545 + ruleId: number, 1546 + accessToken: string, 1547 + options?: FetchOptions 1548 + ): Promise<{ success: boolean }> { 1549 + return apiFetch<{ success: boolean }>( 1550 + `/api/communities/${encodeURIComponent(communityDid)}/rules/${ruleId}`, 1551 + { 1552 + ...options, 1553 + method: 'DELETE', 1554 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 1555 + } 1556 + ) 1557 + } 1558 + 1559 + export function reorderCommunityRules( 1560 + communityDid: string, 1561 + input: ReorderRulesInput, 1562 + accessToken: string, 1563 + options?: FetchOptions 1564 + ): Promise<{ success: boolean }> { 1565 + return apiFetch<{ success: boolean }>( 1566 + `/api/communities/${encodeURIComponent(communityDid)}/rules/reorder`, 1567 + { 1568 + ...options, 1569 + method: 'PUT', 1570 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 1571 + body: input, 1572 + } 1573 + ) 1492 1574 } 1493 1575 1494 1576 export { ApiError }
+43
src/lib/api/types.ts
··· 862 862 flags: BehavioralFlag[] 863 863 } 864 864 865 + // --- Community Rules --- 866 + 867 + export interface CommunityRule { 868 + id: number 869 + title: string 870 + description: string 871 + displayOrder: number 872 + createdAt: string 873 + updatedAt: string 874 + archivedAt: string | null 875 + } 876 + 877 + export interface CommunityRulesResponse { 878 + data: CommunityRule[] 879 + } 880 + 881 + export interface CommunityRuleVersion { 882 + id: number 883 + ruleId: number 884 + title: string 885 + description: string 886 + createdAt: string 887 + } 888 + 889 + export interface CommunityRuleVersionsResponse { 890 + data: CommunityRuleVersion[] 891 + cursor: string | null 892 + } 893 + 894 + export interface CreateRuleInput { 895 + title: string 896 + description: string 897 + } 898 + 899 + export interface UpdateRuleInput { 900 + title: string 901 + description: string 902 + } 903 + 904 + export interface ReorderRulesInput { 905 + order: Array<{ id: number; displayOrder: number }> 906 + } 907 + 865 908 // --- Shared --- 866 909 867 910 export type MaturityRating = 'safe' | 'mature' | 'adult'