Add in-memory API route for stream filter configuration and update frontend to manage filter settings dynamically

This commit is contained in:
Nikita
2025-12-25 10:14:38 -08:00
parent 9aecbab8a9
commit 9b0026b8d4
3 changed files with 183 additions and 35 deletions

View File

@@ -33,6 +33,7 @@ import { Route as ApiUsageEventsRouteImport } from './routes/api/usage-events'
import { Route as ApiStreamStatusRouteImport } from './routes/api/stream-status'
import { Route as ApiStreamReplaysRouteImport } from './routes/api/stream-replays'
import { Route as ApiStreamRecordingRouteImport } from './routes/api/stream-recording'
import { Route as ApiStreamFilterRouteImport } from './routes/api/stream-filter'
import { Route as ApiStreamCommentsRouteImport } from './routes/api/stream-comments'
import { Route as ApiStreamRouteImport } from './routes/api/stream'
import { Route as ApiProfileRouteImport } from './routes/api/profile'
@@ -199,6 +200,11 @@ const ApiStreamRecordingRoute = ApiStreamRecordingRouteImport.update({
path: '/api/stream-recording',
getParentRoute: () => rootRouteImport,
} as any)
const ApiStreamFilterRoute = ApiStreamFilterRouteImport.update({
id: '/api/stream-filter',
path: '/api/stream-filter',
getParentRoute: () => rootRouteImport,
} as any)
const ApiStreamCommentsRoute = ApiStreamCommentsRouteImport.update({
id: '/api/stream-comments',
path: '/api/stream-comments',
@@ -459,6 +465,7 @@ export interface FileRoutesByFullPath {
'/api/profile': typeof ApiProfileRoute
'/api/stream': typeof ApiStreamRouteWithChildren
'/api/stream-comments': typeof ApiStreamCommentsRoute
'/api/stream-filter': typeof ApiStreamFilterRoute
'/api/stream-recording': typeof ApiStreamRecordingRoute
'/api/stream-replays': typeof ApiStreamReplaysRouteWithChildren
'/api/stream-status': typeof ApiStreamStatusRoute
@@ -529,6 +536,7 @@ export interface FileRoutesByTo {
'/api/profile': typeof ApiProfileRoute
'/api/stream': typeof ApiStreamRouteWithChildren
'/api/stream-comments': typeof ApiStreamCommentsRoute
'/api/stream-filter': typeof ApiStreamFilterRoute
'/api/stream-recording': typeof ApiStreamRecordingRoute
'/api/stream-replays': typeof ApiStreamReplaysRouteWithChildren
'/api/stream-status': typeof ApiStreamStatusRoute
@@ -601,6 +609,7 @@ export interface FileRoutesById {
'/api/profile': typeof ApiProfileRoute
'/api/stream': typeof ApiStreamRouteWithChildren
'/api/stream-comments': typeof ApiStreamCommentsRoute
'/api/stream-filter': typeof ApiStreamFilterRoute
'/api/stream-recording': typeof ApiStreamRecordingRoute
'/api/stream-replays': typeof ApiStreamReplaysRouteWithChildren
'/api/stream-status': typeof ApiStreamStatusRoute
@@ -674,6 +683,7 @@ export interface FileRouteTypes {
| '/api/profile'
| '/api/stream'
| '/api/stream-comments'
| '/api/stream-filter'
| '/api/stream-recording'
| '/api/stream-replays'
| '/api/stream-status'
@@ -744,6 +754,7 @@ export interface FileRouteTypes {
| '/api/profile'
| '/api/stream'
| '/api/stream-comments'
| '/api/stream-filter'
| '/api/stream-recording'
| '/api/stream-replays'
| '/api/stream-status'
@@ -815,6 +826,7 @@ export interface FileRouteTypes {
| '/api/profile'
| '/api/stream'
| '/api/stream-comments'
| '/api/stream-filter'
| '/api/stream-recording'
| '/api/stream-replays'
| '/api/stream-status'
@@ -887,6 +899,7 @@ export interface RootRouteChildren {
ApiProfileRoute: typeof ApiProfileRoute
ApiStreamRoute: typeof ApiStreamRouteWithChildren
ApiStreamCommentsRoute: typeof ApiStreamCommentsRoute
ApiStreamFilterRoute: typeof ApiStreamFilterRoute
ApiStreamRecordingRoute: typeof ApiStreamRecordingRoute
ApiStreamReplaysRoute: typeof ApiStreamReplaysRouteWithChildren
ApiStreamStatusRoute: typeof ApiStreamStatusRoute
@@ -1086,6 +1099,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiStreamRecordingRouteImport
parentRoute: typeof rootRouteImport
}
'/api/stream-filter': {
id: '/api/stream-filter'
path: '/api/stream-filter'
fullPath: '/api/stream-filter'
preLoaderRoute: typeof ApiStreamFilterRouteImport
parentRoute: typeof rootRouteImport
}
'/api/stream-comments': {
id: '/api/stream-comments'
path: '/api/stream-comments'
@@ -1580,6 +1600,7 @@ const rootRouteChildren: RootRouteChildren = {
ApiProfileRoute: ApiProfileRoute,
ApiStreamRoute: ApiStreamRouteWithChildren,
ApiStreamCommentsRoute: ApiStreamCommentsRoute,
ApiStreamFilterRoute: ApiStreamFilterRoute,
ApiStreamRecordingRoute: ApiStreamRecordingRoute,
ApiStreamReplaysRoute: ApiStreamReplaysRouteWithChildren,
ApiStreamStatusRoute: ApiStreamStatusRoute,

View File

@@ -1,47 +1,60 @@
import { json } from "@tanstack/react-start"
import type { APIContext } from "@tanstack/react-router"
import { createFileRoute } from "@tanstack/react-router"
const json = (data: unknown, status = 200) =>
new Response(JSON.stringify(data), {
status,
headers: {
"content-type": "application/json",
"access-control-allow-origin": "*",
"cache-control": "no-store",
},
})
/**
* Get or update stream filter configuration (allowed/blocked apps)
*
* GET: Returns current filter config from Jazz (or hardcoded default)
* PUT: Updates filter config in Jazz
* Stream filter configuration for nikiv
* Stores in-memory (will connect to Jazz later)
*/
// Hardcoded default for nikiv (will be in Jazz later)
const DEFAULT_FILTER = {
// In-memory store (will be replaced with Jazz)
let currentFilter = {
allowedApps: ["zed", "cursor", "xcode", "safari", "warp", "warpPreview"],
blockedApps: ["1password", "keychain", "telegram"],
blockedApps: ["1password", "keychain"],
audioApps: ["spotify", "arc"],
version: 1,
updatedAt: Date.now(),
}
export async function GET({ request }: APIContext) {
try {
// TODO: Read from Jazz when worker is set up
return json(DEFAULT_FILTER)
} catch (error) {
return json({ error: "Failed to fetch filter config" }, { status: 500 })
}
}
export const Route = createFileRoute("/api/stream-filter")({
server: {
handlers: {
GET: async () => {
return json(currentFilter)
},
POST: async ({ request }) => {
try {
const body = await request.json()
const { allowedApps, blockedApps, audioApps } = body
export async function PUT({ request }: APIContext) {
try {
const body = await request.json()
const { allowedApps, blockedApps, audioApps } = body
// Update the filter
currentFilter = {
allowedApps: allowedApps || [],
blockedApps: blockedApps || [],
audioApps: audioApps || [],
version: currentFilter.version + 1,
updatedAt: Date.now(),
}
// TODO: Write to Jazz when worker is set up
// For now, return the updated config
return json({
success: true,
allowedApps: allowedApps || [],
blockedApps: blockedApps || [],
audioApps: audioApps || [],
version: DEFAULT_FILTER.version + 1,
updatedAt: Date.now(),
})
} catch (error) {
return json({ error: "Failed to update filter config" }, { status: 500 })
}
}
console.log(`[stream-filter] Updated config v${currentFilter.version}:`, currentFilter)
return json({
success: true,
...currentFilter,
})
} catch (error) {
console.error("[stream-filter] Update failed:", error)
return json({ error: "Failed to update filter config" }, 500)
}
},
},
},
})

View File

@@ -525,6 +525,14 @@ function StreamingSection({ username }: { username: string | null | undefined })
const [customerCode, setCustomerCode] = useState("")
const [streamKey, setStreamKey] = useState("")
// Filter settings
const [allowedApps, setAllowedApps] = useState<string[]>([])
const [blockedApps, setBlockedApps] = useState<string[]>([])
const [audioApps, setAudioApps] = useState<string[]>([])
const [filterVersion, setFilterVersion] = useState(0)
const [filterSaving, setFilterSaving] = useState(false)
const [filterSaved, setFilterSaved] = useState(false)
useEffect(() => {
const fetchSettings = async () => {
try {
@@ -543,7 +551,24 @@ function StreamingSection({ username }: { username: string | null | undefined })
setLoading(false)
}
}
const fetchFilterConfig = async () => {
try {
const res = await fetch("/api/stream-filter")
if (res.ok) {
const data = await res.json()
setAllowedApps(data.allowedApps || [])
setBlockedApps(data.blockedApps || [])
setAudioApps(data.audioApps || [])
setFilterVersion(data.version || 0)
}
} catch {
// Ignore errors
}
}
fetchSettings()
fetchFilterConfig()
}, [])
const handleSave = async () => {
@@ -582,6 +607,32 @@ function StreamingSection({ username }: { username: string | null | undefined })
setTimeout(() => setCopied(false), 2000)
}
const handleFilterSave = async () => {
setFilterSaving(true)
setFilterSaved(false)
try {
const res = await fetch("/api/stream-filter", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
allowedApps,
blockedApps,
audioApps,
}),
})
if (res.ok) {
const data = await res.json()
setFilterVersion(data.version)
setFilterSaved(true)
setTimeout(() => setFilterSaved(false), 2000)
}
} catch {
// Ignore errors
} finally {
setFilterSaving(false)
}
}
const streamUrl = username ? `https://linsa.io/${username}` : null
return (
@@ -657,6 +708,69 @@ function StreamingSection({ username }: { username: string | null | undefined })
</div>
</SettingCard>
<SettingCard title="Stream Filters">
<div className="space-y-4 py-2">
<div className="p-3 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
<p className="text-sm text-yellow-300">
Control which apps appear in your stream. Changes apply live without restart.
</p>
</div>
<div className="space-y-2">
<label className="text-sm text-white/70">Allowed Apps (comma-separated)</label>
<input
type="text"
value={allowedApps.join(", ")}
onChange={(e) => setAllowedApps(e.target.value.split(",").map(s => s.trim()).filter(Boolean))}
placeholder="e.g., zed, cursor, safari, warp"
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-teal-500"
/>
<p className="text-xs text-white/50">
Only these apps will be visible in the stream. Leave empty to allow all.
</p>
</div>
<div className="space-y-2">
<label className="text-sm text-white/70">Blocked Apps (comma-separated)</label>
<input
type="text"
value={blockedApps.join(", ")}
onChange={(e) => setBlockedApps(e.target.value.split(",").map(s => s.trim()).filter(Boolean))}
placeholder="e.g., 1password, telegram, keychain"
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-teal-500"
/>
<p className="text-xs text-white/50">
These apps will be hidden from the stream even if allowed.
</p>
</div>
<div className="space-y-2">
<label className="text-sm text-white/70">Audio Apps (comma-separated)</label>
<input
type="text"
value={audioApps.join(", ")}
onChange={(e) => setAudioApps(e.target.value.split(",").map(s => s.trim()).filter(Boolean))}
placeholder="e.g., spotify, arc"
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-teal-500"
/>
<p className="text-xs text-white/50">
Apps to capture audio from.
</p>
</div>
<div className="flex justify-between items-center pt-2">
<span className="text-xs text-white/50">Config version: {filterVersion}</span>
<div className="flex items-center gap-2">
{filterSaved && <span className="text-sm text-teal-400 flex items-center gap-1"><Check className="w-4 h-4" /> Saved</span>}
<button
type="button"
onClick={handleFilterSave}
disabled={filterSaving}
className="px-4 py-2 rounded-lg text-sm font-semibold text-white bg-teal-600 hover:bg-teal-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{filterSaving ? "Saving..." : "Save Filters"}
</button>
</div>
</div>
</div>
</SettingCard>
<SettingCard title="Your Stream">
<div className="space-y-4 py-2">
{streamUrl && (