chore(auto): task completed - unknown

This commit is contained in:
MythEclipse
2026-06-13 14:24:09 +07:00
parent 1a195b737e
commit e6e34281ff
9 changed files with 184 additions and 61 deletions
@@ -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();
+33
View File
@@ -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({