mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-11 11:50:25 +01:00
Add in-memory API route for stream filter configuration and update frontend to manage filter settings dynamically
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user