mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-11 20:00:23 +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 ApiStreamStatusRouteImport } from './routes/api/stream-status'
|
||||||
import { Route as ApiStreamReplaysRouteImport } from './routes/api/stream-replays'
|
import { Route as ApiStreamReplaysRouteImport } from './routes/api/stream-replays'
|
||||||
import { Route as ApiStreamRecordingRouteImport } from './routes/api/stream-recording'
|
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 ApiStreamCommentsRouteImport } from './routes/api/stream-comments'
|
||||||
import { Route as ApiStreamRouteImport } from './routes/api/stream'
|
import { Route as ApiStreamRouteImport } from './routes/api/stream'
|
||||||
import { Route as ApiProfileRouteImport } from './routes/api/profile'
|
import { Route as ApiProfileRouteImport } from './routes/api/profile'
|
||||||
@@ -199,6 +200,11 @@ const ApiStreamRecordingRoute = ApiStreamRecordingRouteImport.update({
|
|||||||
path: '/api/stream-recording',
|
path: '/api/stream-recording',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ApiStreamFilterRoute = ApiStreamFilterRouteImport.update({
|
||||||
|
id: '/api/stream-filter',
|
||||||
|
path: '/api/stream-filter',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const ApiStreamCommentsRoute = ApiStreamCommentsRouteImport.update({
|
const ApiStreamCommentsRoute = ApiStreamCommentsRouteImport.update({
|
||||||
id: '/api/stream-comments',
|
id: '/api/stream-comments',
|
||||||
path: '/api/stream-comments',
|
path: '/api/stream-comments',
|
||||||
@@ -459,6 +465,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/api/profile': typeof ApiProfileRoute
|
'/api/profile': typeof ApiProfileRoute
|
||||||
'/api/stream': typeof ApiStreamRouteWithChildren
|
'/api/stream': typeof ApiStreamRouteWithChildren
|
||||||
'/api/stream-comments': typeof ApiStreamCommentsRoute
|
'/api/stream-comments': typeof ApiStreamCommentsRoute
|
||||||
|
'/api/stream-filter': typeof ApiStreamFilterRoute
|
||||||
'/api/stream-recording': typeof ApiStreamRecordingRoute
|
'/api/stream-recording': typeof ApiStreamRecordingRoute
|
||||||
'/api/stream-replays': typeof ApiStreamReplaysRouteWithChildren
|
'/api/stream-replays': typeof ApiStreamReplaysRouteWithChildren
|
||||||
'/api/stream-status': typeof ApiStreamStatusRoute
|
'/api/stream-status': typeof ApiStreamStatusRoute
|
||||||
@@ -529,6 +536,7 @@ export interface FileRoutesByTo {
|
|||||||
'/api/profile': typeof ApiProfileRoute
|
'/api/profile': typeof ApiProfileRoute
|
||||||
'/api/stream': typeof ApiStreamRouteWithChildren
|
'/api/stream': typeof ApiStreamRouteWithChildren
|
||||||
'/api/stream-comments': typeof ApiStreamCommentsRoute
|
'/api/stream-comments': typeof ApiStreamCommentsRoute
|
||||||
|
'/api/stream-filter': typeof ApiStreamFilterRoute
|
||||||
'/api/stream-recording': typeof ApiStreamRecordingRoute
|
'/api/stream-recording': typeof ApiStreamRecordingRoute
|
||||||
'/api/stream-replays': typeof ApiStreamReplaysRouteWithChildren
|
'/api/stream-replays': typeof ApiStreamReplaysRouteWithChildren
|
||||||
'/api/stream-status': typeof ApiStreamStatusRoute
|
'/api/stream-status': typeof ApiStreamStatusRoute
|
||||||
@@ -601,6 +609,7 @@ export interface FileRoutesById {
|
|||||||
'/api/profile': typeof ApiProfileRoute
|
'/api/profile': typeof ApiProfileRoute
|
||||||
'/api/stream': typeof ApiStreamRouteWithChildren
|
'/api/stream': typeof ApiStreamRouteWithChildren
|
||||||
'/api/stream-comments': typeof ApiStreamCommentsRoute
|
'/api/stream-comments': typeof ApiStreamCommentsRoute
|
||||||
|
'/api/stream-filter': typeof ApiStreamFilterRoute
|
||||||
'/api/stream-recording': typeof ApiStreamRecordingRoute
|
'/api/stream-recording': typeof ApiStreamRecordingRoute
|
||||||
'/api/stream-replays': typeof ApiStreamReplaysRouteWithChildren
|
'/api/stream-replays': typeof ApiStreamReplaysRouteWithChildren
|
||||||
'/api/stream-status': typeof ApiStreamStatusRoute
|
'/api/stream-status': typeof ApiStreamStatusRoute
|
||||||
@@ -674,6 +683,7 @@ export interface FileRouteTypes {
|
|||||||
| '/api/profile'
|
| '/api/profile'
|
||||||
| '/api/stream'
|
| '/api/stream'
|
||||||
| '/api/stream-comments'
|
| '/api/stream-comments'
|
||||||
|
| '/api/stream-filter'
|
||||||
| '/api/stream-recording'
|
| '/api/stream-recording'
|
||||||
| '/api/stream-replays'
|
| '/api/stream-replays'
|
||||||
| '/api/stream-status'
|
| '/api/stream-status'
|
||||||
@@ -744,6 +754,7 @@ export interface FileRouteTypes {
|
|||||||
| '/api/profile'
|
| '/api/profile'
|
||||||
| '/api/stream'
|
| '/api/stream'
|
||||||
| '/api/stream-comments'
|
| '/api/stream-comments'
|
||||||
|
| '/api/stream-filter'
|
||||||
| '/api/stream-recording'
|
| '/api/stream-recording'
|
||||||
| '/api/stream-replays'
|
| '/api/stream-replays'
|
||||||
| '/api/stream-status'
|
| '/api/stream-status'
|
||||||
@@ -815,6 +826,7 @@ export interface FileRouteTypes {
|
|||||||
| '/api/profile'
|
| '/api/profile'
|
||||||
| '/api/stream'
|
| '/api/stream'
|
||||||
| '/api/stream-comments'
|
| '/api/stream-comments'
|
||||||
|
| '/api/stream-filter'
|
||||||
| '/api/stream-recording'
|
| '/api/stream-recording'
|
||||||
| '/api/stream-replays'
|
| '/api/stream-replays'
|
||||||
| '/api/stream-status'
|
| '/api/stream-status'
|
||||||
@@ -887,6 +899,7 @@ export interface RootRouteChildren {
|
|||||||
ApiProfileRoute: typeof ApiProfileRoute
|
ApiProfileRoute: typeof ApiProfileRoute
|
||||||
ApiStreamRoute: typeof ApiStreamRouteWithChildren
|
ApiStreamRoute: typeof ApiStreamRouteWithChildren
|
||||||
ApiStreamCommentsRoute: typeof ApiStreamCommentsRoute
|
ApiStreamCommentsRoute: typeof ApiStreamCommentsRoute
|
||||||
|
ApiStreamFilterRoute: typeof ApiStreamFilterRoute
|
||||||
ApiStreamRecordingRoute: typeof ApiStreamRecordingRoute
|
ApiStreamRecordingRoute: typeof ApiStreamRecordingRoute
|
||||||
ApiStreamReplaysRoute: typeof ApiStreamReplaysRouteWithChildren
|
ApiStreamReplaysRoute: typeof ApiStreamReplaysRouteWithChildren
|
||||||
ApiStreamStatusRoute: typeof ApiStreamStatusRoute
|
ApiStreamStatusRoute: typeof ApiStreamStatusRoute
|
||||||
@@ -1086,6 +1099,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof ApiStreamRecordingRouteImport
|
preLoaderRoute: typeof ApiStreamRecordingRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
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': {
|
'/api/stream-comments': {
|
||||||
id: '/api/stream-comments'
|
id: '/api/stream-comments'
|
||||||
path: '/api/stream-comments'
|
path: '/api/stream-comments'
|
||||||
@@ -1580,6 +1600,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
ApiProfileRoute: ApiProfileRoute,
|
ApiProfileRoute: ApiProfileRoute,
|
||||||
ApiStreamRoute: ApiStreamRouteWithChildren,
|
ApiStreamRoute: ApiStreamRouteWithChildren,
|
||||||
ApiStreamCommentsRoute: ApiStreamCommentsRoute,
|
ApiStreamCommentsRoute: ApiStreamCommentsRoute,
|
||||||
|
ApiStreamFilterRoute: ApiStreamFilterRoute,
|
||||||
ApiStreamRecordingRoute: ApiStreamRecordingRoute,
|
ApiStreamRecordingRoute: ApiStreamRecordingRoute,
|
||||||
ApiStreamReplaysRoute: ApiStreamReplaysRouteWithChildren,
|
ApiStreamReplaysRoute: ApiStreamReplaysRouteWithChildren,
|
||||||
ApiStreamStatusRoute: ApiStreamStatusRoute,
|
ApiStreamStatusRoute: ApiStreamStatusRoute,
|
||||||
|
|||||||
@@ -1,47 +1,60 @@
|
|||||||
import { json } from "@tanstack/react-start"
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
import type { APIContext } 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)
|
* Stream filter configuration for nikiv
|
||||||
*
|
* Stores in-memory (will connect to Jazz later)
|
||||||
* GET: Returns current filter config from Jazz (or hardcoded default)
|
|
||||||
* PUT: Updates filter config in Jazz
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Hardcoded default for nikiv (will be in Jazz later)
|
// In-memory store (will be replaced with Jazz)
|
||||||
const DEFAULT_FILTER = {
|
let currentFilter = {
|
||||||
allowedApps: ["zed", "cursor", "xcode", "safari", "warp", "warpPreview"],
|
allowedApps: ["zed", "cursor", "xcode", "safari", "warp", "warpPreview"],
|
||||||
blockedApps: ["1password", "keychain", "telegram"],
|
blockedApps: ["1password", "keychain"],
|
||||||
audioApps: ["spotify", "arc"],
|
audioApps: ["spotify", "arc"],
|
||||||
version: 1,
|
version: 1,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET({ request }: APIContext) {
|
export const Route = createFileRoute("/api/stream-filter")({
|
||||||
try {
|
server: {
|
||||||
// TODO: Read from Jazz when worker is set up
|
handlers: {
|
||||||
return json(DEFAULT_FILTER)
|
GET: async () => {
|
||||||
} catch (error) {
|
return json(currentFilter)
|
||||||
return json({ error: "Failed to fetch filter config" }, { status: 500 })
|
},
|
||||||
}
|
POST: async ({ request }) => {
|
||||||
}
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const { allowedApps, blockedApps, audioApps } = body
|
||||||
|
|
||||||
export async function PUT({ request }: APIContext) {
|
// Update the filter
|
||||||
try {
|
currentFilter = {
|
||||||
const body = await request.json()
|
allowedApps: allowedApps || [],
|
||||||
const { allowedApps, blockedApps, audioApps } = body
|
blockedApps: blockedApps || [],
|
||||||
|
audioApps: audioApps || [],
|
||||||
|
version: currentFilter.version + 1,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: Write to Jazz when worker is set up
|
console.log(`[stream-filter] Updated config v${currentFilter.version}:`, currentFilter)
|
||||||
// For now, return the updated config
|
|
||||||
return json({
|
return json({
|
||||||
success: true,
|
success: true,
|
||||||
allowedApps: allowedApps || [],
|
...currentFilter,
|
||||||
blockedApps: blockedApps || [],
|
})
|
||||||
audioApps: audioApps || [],
|
} catch (error) {
|
||||||
version: DEFAULT_FILTER.version + 1,
|
console.error("[stream-filter] Update failed:", error)
|
||||||
updatedAt: Date.now(),
|
return json({ error: "Failed to update filter config" }, 500)
|
||||||
})
|
}
|
||||||
} catch (error) {
|
},
|
||||||
return json({ error: "Failed to update filter config" }, { status: 500 })
|
},
|
||||||
}
|
},
|
||||||
}
|
})
|
||||||
|
|||||||
@@ -525,6 +525,14 @@ function StreamingSection({ username }: { username: string | null | undefined })
|
|||||||
const [customerCode, setCustomerCode] = useState("")
|
const [customerCode, setCustomerCode] = useState("")
|
||||||
const [streamKey, setStreamKey] = 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(() => {
|
useEffect(() => {
|
||||||
const fetchSettings = async () => {
|
const fetchSettings = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -543,7 +551,24 @@ function StreamingSection({ username }: { username: string | null | undefined })
|
|||||||
setLoading(false)
|
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()
|
fetchSettings()
|
||||||
|
fetchFilterConfig()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
@@ -582,6 +607,32 @@ function StreamingSection({ username }: { username: string | null | undefined })
|
|||||||
setTimeout(() => setCopied(false), 2000)
|
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
|
const streamUrl = username ? `https://linsa.io/${username}` : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -657,6 +708,69 @@ function StreamingSection({ username }: { username: string | null | undefined })
|
|||||||
</div>
|
</div>
|
||||||
</SettingCard>
|
</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">
|
<SettingCard title="Your Stream">
|
||||||
<div className="space-y-4 py-2">
|
<div className="space-y-4 py-2">
|
||||||
{streamUrl && (
|
{streamUrl && (
|
||||||
|
|||||||
Reference in New Issue
Block a user