Skip to content

Commit 79b034d

Browse files
Add swipe-to-delete functionality and manage mode for session cards (#126)
Introduces swipe gesture on mobile to reveal delete button, plus manage mode toggle enabling bulk selection and deletion capabilities
1 parent cb21e66 commit 79b034d

3 files changed

Lines changed: 210 additions & 71 deletions

File tree

Lines changed: 146 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useState, useRef } from "react";
12
import { Card } from "@/components/ui/card";
23
import { Checkbox } from "@/components/ui/checkbox";
34
import { MiniScanner } from "@/components/ui/mini-scanner";
@@ -9,6 +10,7 @@ interface SessionCardProps {
910
session: Session;
1011
isSelected: boolean;
1112
isActive: boolean;
13+
manageMode: boolean;
1214
onSelect: (sessionID: string) => void;
1315
onToggleSelection: (selected: boolean) => void;
1416
onDelete: (e: React.MouseEvent<HTMLButtonElement>) => void;
@@ -18,60 +20,159 @@ export const SessionCard = ({
1820
session,
1921
isSelected,
2022
isActive,
23+
manageMode,
2124
onSelect,
2225
onToggleSelection,
2326
onDelete,
2427
}: SessionCardProps) => {
28+
const [swipeOffset, setSwipeOffset] = useState(0);
29+
const [isSwipeOpen, setIsSwipeOpen] = useState(false);
30+
const touchStartX = useRef<number | null>(null);
31+
const cardRef = useRef<HTMLDivElement>(null);
32+
33+
const handleTouchStart = (e: React.TouchEvent) => {
34+
touchStartX.current = e.touches[0].clientX;
35+
};
36+
37+
const handleTouchMove = (e: React.TouchEvent) => {
38+
if (touchStartX.current === null) return;
39+
40+
const currentX = e.touches[0].clientX;
41+
const diff = touchStartX.current - currentX;
42+
43+
if (diff > 0) {
44+
const newOffset = Math.min(diff, 80);
45+
setSwipeOffset(newOffset);
46+
} else if (diff < 0 && isSwipeOpen) {
47+
const newOffset = Math.max(0, 80 + diff);
48+
setSwipeOffset(newOffset);
49+
}
50+
};
51+
52+
const handleTouchEnd = () => {
53+
if (swipeOffset > 50) {
54+
setIsSwipeOpen(true);
55+
setSwipeOffset(80);
56+
} else if (swipeOffset < 30) {
57+
setIsSwipeOpen(false);
58+
setSwipeOffset(0);
59+
}
60+
touchStartX.current = null;
61+
};
62+
63+
const closeSwipe = () => {
64+
setSwipeOffset(0);
65+
setIsSwipeOpen(false);
66+
};
67+
68+
const handleDeleteClick = (e: React.MouseEvent<HTMLButtonElement>) => {
69+
e.stopPropagation();
70+
onDelete(e);
71+
closeSwipe();
72+
};
2573

2674
return (
27-
<Card
28-
className={`p-3 cursor-pointer transition-all ${
29-
isSelected
30-
? "border-blue-500 shadow-lg shadow-blue-900/30 dark:shadow-blue-900/30 bg-accent"
31-
: isActive
32-
? "bg-accent border-border"
33-
: "bg-card border-border hover:bg-accent hover:border-border"
34-
} hover:shadow-lg`}
35-
onClick={() => onSelect(session.id)}
36-
>
37-
<div className="flex items-start justify-between gap-2">
38-
<div className="flex items-start gap-2 flex-1 min-w-0">
39-
<div className="flex flex-col items-center gap-3 flex-shrink-0">
40-
<Checkbox
41-
checked={isSelected}
42-
onCheckedChange={(checked) => {
43-
onToggleSelection(checked === true);
44-
}}
45-
onClick={(e) => {
46-
e.stopPropagation();
47-
}}
48-
className="w-5 h-5 flex-shrink-0"
49-
/>
50-
<MiniScanner sessionID={session.id} />
51-
</div>
52-
<div className="flex-1 min-w-0">
53-
<div className="flex items-center gap-2">
54-
<h3 className="text-base font-semibold text-orange-600 dark:text-orange-400 truncate">
55-
{session.title || "Untitled Session"}
56-
</h3>
57-
</div>
58-
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
59-
<span className="flex items-center gap-1">
60-
<Clock className="w-3 h-3" />
61-
{formatDistanceToNow(new Date(session.time.updated), {
62-
addSuffix: true,
63-
})}
64-
</span>
65-
</div>
66-
</div>
67-
</div>
75+
<div className="relative" onClick={closeSwipe}>
76+
<div
77+
className={`absolute top-0.5 right-0 bottom-0.5 w-20 bg-red-600 flex items-center justify-center rounded-r-lg transition-opacity ${
78+
swipeOffset > 20 || isSwipeOpen ? "opacity-100" : "opacity-0"
79+
}`}
80+
>
6881
<button
69-
className="h-6 w-6 p-0 text-foreground hover:text-red-600 dark:hover:text-red-400 bg-transparent border-none cursor-pointer"
70-
onClick={onDelete}
82+
className="h-full w-full flex items-center justify-center text-white hover:bg-red-700"
83+
onClick={handleDeleteClick}
7184
>
72-
<Trash2 className="w-4 h-4" />
85+
<Trash2 className="w-5 h-5" />
7386
</button>
7487
</div>
75-
</Card>
88+
<div
89+
ref={cardRef}
90+
onTouchStart={handleTouchStart}
91+
onTouchMove={handleTouchMove}
92+
onTouchEnd={handleTouchEnd}
93+
style={{ transform: `translateX(-${swipeOffset}px)` }}
94+
className="transition-transform"
95+
>
96+
<Card
97+
className={`p-2 cursor-pointer transition-all overflow-hidden ${
98+
isSwipeOpen
99+
? "rounded-none"
100+
: "rounded-r-lg"
101+
} ${
102+
isSelected
103+
? "border-blue-500 shadow-lg shadow-blue-900/30 dark:shadow-blue-900/30 bg-accent"
104+
: isActive
105+
? "bg-accent border-border"
106+
: "bg-card border-border hover:bg-accent hover:border-border"
107+
} hover:shadow-lg`}
108+
onClick={() => {
109+
if (!isSwipeOpen) {
110+
onSelect(session.id);
111+
}
112+
}}
113+
>
114+
<div className="flex items-start justify-between gap-2">
115+
{manageMode ? (
116+
<div className="flex items-start gap-2 flex-1 min-w-0">
117+
<div className="flex flex-col items-center gap-2 flex-shrink-0">
118+
<Checkbox
119+
checked={isSelected}
120+
onCheckedChange={(checked) => {
121+
onToggleSelection(checked === true);
122+
}}
123+
onClick={(e) => {
124+
e.stopPropagation();
125+
}}
126+
className="w-5 h-5 flex-shrink-0"
127+
/>
128+
<MiniScanner sessionID={session.id} />
129+
</div>
130+
<div className="flex-1 min-w-0">
131+
<div className="flex items-center gap-1">
132+
<h3 className="text-base font-semibold text-orange-600 dark:text-orange-400 truncate">
133+
{session.title || "Untitled Session"}
134+
</h3>
135+
</div>
136+
<div className="flex items-center gap-2 mt-0.5 text-xs text-muted-foreground">
137+
<span className="flex items-center gap-1">
138+
<Clock className="w-3 h-3" />
139+
{formatDistanceToNow(new Date(session.time.updated), {
140+
addSuffix: true,
141+
})}
142+
</span>
143+
</div>
144+
</div>
145+
</div>
146+
) : (
147+
<div className="flex flex-col flex-1 min-w-0">
148+
<h3 className="text-sm font-semibold text-orange-600 dark:text-orange-400 truncate">
149+
{session.title || "Untitled Session"}
150+
</h3>
151+
<div className="flex items-center gap-2 mt-0.5 text-xs text-muted-foreground">
152+
<span className="flex items-center">
153+
<Clock className="w-3 h-3 mr-1" />
154+
{formatDistanceToNow(new Date(session.time.updated), {
155+
addSuffix: true,
156+
})}
157+
</span>
158+
<MiniScanner sessionID={session.id} />
159+
</div>
160+
</div>
161+
)}
162+
{manageMode && (
163+
<button
164+
className="h-6 w-6 p-0 text-foreground hover:text-red-600 dark:hover:text-red-400 bg-transparent border-none cursor-pointer"
165+
onClick={(e) => {
166+
e.stopPropagation();
167+
onDelete(e);
168+
}}
169+
>
170+
<Trash2 className="w-4 h-4" />
171+
</button>
172+
)}
173+
</div>
174+
</Card>
175+
</div>
176+
</div>
76177
);
77178
};

frontend/src/components/session/SessionList.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export const SessionList = ({
2727
const [selectedSessions, setSelectedSessions] = useState<Set<string>>(
2828
new Set(),
2929
);
30+
const [manageMode, setManageMode] = useState(false);
3031

3132
const filteredSessions = useMemo(() => {
3233
if (!sessions) return [];
@@ -107,6 +108,17 @@ export const SessionList = ({
107108
setSelectedSessions(newSelected);
108109
};
109110

111+
const toggleManageMode = () => {
112+
setManageMode((prev) => {
113+
if (!prev) {
114+
return true;
115+
} else {
116+
setSelectedSessions(new Set());
117+
return false;
118+
}
119+
});
120+
};
121+
110122
const toggleSelectAll = () => {
111123
if (!filteredSessions || filteredSessions.length === 0) return;
112124

@@ -150,6 +162,8 @@ export const SessionList = ({
150162
onToggleSelectAll={toggleSelectAll}
151163
onDelete={handleBulkDelete}
152164
onDeleteAll={handleDeleteAll}
165+
manageMode={manageMode}
166+
onToggleManageMode={toggleManageMode}
153167
/>
154168
</div>
155169

@@ -172,6 +186,7 @@ export const SessionList = ({
172186
session={session}
173187
isSelected={selectedSessions.has(session.id)}
174188
isActive={activeSessionID === session.id}
189+
manageMode={manageMode}
175190
onSelect={onSelectSession}
176191
onToggleSelection={(selected) => {
177192
toggleSessionSelection(session.id, selected);
@@ -191,6 +206,7 @@ export const SessionList = ({
191206
session={session}
192207
isSelected={selectedSessions.has(session.id)}
193208
isActive={activeSessionID === session.id}
209+
manageMode={manageMode}
194210
onSelect={onSelectSession}
195211
onToggleSelection={(selected) => {
196212
toggleSessionSelection(session.id, selected);
Lines changed: 48 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Input } from "@/components/ui/input";
22
import { Button } from "@/components/ui/button";
3-
import { Trash2, Search, GripVertical, Check } from "lucide-react";
3+
import { Trash2, Search, GripVertical, Check, Pencil, PencilOff } from "lucide-react";
44

55
interface ListToolbarProps {
66
searchQuery: string;
@@ -15,6 +15,8 @@ interface ListToolbarProps {
1515
reorderMode?: boolean;
1616
onToggleReorderMode?: () => void;
1717
showReorderToggle?: boolean;
18+
manageMode?: boolean;
19+
onToggleManageMode?: () => void;
1820
}
1921

2022
export function ListToolbar({
@@ -30,6 +32,8 @@ export function ListToolbar({
3032
reorderMode = false,
3133
onToggleReorderMode,
3234
showReorderToggle = false,
35+
manageMode = false,
36+
onToggleManageMode,
3337
}: ListToolbarProps) {
3438
const hasItems = totalCount > 0;
3539
const hasSelection = selectedCount > 0;
@@ -43,7 +47,7 @@ export function ListToolbar({
4347
};
4448

4549
return (
46-
<div className="flex items-center gap-3">
50+
<div className="flex items-center gap-2">
4751
<div className="relative flex-1">
4852
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
4953
<Input
@@ -54,12 +58,43 @@ export function ListToolbar({
5458
className="pl-10"
5559
/>
5660
</div>
61+
{manageMode && hasItems && (
62+
<Button
63+
onClick={onToggleSelectAll}
64+
variant={hasSelection ? "default" : "outline"}
65+
className="whitespace-nowrap hidden md:flex"
66+
>
67+
{allSelected ? "Deselect All" : "Select All"}
68+
</Button>
69+
)}
70+
{manageMode && (
71+
<Button
72+
onClick={onDelete}
73+
variant="destructive"
74+
disabled={!hasSelection}
75+
className="hidden md:flex whitespace-nowrap"
76+
>
77+
<Trash2 className="w-4 h-4 mr-2" />
78+
Delete ({selectedCount})
79+
</Button>
80+
)}
81+
{manageMode && (
82+
<Button
83+
onClick={handleMobileDelete}
84+
variant="destructive"
85+
size="icon"
86+
className="md:hidden shrink-0 size-10"
87+
disabled={!hasItems}
88+
>
89+
<Trash2 className="w-4 h-4" />
90+
</Button>
91+
)}
5792
{showReorderToggle && onToggleReorderMode && (
5893
<Button
5994
onClick={onToggleReorderMode}
6095
variant={reorderMode ? "default" : "outline"}
6196
size="icon"
62-
className="md:hidden shrink-0"
97+
className="md:hidden shrink-0 size-10"
6398
>
6499
{reorderMode ? (
65100
<Check className="w-4 h-4" />
@@ -68,33 +103,20 @@ export function ListToolbar({
68103
)}
69104
</Button>
70105
)}
71-
{hasItems && (
106+
{onToggleManageMode && hasItems && (
72107
<Button
73-
onClick={onToggleSelectAll}
74-
variant={hasSelection ? "default" : "outline"}
75-
className="whitespace-nowrap hidden md:flex"
108+
onClick={onToggleManageMode}
109+
variant="outline"
110+
size="icon"
111+
className="shrink-0 size-10"
76112
>
77-
{allSelected ? "Deselect All" : "Select All"}
113+
{manageMode ? (
114+
<PencilOff className="w-4 h-4" />
115+
) : (
116+
<Pencil className="w-4 h-4" />
117+
)}
78118
</Button>
79119
)}
80-
<Button
81-
onClick={onDelete}
82-
variant="destructive"
83-
disabled={!hasSelection}
84-
className="hidden md:flex whitespace-nowrap"
85-
>
86-
<Trash2 className="w-4 h-4 mr-2" />
87-
Delete ({selectedCount})
88-
</Button>
89-
<Button
90-
onClick={handleMobileDelete}
91-
variant="destructive"
92-
size="icon"
93-
className="md:hidden"
94-
disabled={!hasItems}
95-
>
96-
<Trash2 className="w-4 h-4" />
97-
</Button>
98120
</div>
99121
);
100122
}

0 commit comments

Comments
 (0)