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,240 @@
1
+ import { useMemo, useEffect, Fragment, memo } from "react";
2
+ import { View, Text, ScrollView, Pressable, StyleSheet, PanResponder } from "react-native";
3
+ import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
4
+ import type { KanbanLane, KanbanTask } from "../shared/kanban";
5
+ import { KanbanCard, KanbanDropSpacer } from "./kanban-card";
6
+ import { getProjectDisplayName, type ProjectItem } from "./use-projects";
7
+ import type { LaneDragBinding } from "./kanban-drag";
8
+ import { useI18n } from "./i18n";
9
+
10
+ type PluginTheme = PluginSurfaceProps["theme"];
11
+
12
+ export interface KanbanLaneViewProps {
13
+ lane: KanbanLane;
14
+ tasks: KanbanTask[];
15
+ projects: ProjectItem[];
16
+ theme: PluginTheme;
17
+ layout: { compact: boolean; platform: "ios" | "android" | "web" };
18
+ dragBinding: LaneDragBinding;
19
+ onAddTask: (laneId: string) => void;
20
+ onManageLane: (laneId: string) => void;
21
+ onSelectTask: (task: KanbanTask) => void;
22
+ resolveProjectName?: (projectId: string | null) => string | null;
23
+ }
24
+
25
+ function KanbanLaneViewInner({
26
+ lane,
27
+ tasks,
28
+ projects,
29
+ theme,
30
+ layout,
31
+ dragBinding,
32
+ onAddTask,
33
+ onManageLane,
34
+ onSelectTask,
35
+ resolveProjectName,
36
+ }: KanbanLaneViewProps) {
37
+ const { t } = useI18n();
38
+ const {
39
+ isHovered,
40
+ targetIndex,
41
+ isTaskDragging,
42
+ draggedCardHeight,
43
+ registerLaneLayout,
44
+ registerCardsViewport,
45
+ handleLaneScroll,
46
+ setDropSpacerY,
47
+ setLaneCardOrder,
48
+ bindCard,
49
+ } = dragBinding;
50
+
51
+ // Sync card order to controller whenever tasks in this lane change
52
+ useEffect(() => {
53
+ setLaneCardOrder?.(lane.id, tasks.map((t) => t.id));
54
+ }, [lane.id, tasks, setLaneCardOrder]);
55
+
56
+ const styles = useMemo(
57
+ () =>
58
+ StyleSheet.create({
59
+ laneColumn: {
60
+ width: layout.compact ? 270 : 300,
61
+ backgroundColor: theme.colors.surface1,
62
+ borderColor: theme.colors.border,
63
+ borderWidth: 2,
64
+ borderRadius: 10,
65
+ maxHeight: "100%",
66
+ padding: 10,
67
+ gap: 10,
68
+ userSelect: "none",
69
+ },
70
+ laneColumnHovered: {
71
+ borderColor: theme.colors.accent,
72
+ backgroundColor: theme.colors.surface2,
73
+ },
74
+ laneHeader: {
75
+ flexDirection: "row",
76
+ alignItems: "center",
77
+ justifyContent: "space-between",
78
+ paddingBottom: 8,
79
+ borderBottomWidth: 1,
80
+ borderBottomColor: theme.colors.border,
81
+ },
82
+ laneTitleGroup: {
83
+ flexDirection: "row",
84
+ alignItems: "center",
85
+ gap: 6,
86
+ flex: 1,
87
+ },
88
+ laneTitleText: {
89
+ fontSize: 14,
90
+ fontWeight: "700",
91
+ color: theme.colors.foreground,
92
+ userSelect: "none",
93
+ },
94
+ laneCountBadge: {
95
+ backgroundColor: theme.colors.surface2,
96
+ paddingHorizontal: 6,
97
+ paddingVertical: 2,
98
+ borderRadius: 10,
99
+ },
100
+ laneCountText: {
101
+ fontSize: 11,
102
+ color: theme.colors.foregroundMuted,
103
+ fontWeight: "600",
104
+ },
105
+ laneHeaderActions: {
106
+ flexDirection: "row",
107
+ alignItems: "center",
108
+ gap: 4,
109
+ },
110
+ laneHeaderBtn: {
111
+ paddingHorizontal: 6,
112
+ paddingVertical: 3,
113
+ borderRadius: 4,
114
+ backgroundColor: theme.colors.surface2,
115
+ },
116
+ laneHeaderBtnText: {
117
+ fontSize: 11,
118
+ color: theme.colors.foreground,
119
+ },
120
+ cardsScroll: {
121
+ flex: 1,
122
+ },
123
+ cardsList: {
124
+ gap: 8,
125
+ paddingBottom: 8,
126
+ },
127
+ emptyLanePlaceholder: {
128
+ paddingVertical: 24,
129
+ alignItems: "center",
130
+ justifyContent: "center",
131
+ borderStyle: "dashed",
132
+ borderWidth: 1,
133
+ borderColor: theme.colors.border,
134
+ borderRadius: 8,
135
+ marginVertical: 6,
136
+ },
137
+ emptyLaneText: {
138
+ fontSize: 12,
139
+ color: theme.colors.foregroundMuted,
140
+ textAlign: "center",
141
+ paddingHorizontal: 12,
142
+ userSelect: "none",
143
+ },
144
+ }),
145
+ [theme, layout.compact]
146
+ );
147
+
148
+ const dropBeforeTaskId = isHovered
149
+ ? tasks.filter((task) => !isTaskDragging(task.id))[targetIndex]?.id
150
+ : undefined;
151
+
152
+ return (
153
+ <View
154
+ style={[styles.laneColumn, isHovered && styles.laneColumnHovered]}
155
+ onLayout={(e) => registerLaneLayout(e.nativeEvent.layout)}
156
+ >
157
+ {/* Lane Header */}
158
+ <View style={styles.laneHeader}>
159
+ <View style={styles.laneTitleGroup}>
160
+ <Text style={styles.laneTitleText} numberOfLines={1}>
161
+ {lane.title}
162
+ </Text>
163
+ <View style={styles.laneCountBadge}>
164
+ <Text style={styles.laneCountText}>{tasks.length}</Text>
165
+ </View>
166
+ </View>
167
+
168
+ <View style={styles.laneHeaderActions}>
169
+ <Pressable
170
+ onPress={() => onAddTask(lane.id)}
171
+ style={styles.laneHeaderBtn}
172
+ hitSlop={6}
173
+ >
174
+ <Text style={styles.laneHeaderBtnText}>{t("lane.add")}</Text>
175
+ </Pressable>
176
+
177
+ <Pressable
178
+ onPress={() => onManageLane(lane.id)}
179
+ style={styles.laneHeaderBtn}
180
+ hitSlop={6}
181
+ >
182
+ <Text style={styles.laneHeaderBtnText}>{t("lane.manage")}</Text>
183
+ </Pressable>
184
+ </View>
185
+ </View>
186
+
187
+ {/* Cards Viewport */}
188
+ <ScrollView
189
+ style={styles.cardsScroll}
190
+ showsVerticalScrollIndicator={false}
191
+ onLayout={(e) => registerCardsViewport(e.nativeEvent.layout)}
192
+ onScroll={(e) => handleLaneScroll(e.nativeEvent.contentOffset.y)}
193
+ scrollEventThrottle={16}
194
+ >
195
+ <View style={styles.cardsList}>
196
+ {tasks.map((task) => (
197
+ <Fragment key={task.id}>
198
+ {isHovered && dropBeforeTaskId === task.id && (
199
+ <KanbanDropSpacer
200
+ theme={theme}
201
+ height={draggedCardHeight}
202
+ onLayout={(rect) => setDropSpacerY(rect.y)}
203
+ />
204
+ )}
205
+ <KanbanCard
206
+ task={task}
207
+ projectDisplayName={
208
+ resolveProjectName
209
+ ? resolveProjectName(task.projectId)
210
+ : getProjectDisplayName(task.projectId, projects)
211
+ }
212
+ theme={theme}
213
+ layout={layout}
214
+ binding={bindCard(task.id, () => onSelectTask(task), PanResponder)}
215
+ />
216
+ </Fragment>
217
+ ))}
218
+
219
+ {isHovered && !dropBeforeTaskId && (
220
+ <KanbanDropSpacer
221
+ theme={theme}
222
+ height={draggedCardHeight}
223
+ onLayout={(rect) => setDropSpacerY(rect.y)}
224
+ />
225
+ )}
226
+
227
+ {tasks.length === 0 && !isHovered && (
228
+ <View style={styles.emptyLanePlaceholder}>
229
+ <Text style={styles.emptyLaneText}>
230
+ {t("lane.emptyHint")}
231
+ </Text>
232
+ </View>
233
+ )}
234
+ </View>
235
+ </ScrollView>
236
+ </View>
237
+ );
238
+ }
239
+
240
+ export const KanbanLaneView = memo(KanbanLaneViewInner);
@@ -0,0 +1,186 @@
1
+ import { useEffect, useRef, useSyncExternalStore, memo } from "react";
2
+ import { Animated, View, StyleSheet } from "react-native";
3
+ import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
4
+ import type { KanbanTask } from "../shared/kanban";
5
+ import { KanbanCardPreview } from "./kanban-card";
6
+ import type { UseKanbanDragReturn } from "./kanban-drag";
7
+
8
+ type PluginTheme = PluginSurfaceProps["theme"];
9
+
10
+ const AnimatedView = (Animated && Animated.View) || View;
11
+
12
+ export interface KanbanDragOverlayProps {
13
+ drag: UseKanbanDragReturn;
14
+ theme: PluginTheme;
15
+ layout: { compact: boolean };
16
+ task?: KanbanTask | null;
17
+ projectDisplayName?: string | null;
18
+ }
19
+
20
+ function KanbanDragOverlayInner({
21
+ drag,
22
+ theme,
23
+ layout,
24
+ task: propTask,
25
+ projectDisplayName: propProjectName,
26
+ }: KanbanDragOverlayProps) {
27
+ const liveFeedback = useSyncExternalStore(
28
+ drag.controller ? drag.controller.subscribe : (() => () => {}),
29
+ drag.controller ? drag.controller.getFeedback : (() => drag.feedback),
30
+ drag.controller ? drag.controller.getFeedback : (() => drag.feedback)
31
+ );
32
+
33
+ const curDropping = useSyncExternalStore(
34
+ drag.controller ? drag.controller.subscribeDrop : (() => () => {}),
35
+ drag.controller ? drag.controller.getDroppingState : (() => drag.droppingState),
36
+ drag.controller ? drag.controller.getDroppingState : (() => drag.droppingState)
37
+ );
38
+ const animPos = useRef(Animated?.ValueXY ? new Animated.ValueXY({ x: 0, y: 0 }) : null).current;
39
+ const animRotate = useRef(Animated?.Value ? new Animated.Value(1) : null).current;
40
+ const animScale = useRef(Animated?.Value ? new Animated.Value(1.03) : null).current;
41
+ const droppingHandledRef = useRef<string | null>(null);
42
+ const lastOpIdRef = useRef<string | null>(null);
43
+
44
+ if (curDropping && animPos && animRotate && animScale && lastOpIdRef.current !== curDropping.operationId) {
45
+ lastOpIdRef.current = curDropping.operationId;
46
+ animPos.setValue({ x: curDropping.fromX, y: curDropping.fromY });
47
+ animRotate.setValue(1);
48
+ animScale.setValue(1.03);
49
+ } else if (!curDropping) {
50
+ lastOpIdRef.current = null;
51
+ }
52
+
53
+ useEffect(() => {
54
+ if (!curDropping) {
55
+ droppingHandledRef.current = null;
56
+ return;
57
+ }
58
+
59
+ const dropKey = curDropping.operationId || `${curDropping.taskId}_${curDropping.targetLaneId}_${curDropping.targetIndex}`;
60
+ if (droppingHandledRef.current === dropKey) {
61
+ return;
62
+ }
63
+ droppingHandledRef.current = dropKey;
64
+
65
+ if (!Animated || !animPos || !animRotate || !animScale) {
66
+ drag.commitDrop();
67
+ return;
68
+ }
69
+
70
+ animPos.setValue({ x: curDropping.fromX, y: curDropping.fromY });
71
+ animRotate.setValue(1);
72
+ animScale.setValue(1.03);
73
+
74
+ Animated.parallel([
75
+ Animated.spring(animPos, {
76
+ toValue: { x: curDropping.toX, y: curDropping.toY },
77
+ useNativeDriver: false,
78
+ friction: 9,
79
+ tension: 90,
80
+ restDisplacementThreshold: 1,
81
+ restSpeedThreshold: 1,
82
+ }),
83
+ Animated.timing(animRotate, {
84
+ toValue: 0,
85
+ duration: 160,
86
+ useNativeDriver: false,
87
+ }),
88
+ Animated.timing(animScale, {
89
+ toValue: 1,
90
+ duration: 160,
91
+ useNativeDriver: false,
92
+ }),
93
+ ]).start((result) => {
94
+ if (result === undefined || result?.finished) {
95
+ drag.commitDrop();
96
+ } else {
97
+ drag.abortDrop?.();
98
+ }
99
+ });
100
+ }, [curDropping, drag, animPos, animRotate, animScale]);
101
+
102
+ const activeTask =
103
+ curDropping?.task ??
104
+ propTask ??
105
+ (curDropping?.taskId && drag.getTaskPreview
106
+ ? drag.getTaskPreview(curDropping.taskId)?.task
107
+ : null) ??
108
+ (liveFeedback.draggingTaskId && drag.getTaskPreview
109
+ ? drag.getTaskPreview(liveFeedback.draggingTaskId)?.task
110
+ : null);
111
+ const activeProjectName =
112
+ curDropping?.projectName ??
113
+ propProjectName ??
114
+ (curDropping?.taskId && drag.getTaskPreview
115
+ ? drag.getTaskPreview(curDropping.taskId)?.projectDisplayName
116
+ : null) ??
117
+ (liveFeedback.draggingTaskId && drag.getTaskPreview
118
+ ? drag.getTaskPreview(liveFeedback.draggingTaskId)?.projectDisplayName
119
+ : null);
120
+
121
+ const isVisible =
122
+ (liveFeedback.isDragging && typeof liveFeedback.pointerX === "number" && activeTask !== null) ||
123
+ (curDropping !== null && activeTask !== null);
124
+
125
+ if (!isVisible || !activeTask) {
126
+ return null;
127
+ }
128
+
129
+ const containerBounds = drag.controller?.getContainerBounds?.() ?? null;
130
+ const containerX = containerBounds?.x ?? 0;
131
+ const containerY = containerBounds?.y ?? 0;
132
+ const cardHalfWidth = layout.compact ? 125 : 140;
133
+
134
+ let overlayStyle: any;
135
+ if (curDropping && animPos && animRotate && animScale) {
136
+ overlayStyle = {
137
+ left: animPos.x,
138
+ top: animPos.y,
139
+ transform: [
140
+ {
141
+ rotate: animRotate.interpolate({
142
+ inputRange: [0, 1],
143
+ outputRange: ["0deg", "2.5deg"],
144
+ }),
145
+ },
146
+ { scale: animScale },
147
+ ],
148
+ };
149
+ } else if (typeof liveFeedback.pointerX === "number" && typeof liveFeedback.pointerY === "number") {
150
+ overlayStyle = {
151
+ left: liveFeedback.pointerX - containerX - cardHalfWidth,
152
+ top: liveFeedback.pointerY - containerY - 20,
153
+ transform: [{ rotate: "2.5deg" }, { scale: 1.03 }],
154
+ };
155
+ } else {
156
+ return null;
157
+ }
158
+
159
+ return (
160
+ <AnimatedView
161
+ pointerEvents="none"
162
+ style={[
163
+ styles.dragOverlay,
164
+ overlayStyle,
165
+ ]}
166
+ >
167
+ <KanbanCardPreview
168
+ task={activeTask}
169
+ projectDisplayName={activeProjectName ?? null}
170
+ theme={theme}
171
+ layout={layout}
172
+ animated={true}
173
+ />
174
+ </AnimatedView>
175
+ );
176
+ }
177
+
178
+ const styles = StyleSheet.create({
179
+ dragOverlay: {
180
+ position: "absolute",
181
+ zIndex: 9999,
182
+ pointerEvents: "none",
183
+ },
184
+ });
185
+
186
+ export const KanbanDragOverlay = memo(KanbanDragOverlayInner);