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,545 @@
1
+ import { useState, useMemo } from "react";
2
+ import {
3
+ View,
4
+ Text,
5
+ ScrollView,
6
+ Pressable,
7
+ StyleSheet,
8
+ ActivityIndicator,
9
+ } from "react-native";
10
+ import { useSettings } from "@getpaseo/plugin/client";
11
+ import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
12
+ import {
13
+ kanbanSettings,
14
+ filterTasksByProject,
15
+ type KanbanTask,
16
+ } from "../shared/kanban";
17
+ import { useProjects, getProjectDisplayName } from "./use-projects";
18
+ import { TaskModal } from "./task-modal";
19
+ import { LaneModal } from "./lane-modal";
20
+ import { useKanbanDrag } from "./kanban-drag";
21
+ import { KanbanLaneView } from "./kanban-lane";
22
+ import { KanbanDragOverlay } from "./kanban-overlay";
23
+ import {
24
+ openTaskSession,
25
+ openLaneSession,
26
+ executeTaskReorder,
27
+ type TaskEditSession,
28
+ type LaneEditSession,
29
+ } from "./kanban-session";
30
+ import { useI18n } from "./i18n";
31
+
32
+ export function KanbanBoardView(props: PluginSurfaceProps) {
33
+ const { theme, layout } = props;
34
+ const { t, language } = useI18n(props);
35
+ const settings = useSettings(kanbanSettings);
36
+ const { projects, resolveProjectName, isError: projectsError } = useProjects();
37
+
38
+ const [selectedProjectId, setSelectedProjectId] = useState<string>("all");
39
+ const [activeTaskSession, setActiveTaskSession] = useState<TaskEditSession | null>(null);
40
+ const [activeLaneSession, setActiveLaneSession] = useState<LaneEditSession | null>(null);
41
+ const [reorderError, setReorderError] = useState<string | null>(null);
42
+
43
+ const isReady = settings.status === "ready";
44
+ const board = isReady ? settings.values : null;
45
+ const revision = isReady ? settings.revision : "";
46
+
47
+ // Filter tasks based on selected project
48
+ const filteredTasks = useMemo(() => {
49
+ if (!board) return [];
50
+ return filterTasksByProject(board.tasks, selectedProjectId);
51
+ }, [board, selectedProjectId]);
52
+
53
+ const handleReorderTask = async (
54
+ taskId: string,
55
+ targetLaneId: string,
56
+ targetIndex: number
57
+ ) => {
58
+ if (!board) return;
59
+ const result = await executeTaskReorder({
60
+ baseBoard: board,
61
+ baseRevision: revision,
62
+ taskId,
63
+ targetLaneId,
64
+ targetIndex,
65
+ filter: selectedProjectId,
66
+ save: (b, r) => settings.save(b, r),
67
+ language,
68
+ });
69
+ if (!result.success) {
70
+ setReorderError(result.error);
71
+ } else {
72
+ setReorderError(null);
73
+ }
74
+ };
75
+
76
+ const getTaskPreview = (taskId: string) => {
77
+ if (!board) return null;
78
+ const task = board.tasks.find((t) => t.id === taskId) ?? null;
79
+ return {
80
+ task,
81
+ projectDisplayName: task ? resolveProjectName(task.projectId) : null,
82
+ };
83
+ };
84
+
85
+ const drag = useKanbanDrag({
86
+ lanes: board?.lanes ?? [],
87
+ onReorderTask: handleReorderTask,
88
+ getTaskPreview,
89
+ });
90
+
91
+ const openNewTask = (defaultLaneId?: string) => {
92
+ if (drag.isDragLocked() || !board) return;
93
+ setActiveTaskSession(
94
+ openTaskSession({
95
+ mode: "create",
96
+ baseBoard: board,
97
+ baseRevision: revision,
98
+ save: (b, r) => settings.save(b, r),
99
+ defaultLaneId: defaultLaneId ?? board.lanes[0]?.id ?? "",
100
+ defaultProjectId:
101
+ selectedProjectId === "all" || selectedProjectId === "unassigned"
102
+ ? null
103
+ : selectedProjectId,
104
+ language,
105
+ })
106
+ );
107
+ };
108
+
109
+ const openEditTask = (taskId: string) => {
110
+ if (drag.isDragLocked() || !board) return;
111
+ setActiveTaskSession(
112
+ openTaskSession({
113
+ mode: "edit",
114
+ taskId,
115
+ baseBoard: board,
116
+ baseRevision: revision,
117
+ save: (b, r) => settings.save(b, r),
118
+ language,
119
+ })
120
+ );
121
+ };
122
+
123
+ const openNewLane = () => {
124
+ if (drag.isDragLocked() || !board) return;
125
+ setActiveLaneSession(
126
+ openLaneSession({
127
+ mode: "create",
128
+ baseBoard: board,
129
+ baseRevision: revision,
130
+ save: (b, r) => settings.save(b, r),
131
+ language,
132
+ })
133
+ );
134
+ };
135
+
136
+ const openEditLane = (laneId: string) => {
137
+ if (drag.isDragLocked() || !board) return;
138
+ setActiveLaneSession(
139
+ openLaneSession({
140
+ mode: "edit",
141
+ laneId,
142
+ baseBoard: board,
143
+ baseRevision: revision,
144
+ save: (b, r) => settings.save(b, r),
145
+ language,
146
+ })
147
+ );
148
+ };
149
+
150
+ const styles = useMemo(
151
+ () =>
152
+ StyleSheet.create({
153
+ container: {
154
+ flex: 1,
155
+ backgroundColor: theme.colors.surface0,
156
+ userSelect: "none",
157
+ },
158
+ header: {
159
+ paddingHorizontal: layout.compact ? 12 : 20,
160
+ paddingVertical: layout.compact ? 10 : 14,
161
+ borderBottomWidth: 1,
162
+ borderBottomColor: theme.colors.border,
163
+ backgroundColor: theme.colors.surface0,
164
+ gap: 10,
165
+ },
166
+ headerTopRow: {
167
+ flexDirection: "row",
168
+ alignItems: "center",
169
+ justifyContent: "space-between",
170
+ flexWrap: "wrap",
171
+ gap: 8,
172
+ },
173
+ titleRow: {
174
+ flexDirection: "row",
175
+ alignItems: "center",
176
+ gap: 8,
177
+ },
178
+ titleText: {
179
+ fontSize: layout.compact ? 18 : 22,
180
+ fontWeight: "700",
181
+ color: theme.colors.foreground,
182
+ },
183
+ headerActions: {
184
+ flexDirection: "row",
185
+ alignItems: "center",
186
+ gap: 8,
187
+ },
188
+ primaryButton: {
189
+ backgroundColor: theme.colors.accent,
190
+ paddingHorizontal: 14,
191
+ paddingVertical: 8,
192
+ borderRadius: 8,
193
+ },
194
+ primaryButtonText: {
195
+ color: theme.colors.accentForeground,
196
+ fontSize: 13,
197
+ fontWeight: "600",
198
+ },
199
+ secondaryButton: {
200
+ backgroundColor: theme.colors.surface1,
201
+ borderWidth: 1,
202
+ borderColor: theme.colors.border,
203
+ paddingHorizontal: 12,
204
+ paddingVertical: 8,
205
+ borderRadius: 8,
206
+ },
207
+ secondaryButtonText: {
208
+ color: theme.colors.foreground,
209
+ fontSize: 13,
210
+ fontWeight: "500",
211
+ },
212
+ filterRow: {
213
+ flexDirection: "row",
214
+ alignItems: "center",
215
+ gap: 6,
216
+ },
217
+ filterLabel: {
218
+ fontSize: 12,
219
+ fontWeight: "600",
220
+ color: theme.colors.foregroundMuted,
221
+ marginRight: 4,
222
+ },
223
+ filterChip: {
224
+ paddingHorizontal: 10,
225
+ paddingVertical: 4,
226
+ borderRadius: 6,
227
+ backgroundColor: theme.colors.surface1,
228
+ borderWidth: 1,
229
+ borderColor: theme.colors.border,
230
+ },
231
+ filterChipSelected: {
232
+ backgroundColor: theme.colors.accent,
233
+ borderColor: theme.colors.accent,
234
+ },
235
+ filterChipText: {
236
+ fontSize: 12,
237
+ color: theme.colors.foreground,
238
+ },
239
+ filterChipTextSelected: {
240
+ color: theme.colors.accentForeground,
241
+ fontWeight: "600",
242
+ },
243
+ conflictBanner: {
244
+ marginHorizontal: layout.compact ? 12 : 20,
245
+ marginTop: 10,
246
+ padding: 10,
247
+ backgroundColor: theme.colors.surface2,
248
+ borderColor: theme.colors.statusDanger,
249
+ borderWidth: 1,
250
+ borderRadius: 8,
251
+ flexDirection: "row",
252
+ alignItems: "center",
253
+ justifyContent: "space-between",
254
+ },
255
+ conflictText: {
256
+ color: theme.colors.statusDanger,
257
+ fontSize: 13,
258
+ flex: 1,
259
+ },
260
+ conflictAction: {
261
+ marginLeft: 10,
262
+ paddingHorizontal: 8,
263
+ paddingVertical: 4,
264
+ backgroundColor: theme.colors.surface1,
265
+ borderRadius: 4,
266
+ borderWidth: 1,
267
+ borderColor: theme.colors.border,
268
+ },
269
+ lanesWrapper: {
270
+ flex: 1,
271
+ position: "relative",
272
+ userSelect: "none",
273
+ },
274
+ lanesContainer: {
275
+ flex: 1,
276
+ padding: layout.compact ? 10 : 16,
277
+ userSelect: "none",
278
+ },
279
+ lanesContent: {
280
+ flexDirection: "row",
281
+ gap: layout.compact ? 10 : 14,
282
+ paddingRight: 24,
283
+ },
284
+ centerBox: {
285
+ flex: 1,
286
+ alignItems: "center",
287
+ justifyContent: "center",
288
+ padding: 24,
289
+ gap: 12,
290
+ },
291
+ centerText: {
292
+ fontSize: 14,
293
+ color: theme.colors.foreground,
294
+ textAlign: "center",
295
+ },
296
+ }),
297
+ [theme, layout.compact]
298
+ );
299
+
300
+ if (settings.status === "loading") {
301
+ return (
302
+ <View style={[styles.container, styles.centerBox]}>
303
+ <ActivityIndicator size="large" color={theme.colors.accent} />
304
+ <Text style={styles.centerText}>{t("kanban.loading")}</Text>
305
+ </View>
306
+ );
307
+ }
308
+
309
+ if (settings.status === "error") {
310
+ return (
311
+ <View style={[styles.container, styles.centerBox]}>
312
+ <Text style={[styles.centerText, { color: theme.colors.statusDanger }]}>
313
+ {t("kanban.loadError", { error: settings.error })}
314
+ </Text>
315
+ <Pressable onPress={() => settings.reload()} style={styles.secondaryButton}>
316
+ <Text style={styles.secondaryButtonText}>{t("kanban.retryLoad")}</Text>
317
+ </Pressable>
318
+ </View>
319
+ );
320
+ }
321
+
322
+ if (settings.status === "invalid") {
323
+ return (
324
+ <View style={[styles.container, styles.centerBox]}>
325
+ <Text style={[styles.centerText, { color: theme.colors.statusWarning ?? theme.colors.statusDanger }]}>
326
+ {t("kanban.formatError", { error: settings.error })}
327
+ </Text>
328
+ <Text style={[styles.centerText, { color: theme.colors.foregroundMuted, fontSize: 12 }]}>
329
+ {t("kanban.formatErrorHint")}
330
+ </Text>
331
+ <View style={{ flexDirection: "row", gap: 10 }}>
332
+ <Pressable onPress={() => settings.reload()} style={styles.secondaryButton}>
333
+ <Text style={styles.secondaryButtonText}>{t("kanban.retryLoad")}</Text>
334
+ </Pressable>
335
+ <Pressable onPress={() => settings.reset()} style={styles.primaryButton}>
336
+ <Text style={styles.primaryButtonText}>{t("kanban.resetDefault")}</Text>
337
+ </Pressable>
338
+ </View>
339
+ </View>
340
+ );
341
+ }
342
+
343
+ if (!board) return null;
344
+
345
+ return (
346
+ <View style={styles.container}>
347
+ {/* Top Header */}
348
+ <View style={styles.header}>
349
+ <View style={styles.headerTopRow}>
350
+ <View style={styles.titleRow}>
351
+ <Text style={styles.titleText}>{t("kanban.title")}</Text>
352
+ <View style={{
353
+ backgroundColor: theme.colors.surface2,
354
+ paddingHorizontal: 6,
355
+ paddingVertical: 2,
356
+ borderRadius: 10,
357
+ }}>
358
+ <Text style={{
359
+ fontSize: 11,
360
+ color: theme.colors.foregroundMuted,
361
+ fontWeight: "600",
362
+ }}>
363
+ {t("kanban.stats", { tasks: board.tasks.length, lanes: board.lanes.length })}
364
+ </Text>
365
+ </View>
366
+ </View>
367
+
368
+ <View style={styles.headerActions}>
369
+ <Pressable
370
+ onPress={openNewLane}
371
+ style={styles.secondaryButton}
372
+ >
373
+ <Text style={styles.secondaryButtonText}>{t("kanban.addLane")}</Text>
374
+ </Pressable>
375
+
376
+ <Pressable
377
+ onPress={() => openNewTask()}
378
+ style={styles.primaryButton}
379
+ >
380
+ <Text style={styles.primaryButtonText}>{t("kanban.newTask")}</Text>
381
+ </Pressable>
382
+ </View>
383
+ </View>
384
+
385
+ {/* Project Filter */}
386
+ <ScrollView horizontal showsHorizontalScrollIndicator={false}>
387
+ <View style={styles.filterRow}>
388
+ <Text style={styles.filterLabel}>{t("kanban.filterLabel")}</Text>
389
+ <Pressable
390
+ onPress={() => {
391
+ if (drag.isDragLocked()) return;
392
+ setSelectedProjectId("all");
393
+ }}
394
+ style={[
395
+ styles.filterChip,
396
+ selectedProjectId === "all" && styles.filterChipSelected,
397
+ ]}
398
+ >
399
+ <Text
400
+ style={[
401
+ styles.filterChipText,
402
+ selectedProjectId === "all" && styles.filterChipTextSelected,
403
+ ]}
404
+ >
405
+ {t("kanban.filterAll")}
406
+ </Text>
407
+ </Pressable>
408
+
409
+ <Pressable
410
+ onPress={() => {
411
+ if (drag.isDragLocked()) return;
412
+ setSelectedProjectId("unassigned");
413
+ }}
414
+ style={[
415
+ styles.filterChip,
416
+ selectedProjectId === "unassigned" && styles.filterChipSelected,
417
+ ]}
418
+ >
419
+ <Text
420
+ style={[
421
+ styles.filterChipText,
422
+ selectedProjectId === "unassigned" && styles.filterChipTextSelected,
423
+ ]}
424
+ >
425
+ {t("kanban.filterUnassigned")}
426
+ </Text>
427
+ </Pressable>
428
+
429
+ {projects.map((proj) => {
430
+ const isSelected = selectedProjectId === proj.projectId;
431
+ return (
432
+ <Pressable
433
+ key={proj.projectId}
434
+ onPress={() => {
435
+ if (drag.isDragLocked()) return;
436
+ setSelectedProjectId(proj.projectId);
437
+ }}
438
+ style={[
439
+ styles.filterChip,
440
+ isSelected && styles.filterChipSelected,
441
+ ]}
442
+ >
443
+ <Text
444
+ style={[
445
+ styles.filterChipText,
446
+ isSelected && styles.filterChipTextSelected,
447
+ ]}
448
+ >
449
+ {proj.projectDisplayName}
450
+ </Text>
451
+ </Pressable>
452
+ );
453
+ })}
454
+
455
+ {projectsError && (
456
+ <Text style={{ fontSize: 11, color: theme.colors.statusWarning ?? theme.colors.foregroundMuted }}>
457
+ {t("kanban.projectsError")}
458
+ </Text>
459
+ )}
460
+ </View>
461
+ </ScrollView>
462
+ </View>
463
+
464
+ {/* Conflict / Save Error Notification */}
465
+ {(settings.saveError || reorderError) && (
466
+ <View style={styles.conflictBanner}>
467
+ <Text style={styles.conflictText}>
468
+ {reorderError
469
+ ? t("kanban.saveFailed", { error: reorderError })
470
+ : t("kanban.conflictError", { error: settings.saveError })}
471
+ </Text>
472
+ <Pressable
473
+ onPress={() => {
474
+ setReorderError(null);
475
+ settings.reload();
476
+ }}
477
+ style={styles.conflictAction}
478
+ >
479
+ <Text style={{ fontSize: 12, color: theme.colors.foreground }}>{t("kanban.refresh")}</Text>
480
+ </Pressable>
481
+ </View>
482
+ )}
483
+
484
+ {/* Main Board Lanes */}
485
+ <View style={styles.lanesWrapper}>
486
+ <ScrollView
487
+ ref={drag.bindContainerRef}
488
+ horizontal
489
+ style={styles.lanesContainer}
490
+ contentContainerStyle={styles.lanesContent}
491
+ onLayout={(e) => drag.handleContainerLayout(e.nativeEvent.layout)}
492
+ onScroll={(e) => drag.handleContainerScroll(e.nativeEvent.contentOffset.x)}
493
+ scrollEventThrottle={16}
494
+ >
495
+ {board.lanes.map((lane) => {
496
+ const laneTasks = filteredTasks.filter((t) => t.laneId === lane.id);
497
+ return (
498
+ <KanbanLaneView
499
+ key={lane.id}
500
+ lane={lane}
501
+ tasks={laneTasks}
502
+ projects={projects}
503
+ resolveProjectName={resolveProjectName}
504
+ theme={theme}
505
+ layout={layout}
506
+ dragBinding={drag.bindLane(lane.id)}
507
+ onAddTask={(laneId) => openNewTask(laneId)}
508
+ onManageLane={(laneId) => openEditLane(laneId)}
509
+ onSelectTask={(task) => openEditTask(task.id)}
510
+ />
511
+ );
512
+ })}
513
+ </ScrollView>
514
+
515
+ <KanbanDragOverlay
516
+ drag={drag}
517
+ theme={theme}
518
+ layout={layout}
519
+ />
520
+ </View>
521
+
522
+ {/* Modals with Session Baseline Locking */}
523
+ {activeTaskSession && (
524
+ <TaskModal
525
+ open={activeTaskSession !== null}
526
+ onClose={() => setActiveTaskSession(null)}
527
+ session={activeTaskSession}
528
+ projects={projects}
529
+ theme={theme}
530
+ layout={layout}
531
+ />
532
+ )}
533
+
534
+ {activeLaneSession && (
535
+ <LaneModal
536
+ open={activeLaneSession !== null}
537
+ onClose={() => setActiveLaneSession(null)}
538
+ session={activeLaneSession}
539
+ theme={theme}
540
+ layout={layout}
541
+ />
542
+ )}
543
+ </View>
544
+ );
545
+ }