A lexicon-driven AppView for ATProto. happyview.dev
backfill firehose jetstream atproto appview oauth lexicon
8
fork

Configure Feed

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

feat: add control for experiments to the dashboard

Trezy 3af84706 077c74bc

+122
+96
web/src/app/dashboard/settings/experiments/page.tsx
··· 1 + "use client" 2 + 3 + import { useCallback, useEffect, useState } from "react" 4 + import { IconAlertTriangle } from "@tabler/icons-react" 5 + 6 + import { useCurrentUser } from "@/hooks/use-current-user" 7 + import { getFeatureFlags, setFeatureFlag, type FeatureFlag } from "@/lib/api" 8 + import { SiteHeader } from "@/components/site-header" 9 + import { Switch } from "@/components/ui/switch" 10 + import { Label } from "@/components/ui/label" 11 + 12 + export default function ExperimentsPage() { 13 + const { hasPermission } = useCurrentUser() 14 + const canManage = hasPermission("settings:manage") 15 + 16 + const [flags, setFlags] = useState<FeatureFlag[]>([]) 17 + const [error, setError] = useState<string | null>(null) 18 + const [toggling, setToggling] = useState<string | null>(null) 19 + 20 + const load = useCallback(async () => { 21 + try { 22 + const data = await getFeatureFlags() 23 + setFlags(data) 24 + } catch (e: unknown) { 25 + setError(e instanceof Error ? e.message : String(e)) 26 + } 27 + }, []) 28 + 29 + useEffect(() => { 30 + load() 31 + }, [load]) 32 + 33 + async function handleToggle(flag: FeatureFlag) { 34 + setError(null) 35 + setToggling(flag.key) 36 + try { 37 + await setFeatureFlag(flag.key, !flag.enabled) 38 + await load() 39 + } catch (e: unknown) { 40 + setError(e instanceof Error ? e.message : String(e)) 41 + } finally { 42 + setToggling(null) 43 + } 44 + } 45 + 46 + return ( 47 + <> 48 + <SiteHeader title="Experiments" /> 49 + <div className="flex flex-1 flex-col gap-6 p-4 md:p-6 max-w-3xl"> 50 + <div className="flex items-start gap-3 rounded-lg border border-yellow-500/30 bg-yellow-500/5 p-4"> 51 + <IconAlertTriangle className="mt-0.5 size-5 shrink-0 text-yellow-600 dark:text-yellow-500" /> 52 + <div className="text-sm"> 53 + <p className="font-medium text-yellow-600 dark:text-yellow-500"> 54 + Experimental features 55 + </p> 56 + <p className="mt-1 text-muted-foreground"> 57 + These features are under active development. They may be 58 + incomplete, change without notice, or be removed entirely. 59 + Disabling a feature hides its routes and UI — no data is deleted. 60 + </p> 61 + </div> 62 + </div> 63 + 64 + {error && <p className="text-destructive text-sm">{error}</p>} 65 + 66 + {flags.map((flag) => ( 67 + <div 68 + key={flag.key} 69 + className="flex items-start justify-between gap-4 rounded-lg border p-4" 70 + > 71 + <div className="flex flex-col gap-1"> 72 + <Label htmlFor={flag.key} className="text-sm font-medium"> 73 + {flag.name} 74 + </Label> 75 + <p className="text-muted-foreground text-xs"> 76 + {flag.description} 77 + </p> 78 + </div> 79 + <Switch 80 + id={flag.key} 81 + checked={flag.enabled} 82 + onCheckedChange={() => handleToggle(flag)} 83 + disabled={!canManage || toggling === flag.key} 84 + /> 85 + </div> 86 + ))} 87 + 88 + {flags.length === 0 && !error && ( 89 + <p className="text-muted-foreground text-sm"> 90 + No experimental features available. 91 + </p> 92 + )} 93 + </div> 94 + </> 95 + ) 96 + }
+7
web/src/components/app-sidebar.tsx
··· 19 19 IconArrowUpCircle, 20 20 IconArrowsShuffle, 21 21 IconSkull, 22 + IconFlask, 22 23 } from "@tabler/icons-react"; 23 24 import Image from "next/image"; 24 25 import Link from "next/link"; ··· 122 123 url: "/dashboard/events", 123 124 icon: IconClipboardList, 124 125 requiredPermissions: ["events:read"], 126 + }, 127 + { 128 + title: "Experiments", 129 + url: "/dashboard/settings/experiments", 130 + icon: IconFlask, 131 + requiredPermissions: ["settings:manage"], 125 132 }, 126 133 ]; 127 134
+19
web/src/lib/api.ts
··· 338 338 return apiFetch("/admin/settings/logo", { method: "DELETE" }) 339 339 } 340 340 341 + // Feature Flags 342 + export type FeatureFlag = { 343 + key: string 344 + name: string 345 + description: string 346 + enabled: boolean 347 + } 348 + 349 + export function getFeatureFlags() { 350 + return apiFetch<FeatureFlag[]>("/admin/feature-flags") 351 + } 352 + 353 + export function setFeatureFlag(key: string, enabled: boolean) { 354 + if (enabled) { 355 + return upsertSetting(key, "true") 356 + } 357 + return deleteSetting(key) 358 + } 359 + 341 360 // Proxy config 342 361 export type ProxyConfig = { 343 362 mode: "disabled" | "open" | "allowlist" | "blocklist"