chore(auto): task completed - unknown
This commit is contained in:
@@ -25,5 +25,15 @@ export function createRecordingsRouter(): Router {
|
||||
}),
|
||||
);
|
||||
|
||||
// DELETE /api/recordings/:id
|
||||
router.delete(
|
||||
"/recordings/:id",
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const { id } = req.params;
|
||||
await recordingsService.deleteById(id);
|
||||
res.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,11 @@ export class RecordingsService {
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
async deleteById(id: string): Promise<void> {
|
||||
const db = getDatabase();
|
||||
await db.execute(sql`DELETE FROM voice_recordings WHERE id = ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const recordingsService = new RecordingsService();
|
||||
|
||||
@@ -48,10 +48,43 @@ export default function App() {
|
||||
[monitorGuildId, voice.guilds],
|
||||
);
|
||||
|
||||
// Update speaker list from incremental voice_active_user events
|
||||
const updateSpeakerList = (
|
||||
prev: ActiveSpeaker[],
|
||||
data: Partial<ActiveSpeaker> & { userId?: string; id?: string; speaking: boolean },
|
||||
): ActiveSpeaker[] => {
|
||||
const key = data.userId ?? data.id;
|
||||
if (!key) return prev;
|
||||
const idx = prev.findIndex(
|
||||
(s) => (s.userId ?? s.id) === key,
|
||||
);
|
||||
if (idx >= 0) {
|
||||
const next = [...prev];
|
||||
next[idx] = { ...next[idx], ...data };
|
||||
return next;
|
||||
}
|
||||
return [...prev, data as ActiveSpeaker];
|
||||
};
|
||||
|
||||
const socket = useDashboardSocket({
|
||||
onVoicePcmData: (d) =>
|
||||
audio.handleIncomingPcm(d as { userId: string; pcm: string }),
|
||||
onUserState: (users) => setActiveSpeakers(users as ActiveSpeaker[]),
|
||||
onVoiceActiveUser: (data) =>
|
||||
setActiveSpeakers((prev) =>
|
||||
updateSpeakerList(
|
||||
prev,
|
||||
data as Partial<ActiveSpeaker> & {
|
||||
userId?: string;
|
||||
id?: string;
|
||||
speaking: boolean;
|
||||
},
|
||||
),
|
||||
),
|
||||
onVoiceRecordingStarted: () =>
|
||||
window.dispatchEvent(new CustomEvent("voice_recording_uploaded")),
|
||||
onVoiceRecordingStopped: () =>
|
||||
window.dispatchEvent(new CustomEvent("voice_recording_uploaded")),
|
||||
onMessageCreated: (m) =>
|
||||
messages.setMessages((prev) => mergeMessages(prev, [m as MessageRecord])),
|
||||
onMessageUpdated: (m) => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ActiveSpeaker } from "../../../shared/api/client";
|
||||
import { Skeleton } from "../../../shared/ui";
|
||||
import { EmptyStateMascot } from "../../../widgets/mascot/MascotImage";
|
||||
|
||||
interface ActiveSpeakersProps {
|
||||
@@ -14,7 +13,6 @@ export function ActiveSpeakers({ speakers }: ActiveSpeakersProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{speakers.map((s) => {
|
||||
// BUG 4 FIX: stable key — no index fallback
|
||||
const key = s.userId ?? s.id ?? `speaker-${s.username}`;
|
||||
return (
|
||||
<div
|
||||
@@ -28,8 +26,19 @@ export function ActiveSpeakers({ speakers }: ActiveSpeakersProps) {
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{s.username}</div>
|
||||
<div className="text-xs font-medium text-emerald-700">
|
||||
Speaking
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={`inline-block h-2 w-2 rounded-full ${
|
||||
s.speaking ? "bg-emerald-500" : "bg-muted-foreground/40"
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`text-xs font-medium ${
|
||||
s.speaking ? "text-emerald-600" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{s.speaking ? "Speaking" : "Silent"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -38,22 +47,3 @@ export function ActiveSpeakers({ speakers }: ActiveSpeakersProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActiveSpeakersSkeleton() {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-3 rounded-xl border border-border bg-background/60 p-3"
|
||||
>
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,23 @@ interface AudioVisualizerProps {
|
||||
|
||||
export function AudioVisualizer({ levels }: AudioVisualizerProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!canvas || !container) return;
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = 128 * dpr;
|
||||
canvas.style.height = "128px";
|
||||
});
|
||||
ro.observe(container);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
@@ -13,15 +30,16 @@ export function AudioVisualizer({ levels }: AudioVisualizerProps) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const width = canvas.width;
|
||||
const height = canvas.height;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const width = canvas.width / dpr;
|
||||
const height = canvas.height / dpr;
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const barWidth = width / levels.length;
|
||||
const maxBarHeight = height * 0.85;
|
||||
|
||||
// IMPHNEN blue gradient
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, height);
|
||||
gradient.addColorStop(0, "#23a1eb");
|
||||
gradient.addColorStop(1, "#3eb0f2");
|
||||
@@ -34,7 +52,6 @@ export function AudioVisualizer({ levels }: AudioVisualizerProps) {
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
|
||||
// More rounded bar
|
||||
const radius = barWidth * 0.4;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + radius, y);
|
||||
@@ -49,11 +66,11 @@ export function AudioVisualizer({ levels }: AudioVisualizerProps) {
|
||||
}, [levels]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<div ref={containerRef} className="relative w-full">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={512}
|
||||
height={128}
|
||||
width={0}
|
||||
height={0}
|
||||
className="w-full rounded-lg bg-primary/5"
|
||||
style={{ height: "128px" }}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Music2, SkipForward, Square, Volume2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Music2, SkipForward, Square, Volume2, VolumeX } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Button, Input } from "../../../shared/ui";
|
||||
|
||||
interface MusicSubPanelProps {
|
||||
@@ -24,17 +24,39 @@ export function MusicSubPanel({
|
||||
? Math.max(0, Math.min(1, volume))
|
||||
: 1;
|
||||
const [draftVolume, setDraftVolume] = useState(Math.round(safeVolume * 100));
|
||||
const [muted, setMuted] = useState(false);
|
||||
const prevVolumeRef = useRef(safeVolume);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Debounced volume — poll every 200ms instead of instant send to avoid flood
|
||||
// Proper debounce: setTimeout instead of setInterval polling
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
const normalized = draftVolume / 100;
|
||||
if (Math.abs(normalized - safeVolume) >= 0.001)
|
||||
onVolumeChange(normalized);
|
||||
}, 200);
|
||||
return () => clearInterval(id);
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [draftVolume, safeVolume, onVolumeChange]);
|
||||
|
||||
const handleMute = useCallback(() => {
|
||||
if (muted) {
|
||||
// Unmute: restore previous volume
|
||||
const restore = prevVolumeRef.current;
|
||||
setDraftVolume(Math.round(restore * 100));
|
||||
onVolumeChange(restore);
|
||||
setMuted(false);
|
||||
} else {
|
||||
// Mute: save current, set to 0
|
||||
prevVolumeRef.current = safeVolume;
|
||||
setDraftVolume(0);
|
||||
onVolumeChange(0);
|
||||
setMuted(true);
|
||||
}
|
||||
}, [muted, safeVolume, onVolumeChange]);
|
||||
|
||||
const submit = () => {
|
||||
const t = source.trim();
|
||||
if (!t) return;
|
||||
@@ -51,14 +73,23 @@ export function MusicSubPanel({
|
||||
placeholder="YouTube URL, Spotify track, or search terms"
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<Volume2 className="h-4 w-4 shrink-0 text-primary" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMute}
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{muted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={draftVolume}
|
||||
onChange={(e) => setDraftVolume(Number(e.target.value))}
|
||||
onChange={(e) => {
|
||||
setDraftVolume(Number(e.target.value));
|
||||
if (muted) setMuted(false);
|
||||
}}
|
||||
className="h-2 w-full cursor-pointer accent-primary"
|
||||
/>
|
||||
<span className="w-10 shrink-0 text-right text-sm tabular-nums text-muted-foreground">
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// ─── Recordings Sub-Panel ──
|
||||
|
||||
import { Download, Mic } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Download, Mic, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { VoiceRecording } from "../../../shared/api/client";
|
||||
import { listRecordings } from "../../../shared/api/client";
|
||||
import { deleteRecording, listRecordings } from "../../../shared/api/client";
|
||||
import { formatBytes, formatDate } from "../../../shared/lib/utils";
|
||||
import { Badge, Button, Skeleton } from "../../../shared/ui";
|
||||
import { EmptyStateMascot } from "../../../widgets/mascot/MascotImage";
|
||||
@@ -12,29 +12,50 @@ export function RecordingsSubPanel() {
|
||||
const [recordings, setRecordings] = useState<VoiceRecording[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deletingIds, setDeletingIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const loadRecordings = useCallback(async (
|
||||
opts?: { signal?: AbortSignal },
|
||||
) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await listRecordings();
|
||||
if (!opts?.signal?.aborted) setRecordings(data);
|
||||
} catch (err) {
|
||||
if (!opts?.signal?.aborted)
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (!opts?.signal?.aborted) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function loadRecordings() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await listRecordings();
|
||||
if (!cancelled) setRecordings(data);
|
||||
} catch (err) {
|
||||
if (!cancelled)
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
loadRecordings();
|
||||
const ab = new AbortController();
|
||||
loadRecordings({ signal: ab.signal });
|
||||
const handler = () => loadRecordings();
|
||||
window.addEventListener("voice_recording_uploaded", handler);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
ab.abort();
|
||||
window.removeEventListener("voice_recording_uploaded", handler);
|
||||
};
|
||||
}, [loadRecordings]);
|
||||
|
||||
const handleDelete = useCallback(async (id: string) => {
|
||||
if (!confirm("Delete this recording?")) return;
|
||||
setDeletingIds((prev) => new Set(prev).add(id));
|
||||
try {
|
||||
await deleteRecording(id);
|
||||
setRecordings((prev) => prev.filter((r) => r.id !== id));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setDeletingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
@@ -64,7 +85,7 @@ export function RecordingsSubPanel() {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => window.location.reload()}
|
||||
onClick={() => loadRecordings()}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
@@ -105,6 +126,15 @@ export function RecordingsSubPanel() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={deletingIds.has(rec.id)}
|
||||
onClick={() => handleDelete(rec.id)}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Badge
|
||||
variant={
|
||||
rec.upload_status === "uploaded"
|
||||
|
||||
@@ -258,6 +258,10 @@ export function listRecordings(limit = 50): Promise<VoiceRecording[]> {
|
||||
return request<VoiceRecording[]>(`/api/recordings?limit=${limit}`);
|
||||
}
|
||||
|
||||
export function deleteRecording(id: string): Promise<void> {
|
||||
return request<void>(`/api/recordings/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// ─── Auth ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function login(password: string): Promise<{ ok: boolean }> {
|
||||
|
||||
@@ -97,10 +97,13 @@ export function useAudioTransmit(socketRef: {
|
||||
for (let i = 0; i < inputData.length; i++)
|
||||
pcmData[i] = Math.max(-1, Math.min(1, inputData[i])) * 32767;
|
||||
|
||||
// 6b: Replace string-concatenation loop with single call
|
||||
// 1024 samples → 2048 bytes — well within call-stack limits
|
||||
// 6b: Safe loop instead of spread operator to avoid call-stack overflow
|
||||
const bytes = new Uint8Array(pcmData.buffer);
|
||||
const base64 = btoa(String.fromCharCode(...bytes));
|
||||
let str = '';
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
str += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
const base64 = btoa(str);
|
||||
|
||||
socketRef.current.send(
|
||||
JSON.stringify({
|
||||
|
||||
Reference in New Issue
Block a user