paseo-kanban 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,326 @@
1
+ import { useMemo, useEffect, useRef } from "react";
2
+ import { View, Text, StyleSheet, Animated } from "react-native";
3
+ import { Icon } from "@getpaseo/plugin/client/react-native";
4
+ import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
5
+ import type { KanbanTask } from "../shared/kanban";
6
+ import type { CardDragBinding } from "./kanban-drag";
7
+ import { useI18n } from "./i18n";
8
+
9
+ type PluginTheme = PluginSurfaceProps["theme"];
10
+
11
+ export interface KanbanCardProps {
12
+ task: KanbanTask;
13
+ projectDisplayName: string | null;
14
+ theme: PluginTheme;
15
+ layout: { compact: boolean };
16
+ binding: CardDragBinding;
17
+ }
18
+
19
+ export function KanbanCard({
20
+ task,
21
+ projectDisplayName,
22
+ theme,
23
+ layout,
24
+ binding,
25
+ }: KanbanCardProps) {
26
+ const { t } = useI18n();
27
+ const bindingRef = useRef(binding);
28
+ bindingRef.current = binding;
29
+
30
+ useEffect(() => {
31
+ return () => {
32
+ bindingRef.current.onUnmount?.();
33
+ };
34
+ }, []);
35
+
36
+ const completedSubtasksCount = useMemo(
37
+ () => task.subtasks.filter((s) => s.completed).length,
38
+ [task.subtasks]
39
+ );
40
+
41
+ const styles = useMemo(
42
+ () =>
43
+ StyleSheet.create({
44
+ card: {
45
+ userSelect: "none",
46
+ backgroundColor: theme.colors.surface2,
47
+ borderColor: theme.colors.border,
48
+ borderWidth: 1,
49
+ borderRadius: 8,
50
+ padding: layout.compact ? 10 : 12,
51
+ gap: 8,
52
+ },
53
+ cardHeader: {
54
+ flexDirection: "row",
55
+ alignItems: "flex-start",
56
+ gap: 6,
57
+ },
58
+ dragHandle: {
59
+ paddingHorizontal: 2,
60
+ paddingVertical: 2,
61
+ justifyContent: "center",
62
+ alignItems: "center",
63
+ },
64
+ title: {
65
+ flex: 1,
66
+ fontSize: layout.compact ? 13 : 14,
67
+ fontWeight: "600",
68
+ color: theme.colors.foreground,
69
+ userSelect: "none",
70
+ },
71
+ badgeRow: {
72
+ flexDirection: "row",
73
+ flexWrap: "wrap",
74
+ alignItems: "center",
75
+ gap: 6,
76
+ },
77
+ projectBadge: {
78
+ backgroundColor: theme.colors.surface1,
79
+ borderColor: theme.colors.border,
80
+ borderWidth: 1,
81
+ borderRadius: 4,
82
+ paddingHorizontal: 6,
83
+ paddingVertical: 2,
84
+ },
85
+ projectBadgeText: {
86
+ fontSize: 11,
87
+ color: theme.colors.foregroundMuted,
88
+ fontWeight: "500",
89
+ },
90
+ subtaskBadge: {
91
+ backgroundColor: theme.colors.surface1,
92
+ borderRadius: 4,
93
+ paddingHorizontal: 6,
94
+ paddingVertical: 2,
95
+ },
96
+ subtaskBadgeText: {
97
+ fontSize: 11,
98
+ color:
99
+ task.subtasks.length > 0 &&
100
+ completedSubtasksCount === task.subtasks.length
101
+ ? theme.colors.statusSuccess ?? theme.colors.accent
102
+ : theme.colors.foregroundMuted,
103
+ fontWeight: "500",
104
+ },
105
+ }),
106
+ [theme, layout.compact, task.subtasks.length, completedSubtasksCount]
107
+ );
108
+
109
+ return (
110
+ <View
111
+ // Keep the responder mounted, but let the single drop slot replace its space.
112
+ style={binding.isDragging ? { position: "absolute", left: 0, right: 0, opacity: 0 } : undefined}
113
+ onLayout={(e) => binding.onLayout?.(e.nativeEvent.layout)}
114
+ {...binding.cardPanHandlers}
115
+ >
116
+ <View style={styles.card}>
117
+ <View style={styles.cardHeader}>
118
+ <View
119
+ style={styles.dragHandle}
120
+ accessibilityRole="button"
121
+ accessibilityLabel={t("card.dragHandle")}
122
+ {...binding.handlePanHandlers}
123
+ >
124
+ <Icon name="GripVertical" size={14} color={theme.colors.foregroundMuted} />
125
+ </View>
126
+
127
+ <Text style={styles.title} numberOfLines={2}>
128
+ {task.title}
129
+ </Text>
130
+ </View>
131
+
132
+ <View style={styles.badgeRow}>
133
+ {projectDisplayName && (
134
+ <View style={styles.projectBadge}>
135
+ <Text style={styles.projectBadgeText} numberOfLines={1}>
136
+ {projectDisplayName}
137
+ </Text>
138
+ </View>
139
+ )}
140
+ {task.subtasks.length > 0 && (
141
+ <View style={styles.subtaskBadge}>
142
+ <Text style={styles.subtaskBadgeText}>
143
+ {t("card.subtasksCount", {
144
+ completed: completedSubtasksCount,
145
+ total: task.subtasks.length,
146
+ })}
147
+ </Text>
148
+ </View>
149
+ )}
150
+ </View>
151
+ </View>
152
+ </View>
153
+ );
154
+ }
155
+
156
+ export function KanbanCardPreview({
157
+ task,
158
+ projectDisplayName,
159
+ theme,
160
+ layout,
161
+ animated = false,
162
+ }: {
163
+ task: KanbanTask;
164
+ projectDisplayName: string | null;
165
+ theme: PluginTheme;
166
+ layout: { compact: boolean };
167
+ animated?: boolean;
168
+ }) {
169
+ const { t } = useI18n();
170
+ const completedSubtasksCount = useMemo(
171
+ () => task.subtasks.filter((s) => s.completed).length,
172
+ [task.subtasks]
173
+ );
174
+
175
+ const styles = useMemo(
176
+ () =>
177
+ StyleSheet.create({
178
+ previewCard: {
179
+ userSelect: "none",
180
+ width: layout.compact ? 250 : 280,
181
+ backgroundColor: theme.colors.surface2,
182
+ borderColor: theme.colors.accent,
183
+ borderWidth: 2,
184
+ borderRadius: 8,
185
+ padding: layout.compact ? 10 : 12,
186
+ gap: 8,
187
+ ...(animated ? {} : { transform: [{ rotate: "2.5deg" }, { scale: 1.03 }] }),
188
+ shadowColor: "#000",
189
+ shadowOffset: { width: 0, height: 8 },
190
+ shadowOpacity: 0.25,
191
+ shadowRadius: 10,
192
+ elevation: 8,
193
+ },
194
+ cardHeader: {
195
+ flexDirection: "row",
196
+ alignItems: "flex-start",
197
+ gap: 6,
198
+ },
199
+ dragHandle: {
200
+ paddingHorizontal: 2,
201
+ paddingVertical: 2,
202
+ justifyContent: "center",
203
+ alignItems: "center",
204
+ },
205
+ title: {
206
+ flex: 1,
207
+ fontSize: layout.compact ? 13 : 14,
208
+ fontWeight: "600",
209
+ color: theme.colors.foreground,
210
+ userSelect: "none",
211
+ },
212
+ badgeRow: {
213
+ flexDirection: "row",
214
+ flexWrap: "wrap",
215
+ alignItems: "center",
216
+ gap: 6,
217
+ },
218
+ projectBadge: {
219
+ backgroundColor: theme.colors.surface1,
220
+ borderColor: theme.colors.border,
221
+ borderWidth: 1,
222
+ borderRadius: 4,
223
+ paddingHorizontal: 6,
224
+ paddingVertical: 2,
225
+ },
226
+ projectBadgeText: {
227
+ fontSize: 11,
228
+ color: theme.colors.foregroundMuted,
229
+ fontWeight: "500",
230
+ },
231
+ subtaskBadge: {
232
+ backgroundColor: theme.colors.surface1,
233
+ borderRadius: 4,
234
+ paddingHorizontal: 6,
235
+ paddingVertical: 2,
236
+ },
237
+ subtaskBadgeText: {
238
+ fontSize: 11,
239
+ color:
240
+ task.subtasks.length > 0 &&
241
+ completedSubtasksCount === task.subtasks.length
242
+ ? theme.colors.statusSuccess ?? theme.colors.accent
243
+ : theme.colors.foregroundMuted,
244
+ fontWeight: "500",
245
+ },
246
+ }),
247
+ [theme, layout.compact, task.subtasks.length, completedSubtasksCount]
248
+ );
249
+
250
+ return (
251
+ <View style={styles.previewCard} pointerEvents="none">
252
+ <View style={styles.cardHeader}>
253
+ <View style={styles.dragHandle}>
254
+ <Icon name="GripVertical" size={14} color={theme.colors.accent} />
255
+ </View>
256
+ <Text style={styles.title} numberOfLines={2}>
257
+ {task.title}
258
+ </Text>
259
+ </View>
260
+
261
+ <View style={styles.badgeRow}>
262
+ {projectDisplayName && (
263
+ <View style={styles.projectBadge}>
264
+ <Text style={styles.projectBadgeText} numberOfLines={1}>
265
+ {projectDisplayName}
266
+ </Text>
267
+ </View>
268
+ )}
269
+ {task.subtasks.length > 0 && (
270
+ <View style={styles.subtaskBadge}>
271
+ <Text style={styles.subtaskBadgeText}>
272
+ {t("card.subtasksCount", {
273
+ completed: completedSubtasksCount,
274
+ total: task.subtasks.length,
275
+ })}
276
+ </Text>
277
+ </View>
278
+ )}
279
+ </View>
280
+ </View>
281
+ );
282
+ }
283
+
284
+ export function KanbanDropSpacer({
285
+ height,
286
+ theme,
287
+ onLayout,
288
+ }: {
289
+ height?: number;
290
+ theme: PluginTheme;
291
+ onLayout?: (layout: { x: number; y: number; width: number; height: number }) => void;
292
+ }) {
293
+ const animHeight = useRef(new Animated.Value(0)).current;
294
+
295
+ useEffect(() => {
296
+ const targetHeight = height && height > 0 ? height : 64;
297
+ Animated.timing(animHeight, {
298
+ toValue: targetHeight,
299
+ duration: 160,
300
+ useNativeDriver: false,
301
+ }).start();
302
+ }, [height, animHeight]);
303
+
304
+ const styles = useMemo(
305
+ () =>
306
+ StyleSheet.create({
307
+ spacer: {
308
+ borderRadius: 8,
309
+ borderWidth: 2,
310
+ borderStyle: "dashed",
311
+ borderColor: theme.colors.accent,
312
+ backgroundColor: theme.colors.surface1,
313
+ opacity: 0.7,
314
+ },
315
+ }),
316
+ [theme]
317
+ );
318
+
319
+ return (
320
+ <Animated.View
321
+ style={[styles.spacer, { height: animHeight }]}
322
+ pointerEvents="none"
323
+ onLayout={(e) => onLayout?.(e.nativeEvent.layout)}
324
+ />
325
+ );
326
+ }