feat(fe): optimize error handling, state consistency, and WS feedback
- Add global SWR config with exponential backoff retry (swr-config.ts) - Add ErrorBoundary component for React crash recovery (error-boundary.tsx) - Standardize ErrorState onRetry on all 6 dashboard views (dashboard, media, messages, moderation, recordings, voice) - Fix useAction: expose resetError + onError callback - Refactor useSpeakers to SWR-backed state (was local useState) for consistent cache/revalidate semantics with other hooks - Remove polling in useReview (15s refreshInterval); replace with useReviewWsSync subscribing to WS moderation_action events - Extract hashUserId to lib/hash.ts (de-dup with ambient-canvas) - WS context: add reconnect/error toast feedback via onStatusChange - WS connection: expose reconnectAttemptCount getter All typecheck + lint clean, Next 16 build passes.
This commit is contained in:
@@ -50,7 +50,12 @@ export function DashboardView({
|
||||
initialStats?: DashboardStats;
|
||||
initialActivity?: Awaited<ReturnType<typeof useActivity>>["data"];
|
||||
}) {
|
||||
const { data: stats, isLoading, error } = useStats(initialStats);
|
||||
const {
|
||||
data: stats,
|
||||
isLoading,
|
||||
error,
|
||||
mutate: mutateStats,
|
||||
} = useStats(initialStats);
|
||||
const { data: activity } = useActivity(14, initialActivity as never);
|
||||
const { data: reactors } = useTopReactors();
|
||||
const { data: reactions } = useTopReactions();
|
||||
@@ -65,7 +70,8 @@ export function DashboardView({
|
||||
);
|
||||
}, [stats, ambient]);
|
||||
|
||||
if (error && !stats) return <ErrorState error={error} />;
|
||||
if (error && !stats)
|
||||
return <ErrorState error={error} onRetry={() => void mutateStats()} />;
|
||||
if (!stats && isLoading)
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@@ -78,7 +84,13 @@ export function DashboardView({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
if (!stats) return <ErrorState error={error ?? new Error("No data")} />;
|
||||
if (!stats)
|
||||
return (
|
||||
<ErrorState
|
||||
error={error ?? new Error("No data")}
|
||||
onRetry={() => void mutateStats()}
|
||||
/>
|
||||
);
|
||||
|
||||
const s = stats;
|
||||
const total = s.total_flagged + s.total_clean || 1;
|
||||
|
||||
@@ -33,7 +33,12 @@ import { useWebSocket } from "@/lib/ws/context";
|
||||
|
||||
export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
||||
const ws = useWebSocket();
|
||||
const { data: media, isLoading, error } = useMediaState(initialStatus);
|
||||
const {
|
||||
data: media,
|
||||
isLoading,
|
||||
error,
|
||||
mutate,
|
||||
} = useMediaState(initialStatus);
|
||||
const queue = useMediaQueue();
|
||||
const skip = useMediaSkip();
|
||||
const stop = useMediaStop();
|
||||
@@ -79,7 +84,8 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
||||
}
|
||||
};
|
||||
|
||||
if (error && !media) return <ErrorState error={error} />;
|
||||
if (error && !media)
|
||||
return <ErrorState error={error} onRetry={() => void mutate()} />;
|
||||
if (!media && isLoading)
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
useMessagesStream,
|
||||
useMessagesWsSync,
|
||||
useRecentEdits,
|
||||
useReviewWsSync,
|
||||
useSemanticSearch,
|
||||
} from "@/hooks";
|
||||
import { aiTone } from "@/lib/ai-status";
|
||||
@@ -94,6 +95,7 @@ export function MessagesView({
|
||||
data: messages,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useMessages(
|
||||
guildId ?? "",
|
||||
channelId ?? undefined,
|
||||
@@ -112,6 +114,7 @@ export function MessagesView({
|
||||
const hasMore = pageInfo?.hasMore ?? false;
|
||||
const loadMore = useLoadMore();
|
||||
useMessagesWsSync(ws, guildId ?? "");
|
||||
useReviewWsSync(ws);
|
||||
const search = useMessageSearch(
|
||||
query,
|
||||
query.trim().length >= 2 && !semanticMode,
|
||||
@@ -326,7 +329,7 @@ export function MessagesView({
|
||||
}
|
||||
/>
|
||||
{error && !messages ? (
|
||||
<ErrorState error={error} />
|
||||
<ErrorState error={error} onRetry={() => refetch()} />
|
||||
) : isLoading && !messages ? (
|
||||
<SkeletonRows rows={8} />
|
||||
) : list.length === 0 ? (
|
||||
|
||||
@@ -81,7 +81,12 @@ export function ModerationView({
|
||||
initialStats?: ModerationStats;
|
||||
initialActions?: ModerationAction[];
|
||||
}) {
|
||||
const { data: stats, isLoading, error } = useModerationStats(initialStats);
|
||||
const {
|
||||
data: stats,
|
||||
isLoading,
|
||||
error,
|
||||
mutate: mutateStats,
|
||||
} = useModerationStats(initialStats);
|
||||
const [statusFilter, setStatusFilter] = useState<string>("");
|
||||
const [typeFilter, setTypeFilter] = useState<string>("");
|
||||
const { data: actions } = useModerationActions(
|
||||
@@ -122,7 +127,8 @@ export function ModerationView({
|
||||
);
|
||||
}, [failedRate, ambient]);
|
||||
|
||||
if (error && !stats) return <ErrorState error={error} />;
|
||||
if (error && !stats)
|
||||
return <ErrorState error={error} onRetry={() => void mutateStats()} />;
|
||||
if (!stats && isLoading)
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@@ -131,7 +137,13 @@ export function ModerationView({
|
||||
<SkeletonRows rows={6} />
|
||||
</div>
|
||||
);
|
||||
if (!stats) return <ErrorState error={error ?? new Error("No data")} />;
|
||||
if (!stats)
|
||||
return (
|
||||
<ErrorState
|
||||
error={error ?? new Error("No data")}
|
||||
onRetry={() => void mutateStats()}
|
||||
/>
|
||||
);
|
||||
|
||||
const statusOpts: SelectOption[] = [
|
||||
{ value: "", label: "All statuses" },
|
||||
|
||||
@@ -33,7 +33,7 @@ export function RecordingsView({
|
||||
initialItems?: VoiceRecording[];
|
||||
}) {
|
||||
const ws = useWebSocket();
|
||||
const { data: items, isLoading, error } = useRecordings(initialItems);
|
||||
const { data: items, isLoading, error, mutate } = useRecordings(initialItems);
|
||||
const del = useDeleteRecording();
|
||||
useRecordingsWsSync(ws);
|
||||
const ambient = useAmbient();
|
||||
@@ -56,7 +56,8 @@ export function RecordingsView({
|
||||
}
|
||||
};
|
||||
|
||||
if (error && !items) return <ErrorState error={error} />;
|
||||
if (error && !items)
|
||||
return <ErrorState error={error} onRetry={() => void mutate()} />;
|
||||
if (!items && isLoading)
|
||||
return (
|
||||
<GlassPanel>
|
||||
|
||||
@@ -40,7 +40,12 @@ export function VoiceView({
|
||||
initialGuilds?: Guild[];
|
||||
}) {
|
||||
const ws = useWebSocket();
|
||||
const { data: status, isLoading, error } = useVoiceStatus(initialStatus);
|
||||
const {
|
||||
data: status,
|
||||
isLoading,
|
||||
error,
|
||||
mutate,
|
||||
} = useVoiceStatus(initialStatus);
|
||||
const connect = useVoiceConnect();
|
||||
const disconnect = useVoiceDisconnect();
|
||||
const mic = useMicTransmit(ws);
|
||||
@@ -68,7 +73,8 @@ export function VoiceView({
|
||||
else ambient.set("vermilion", 0.35, "voice idle");
|
||||
}, [status?.connected, ambient]);
|
||||
|
||||
if (error && !status) return <ErrorState error={error} />;
|
||||
if (error && !status)
|
||||
return <ErrorState error={error} onRetry={() => void mutate()} />;
|
||||
if (!status && isLoading)
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
|
||||
Reference in New Issue
Block a user