Files
noteflow/client/src/pages/Meetings.tsx
Travis Vasceannie f9db9cd8ca fix(client): close delete dialog on successful bulk deletion
Added onSuccess callback to useDeleteMeetings hook and use it in
Meetings.tsx to close dialog, clear selection, and exit selection mode.

Removed flaky useEffect that tried to detect deletion success by
checking if meetings still existed in the list.
2026-01-26 13:40:10 +00:00

337 lines
11 KiB
TypeScript

// Meetings list page
import { Calendar, Loader2, CheckSquare } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import { getAPI } from '@/api/interface';
import type { Meeting, MeetingState } from '@/api/types';
import type { ProjectScope } from '@/api/types/requests';
import { EmptyState } from '@/components/common';
import { BulkActionToolbar, MeetingCard } from '@/components/features/meetings';
import { ProjectScopeFilter } from '@/components/features/projects/ProjectScopeFilter';
import { Button } from '@/components/ui/button';
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog';
import { Input } from '@/components/ui/input';
import { SearchIcon } from '@/components/ui/search-icon';
import { SkeletonMeetingCard } from '@/components/ui/skeleton';
import { UpcomingMeetings } from '@/components/features/calendar';
import { useProjects } from '@/contexts/project-state';
import { useGuardedMutation } from '@/hooks';
import { useDeleteMeetings } from '@/hooks/meetings/use-meeting-mutations';
import { addClientLog } from '@/lib/observability/client';
import { preferences } from '@/lib/preferences';
import { MEETINGS_PAGE_LIMIT, SKELETON_CARDS_COUNT } from '@/lib/constants/timing';
const FILTER_STATES = ['all', 'completed', 'stopped', 'recording'] as const;
type FilterState = (typeof FILTER_STATES)[number];
export default function MeetingsPage() {
const { projectId } = useParams<{ projectId: string }>();
const { activeProject, projects, isLoading: projectsLoading } = useProjects();
const [searchQuery, setSearchQuery] = useState('');
const [stateFilter, setStateFilter] = useState<FilterState>('all');
const [projectScope, setProjectScope] = useState<ProjectScope>(() => {
return preferences.get().meetings_project_scope ?? 'active';
});
const [selectedProjectIds, setSelectedProjectIds] = useState<string[]>(() => {
return preferences.get().meetings_project_ids ?? [];
});
const { guard } = useGuardedMutation();
const resolvedProjectId = projectId ?? activeProject?.id;
const selectedIdsRef = useRef(selectedProjectIds);
selectedIdsRef.current = selectedProjectIds;
const activeProjects = useMemo(
() => projects.filter((project) => !project.is_archived),
[projects]
);
const [meetings, setMeetings] = useState<Meeting[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
// Bulk selection state
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [selectedMeetingIds, setSelectedMeetingIds] = useState<Set<string>>(new Set());
const [showBulkDeleteDialog, setShowBulkDeleteDialog] = useState(false);
const handleDeleteSuccess = useCallback(() => {
setShowBulkDeleteDialog(false);
setSelectedMeetingIds(new Set());
setIsSelectionMode(false);
}, []);
const { mutate: deleteMeetings, isLoading: isDeleting } = useDeleteMeetings({
onSuccess: handleDeleteSuccess,
});
const shouldSkipFetch =
(projectScope === 'selected' && selectedProjectIds.length === 0) ||
(projectScope === 'active' && !resolvedProjectId && !projectsLoading);
const fetchMeetings = useCallback(
async (offset: number, append: boolean) => {
if (shouldSkipFetch) {
setMeetings([]);
setTotalCount(0);
setLoading(false);
return;
}
if (append) {
setLoadingMore(true);
} else {
setLoading(true);
}
try {
const response = await getAPI().listMeetings({
limit: MEETINGS_PAGE_LIMIT,
offset,
states: stateFilter === 'all' ? undefined : [stateFilter as MeetingState],
project_id: projectScope === 'active' ? resolvedProjectId : undefined,
project_ids: projectScope === 'selected' ? selectedIdsRef.current : undefined,
include_segments: true,
});
if (append) {
setMeetings((prev) => [...prev, ...response.meetings]);
} else {
setMeetings(response.meetings);
}
setTotalCount(response.total_count);
} catch (error) {
addClientLog({
level: 'warning',
source: 'app',
message: 'Failed to fetch meeting list',
details: error instanceof Error ? error.message : String(error),
metadata: { context: 'meetings_list_fetch' },
});
} finally {
setLoading(false);
setLoadingMore(false);
}
},
[shouldSkipFetch, stateFilter, projectScope, resolvedProjectId]
);
useEffect(() => {
fetchMeetings(0, false);
}, [fetchMeetings]);
useEffect(() => {
preferences.setMeetingsProjectFilter(projectScope, selectedProjectIds);
}, [projectScope, selectedProjectIds]);
// Clear selections when filters change
useEffect(() => {
setSelectedMeetingIds(new Set());
setIsSelectionMode(false);
}, []);
const filteredMeetings = useMemo(
() => meetings.filter((m) => m.title.toLowerCase().includes(searchQuery.toLowerCase())),
[meetings, searchQuery]
);
const deletableMeetings = useMemo(
() => meetings.filter((m) => m.state !== 'recording'),
[meetings]
);
const deletableMeetingIds = useMemo(
() => new Set(deletableMeetings.map((m) => m.id)),
[deletableMeetings]
);
const handleSelect = useCallback((meetingId: string, selected: boolean) => {
setSelectedMeetingIds((prev) => {
const next = new Set(prev);
if (selected) next.add(meetingId);
else next.delete(meetingId);
return next;
});
}, []);
const handleSelectAll = useCallback(() => {
setSelectedMeetingIds(new Set(deletableMeetingIds));
}, [deletableMeetingIds]);
const handleDeselectAll = useCallback(() => {
setSelectedMeetingIds(new Set());
setIsSelectionMode(false);
}, []);
const toggleSelectionMode = useCallback(() => {
setIsSelectionMode((prev) => {
if (prev) setSelectedMeetingIds(new Set());
return !prev;
});
}, []);
const handleBulkDelete = useCallback(() => {
setShowBulkDeleteDialog(true);
}, []);
const confirmBulkDelete = useCallback(() => {
deleteMeetings(Array.from(selectedMeetingIds));
}, [deleteMeetings, selectedMeetingIds]);
const hasMore = meetings.length < totalCount;
const handleLoadMore = () => {
fetchMeetings(meetings.length, true);
};
const handleDelete = async (meetingId: string, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (confirm('Are you sure you want to delete this meeting?')) {
const result = await guard(async () => getAPI().deleteMeeting(meetingId), {
title: 'Offline mode',
message: 'Deleting meetings requires an active server connection.',
});
if (result) {
fetchMeetings(0, false);
}
}
};
return (
<div className="p-6 max-w-6xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-foreground">Meetings</h1>
</div>
<ProjectScopeFilter
activeProjects={activeProjects}
projectScope={projectScope}
selectedProjectIds={selectedProjectIds}
onProjectScopeChange={setProjectScope}
onSelectedProjectIdsChange={setSelectedProjectIds}
projectsLoading={projectsLoading}
resolvedProjectId={resolvedProjectId}
idPrefix="meeting"
/>
{/* Upcoming Meetings from Calendars */}
<UpcomingMeetings maxEvents={8} />
{/* Past Recordings */}
<div className="pt-4">
<h2 className="text-lg font-semibold text-foreground mb-4">Past Recordings</h2>
{/* Filters */}
<div className="flex gap-3 mb-4">
<div className="relative flex-1 max-w-sm">
<SearchIcon />
<Input
placeholder="Search meetings..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
<div className="flex gap-2">
{FILTER_STATES.map((state) => (
<Button
key={state}
variant={stateFilter === state ? 'default' : 'outline'}
size="sm"
onClick={() => setStateFilter(state)}
className="capitalize"
>
{state}
</Button>
))}
<Button
variant={isSelectionMode ? 'default' : 'outline'}
size="sm"
onClick={toggleSelectionMode}
>
<CheckSquare className="h-4 w-4 mr-1" />
Select
</Button>
</div>
</div>
{/* Meetings Grid */}
{loading ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: SKELETON_CARDS_COUNT }, (_, i) => (
<SkeletonMeetingCard key={i} />
))}
</div>
) : filteredMeetings.length === 0 ? (
<EmptyState
icon={Calendar}
title="No meetings found"
description={
searchQuery
? 'Try a different search term'
: 'Start recording to create your first meeting'
}
/>
) : (
<>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredMeetings.map((meeting, i) => (
<MeetingCard
key={meeting.id}
meeting={meeting}
index={i}
onDelete={handleDelete}
showDeleteButton={!isSelectionMode}
isSelectable={isSelectionMode && meeting.state !== 'recording'}
isSelected={selectedMeetingIds.has(meeting.id)}
onSelect={handleSelect}
/>
))}
</div>
{hasMore && !searchQuery && (
<div className="flex justify-center mt-6">
<Button
variant="outline"
onClick={handleLoadMore}
disabled={loadingMore}
className="min-w-[140px]"
>
{loadingMore ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading...
</>
) : (
`Load More (${totalCount - meetings.length} remaining)`
)}
</Button>
</div>
)}
</>
)}
</div>
<BulkActionToolbar
selectedCount={selectedMeetingIds.size}
totalCount={deletableMeetings.length}
isDeleting={isDeleting}
onSelectAll={handleSelectAll}
onDeselectAll={handleDeselectAll}
onDelete={handleBulkDelete}
/>
<ConfirmationDialog
open={showBulkDeleteDialog}
onOpenChange={setShowBulkDeleteDialog}
onConfirm={confirmBulkDelete}
title="Delete Meetings"
description={`Are you sure you want to delete ${selectedMeetingIds.size} meeting(s)? This action cannot be undone.`}
confirmText="Delete"
variant="destructive"
isLoading={isDeleting}
/>
</div>
);
}