handle drag n drop
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import type { FlutterEvent, FlutterTimelineState } from "@/lib/flutter-bridge";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@@ -28,8 +29,8 @@ declare global {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type FlutterViewProps = {
|
type FlutterViewProps = {
|
||||||
state: Record<string, unknown>;
|
state: FlutterTimelineState;
|
||||||
onEvent: (event: { type: string; payload?: Record<string, unknown> }) => void;
|
onEvent: (event: FlutterEvent) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
height?: number;
|
height?: number;
|
||||||
};
|
};
|
||||||
@@ -60,10 +61,7 @@ export function FlutterView({
|
|||||||
window.__zendegi__ = {
|
window.__zendegi__ = {
|
||||||
getState: () => JSON.stringify(stateRef.current),
|
getState: () => JSON.stringify(stateRef.current),
|
||||||
onEvent: (json: string) => {
|
onEvent: (json: string) => {
|
||||||
const event = JSON.parse(json) as {
|
const event = JSON.parse(json) as FlutterEvent;
|
||||||
type: string;
|
|
||||||
payload?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
onEventRef.current(event);
|
onEventRef.current(event);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const updateTimelineItem = createServerFn({ method: "POST" })
|
|||||||
start: z.string().transform((s) => new Date(s)),
|
start: z.string().transform((s) => new Date(s)),
|
||||||
end: z
|
end: z
|
||||||
.string()
|
.string()
|
||||||
.nullable()
|
.nullish()
|
||||||
.transform((s) => (s ? new Date(s) : null)),
|
.transform((s) => (s ? new Date(s) : null)),
|
||||||
timelineGroupId: z.string().uuid(),
|
timelineGroupId: z.string().uuid(),
|
||||||
lane: z.number().int().min(1),
|
lane: z.number().int().min(1),
|
||||||
|
|||||||
93
apps/web/src/hooks/use-entry-moved-mutation.ts
Normal file
93
apps/web/src/hooks/use-entry-moved-mutation.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { getTimeline } from "@/functions/get-timeline";
|
||||||
|
import { updateTimelineItem } from "@/functions/update-timeline-item";
|
||||||
|
|
||||||
|
type TimelineData = Awaited<ReturnType<typeof getTimeline>>;
|
||||||
|
|
||||||
|
type EntryMovedVars = {
|
||||||
|
entryId: string;
|
||||||
|
newStart: string;
|
||||||
|
newEnd: string | null;
|
||||||
|
newGroupId: string;
|
||||||
|
newLane: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useEntryMovedMutation(timelineId: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const queryKey = ["timeline", timelineId];
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (vars: EntryMovedVars) =>
|
||||||
|
updateTimelineItem({
|
||||||
|
data: {
|
||||||
|
id: vars.entryId,
|
||||||
|
start: vars.newStart,
|
||||||
|
end: vars.newEnd,
|
||||||
|
timelineGroupId: vars.newGroupId,
|
||||||
|
lane: vars.newLane,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
onMutate: async (vars) => {
|
||||||
|
// Cancel in-flight fetches so they don't overwrite our optimistic update
|
||||||
|
await queryClient.cancelQueries({ queryKey });
|
||||||
|
const previous = queryClient.getQueryData<TimelineData>(queryKey);
|
||||||
|
|
||||||
|
queryClient.setQueryData<TimelineData>(queryKey, (old) => {
|
||||||
|
if (!old) return old;
|
||||||
|
|
||||||
|
let movedItem:
|
||||||
|
| TimelineData["groups"][number]["items"][number]
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
const groups = old.groups.map((group) => {
|
||||||
|
const filtered = group.items.filter((item) => {
|
||||||
|
if (item.id === vars.entryId) {
|
||||||
|
movedItem = item;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return { ...group, items: filtered };
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!movedItem) return old;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
groups: groups.map((group) =>
|
||||||
|
group.id === vars.newGroupId
|
||||||
|
? {
|
||||||
|
...group,
|
||||||
|
items: [
|
||||||
|
...group.items,
|
||||||
|
{
|
||||||
|
...movedItem!,
|
||||||
|
start: new Date(vars.newStart),
|
||||||
|
end: vars.newEnd ? new Date(vars.newEnd) : null,
|
||||||
|
lane: vars.newLane,
|
||||||
|
timelineGroupId: vars.newGroupId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: group
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { previous };
|
||||||
|
},
|
||||||
|
|
||||||
|
onError: (_err, _vars, context) => {
|
||||||
|
// Roll back to the previous cache state on server error
|
||||||
|
if (context?.previous) {
|
||||||
|
queryClient.setQueryData(queryKey, context.previous);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onSettled: () => {
|
||||||
|
// Re-fetch from server to ensure consistency
|
||||||
|
queryClient.invalidateQueries({ queryKey });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
59
apps/web/src/lib/flutter-bridge.ts
Normal file
59
apps/web/src/lib/flutter-bridge.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Typed contract for React <-> Flutter communication.
|
||||||
|
*
|
||||||
|
* State : React -> Flutter (pushed on every render)
|
||||||
|
* Events : Flutter -> React (emitted by user interaction in Flutter)
|
||||||
|
*
|
||||||
|
* Keep this file in sync with `packages/z-timeline/lib/state.dart` and
|
||||||
|
* the `emitEvent()` calls in `packages/z-timeline/lib/main.dart`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// State: React -> Flutter
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type FlutterTimelineItem = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
start: string; // ISO-8601
|
||||||
|
end: string | null; // ISO-8601 | null for point-events
|
||||||
|
lane: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FlutterTimelineGroup = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
sortOrder: number;
|
||||||
|
items: Array<FlutterTimelineItem>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FlutterTimelineData = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
groups: Array<FlutterTimelineGroup>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FlutterTimelineState = {
|
||||||
|
timeline: FlutterTimelineData;
|
||||||
|
selectedItemId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Events: Flutter -> React (discriminated union on `type`)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type FlutterEvent =
|
||||||
|
| { type: "content_height"; payload: { height: number } }
|
||||||
|
| { type: "item_selected"; payload: { itemId: string } }
|
||||||
|
| { type: "item_deselected" }
|
||||||
|
| {
|
||||||
|
type: "entry_moved";
|
||||||
|
payload: {
|
||||||
|
entryId: string;
|
||||||
|
newStart: string;
|
||||||
|
newEnd: string | null;
|
||||||
|
newGroupId: string;
|
||||||
|
newLane: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { useSuspenseQuery, useQueryClient } from "@tanstack/react-query";
|
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||||
|
import type { FlutterEvent, FlutterTimelineState } from "@/lib/flutter-bridge";
|
||||||
import { timelineQueryOptions } from "@/functions/get-timeline";
|
import { timelineQueryOptions } from "@/functions/get-timeline";
|
||||||
import { updateTimelineItem } from "@/functions/update-timeline-item";
|
|
||||||
import { FlutterView } from "@/components/flutter-view";
|
import { FlutterView } from "@/components/flutter-view";
|
||||||
|
import { useEntryMovedMutation } from "@/hooks/use-entry-moved-mutation";
|
||||||
|
|
||||||
export const Route = createFileRoute("/timeline/$timelineId")({
|
export const Route = createFileRoute("/timeline/$timelineId")({
|
||||||
loader: async ({ context, params }) => {
|
loader: async ({ context, params }) => {
|
||||||
@@ -16,12 +17,12 @@ export const Route = createFileRoute("/timeline/$timelineId")({
|
|||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const { timelineId } = Route.useParams();
|
const { timelineId } = Route.useParams();
|
||||||
const timelineQuery = useSuspenseQuery(timelineQueryOptions(timelineId));
|
const { data: timeline } = useSuspenseQuery(timelineQueryOptions(timelineId));
|
||||||
const timeline = timelineQuery.data;
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
|
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
|
||||||
|
const [flutterHeight, setFlutterHeight] = useState<number | undefined>();
|
||||||
|
const entryMoved = useEntryMovedMutation(timelineId);
|
||||||
|
|
||||||
const flutterState = useMemo(
|
const flutterState: FlutterTimelineState = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
timeline: {
|
timeline: {
|
||||||
id: timeline.id,
|
id: timeline.id,
|
||||||
@@ -34,8 +35,8 @@ function RouteComponent() {
|
|||||||
id: item.id,
|
id: item.id,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
description: item.description,
|
description: item.description,
|
||||||
start: item.start,
|
start: item.start.toISOString(),
|
||||||
end: item.end,
|
end: item.end?.toISOString() ?? null,
|
||||||
lane: item.lane,
|
lane: item.lane,
|
||||||
})),
|
})),
|
||||||
})),
|
})),
|
||||||
@@ -45,89 +46,24 @@ function RouteComponent() {
|
|||||||
[timeline, selectedItemId]
|
[timeline, selectedItemId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const [flutterHeight, setFlutterHeight] = useState<number | undefined>();
|
|
||||||
|
|
||||||
const handleEvent = useCallback(
|
const handleEvent = useCallback(
|
||||||
(event: { type: string; payload?: Record<string, unknown> }) => {
|
(event: FlutterEvent) => {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "content_height":
|
case "content_height":
|
||||||
setFlutterHeight(event.payload?.height as number);
|
setFlutterHeight(event.payload.height);
|
||||||
break;
|
break;
|
||||||
case "item_selected":
|
case "item_selected":
|
||||||
// setSelectedItemId((event.payload?.itemId as string) ?? null);
|
setSelectedItemId(event.payload.itemId);
|
||||||
break;
|
break;
|
||||||
case "item_deselected":
|
case "item_deselected":
|
||||||
setSelectedItemId(null);
|
setSelectedItemId(null);
|
||||||
break;
|
break;
|
||||||
case "entry_moved": {
|
case "entry_moved":
|
||||||
const p = event.payload;
|
entryMoved.mutate(event.payload);
|
||||||
if (!p) break;
|
|
||||||
const entryId = p.entryId as string;
|
|
||||||
const newStart = p.newStart as string;
|
|
||||||
const newEnd = p.newEnd as string;
|
|
||||||
const newGroupId = p.newGroupId as string;
|
|
||||||
const newLane = p.newLane as number;
|
|
||||||
|
|
||||||
// Optimistic cache update
|
|
||||||
queryClient.setQueryData(
|
|
||||||
["timeline", timelineId],
|
|
||||||
(old: typeof timeline | undefined) => {
|
|
||||||
if (!old) return old;
|
|
||||||
let movedItem:
|
|
||||||
| (typeof old.groups)[number]["items"][number]
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
const groups = old.groups.map((group) => {
|
|
||||||
const filtered = group.items.filter((item) => {
|
|
||||||
if (item.id === entryId) {
|
|
||||||
movedItem = item;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
return { ...group, items: filtered };
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!movedItem) return old;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...old,
|
|
||||||
groups: groups.map((group) =>
|
|
||||||
group.id === newGroupId
|
|
||||||
? {
|
|
||||||
...group,
|
|
||||||
items: [
|
|
||||||
...group.items,
|
|
||||||
{
|
|
||||||
...movedItem!,
|
|
||||||
start: newStart,
|
|
||||||
end: newEnd,
|
|
||||||
lane: newLane,
|
|
||||||
timelineGroupId: newGroupId,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: group
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Persist to DB
|
|
||||||
updateTimelineItem({
|
|
||||||
data: {
|
|
||||||
id: entryId,
|
|
||||||
start: newStart,
|
|
||||||
end: newEnd,
|
|
||||||
timelineGroupId: newGroupId,
|
|
||||||
lane: newLane,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[queryClient, timelineId]
|
[entryMoved]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ class _MainAppState extends State<MainApp> {
|
|||||||
start: start,
|
start: start,
|
||||||
end: end,
|
end: end,
|
||||||
lane: item.lane,
|
lane: item.lane,
|
||||||
|
hasEnd: item.end != null,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -118,16 +119,21 @@ class _MainAppState extends State<MainApp> {
|
|||||||
String newGroupId,
|
String newGroupId,
|
||||||
int newLane,
|
int newLane,
|
||||||
) {
|
) {
|
||||||
final duration = entry.end.difference(entry.start);
|
final payload = <String, Object?>{
|
||||||
final newEnd = newStart.add(duration);
|
|
||||||
|
|
||||||
emitEvent('entry_moved', {
|
|
||||||
'entryId': entry.id,
|
'entryId': entry.id,
|
||||||
'newStart': newStart.toIso8601String(),
|
'newStart': newStart.toIso8601String(),
|
||||||
'newEnd': newEnd.toIso8601String(),
|
|
||||||
'newGroupId': newGroupId,
|
'newGroupId': newGroupId,
|
||||||
'newLane': newLane,
|
'newLane': newLane,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (entry.hasEnd) {
|
||||||
|
final duration = entry.end.difference(entry.start);
|
||||||
|
payload['newEnd'] = newStart.add(duration).toIso8601String();
|
||||||
|
} else {
|
||||||
|
payload['newEnd'] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
emitEvent('entry_moved', payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _emitContentHeight() {
|
void _emitContentHeight() {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ class TimelineEntry {
|
|||||||
required this.start,
|
required this.start,
|
||||||
required this.end,
|
required this.end,
|
||||||
required this.lane,
|
required this.lane,
|
||||||
|
this.hasEnd = true,
|
||||||
}) : assert(!end.isBefore(start), 'Entry end must be on/after start');
|
}) : assert(!end.isBefore(start), 'Entry end must be on/after start');
|
||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
@@ -15,6 +16,7 @@ class TimelineEntry {
|
|||||||
final DateTime start;
|
final DateTime start;
|
||||||
final DateTime end;
|
final DateTime end;
|
||||||
final int lane; // provided by consumer for stacking
|
final int lane; // provided by consumer for stacking
|
||||||
|
final bool hasEnd; // false for point-events (end is synthetic)
|
||||||
|
|
||||||
bool overlaps(DateTime a, DateTime b) {
|
bool overlaps(DateTime a, DateTime b) {
|
||||||
return !(end.isBefore(a) || start.isAfter(b));
|
return !(end.isBefore(a) || start.isAfter(b));
|
||||||
@@ -26,6 +28,7 @@ class TimelineEntry {
|
|||||||
DateTime? start,
|
DateTime? start,
|
||||||
DateTime? end,
|
DateTime? end,
|
||||||
int? lane,
|
int? lane,
|
||||||
|
bool? hasEnd,
|
||||||
}) {
|
}) {
|
||||||
return TimelineEntry(
|
return TimelineEntry(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -33,11 +36,12 @@ class TimelineEntry {
|
|||||||
start: start ?? this.start,
|
start: start ?? this.start,
|
||||||
end: end ?? this.end,
|
end: end ?? this.end,
|
||||||
lane: lane ?? this.lane,
|
lane: lane ?? this.lane,
|
||||||
|
hasEnd: hasEnd ?? this.hasEnd,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => Object.hash(id, groupId, start, end, lane);
|
int get hashCode => Object.hash(id, groupId, start, end, lane, hasEnd);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
@@ -46,10 +50,11 @@ class TimelineEntry {
|
|||||||
other.groupId == groupId &&
|
other.groupId == groupId &&
|
||||||
other.start == start &&
|
other.start == start &&
|
||||||
other.end == end &&
|
other.end == end &&
|
||||||
other.lane == lane;
|
other.lane == lane &&
|
||||||
|
other.hasEnd == hasEnd;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() =>
|
String toString() =>
|
||||||
'TimelineEntry(id: $id, groupId: $groupId, start: $start, end: $end, lane: $lane)';
|
'TimelineEntry(id: $id, groupId: $groupId, start: $start, end: $end, lane: $lane, hasEnd: $hasEnd)';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@
|
|||||||
{ id: "e-1", title: "Brand identity", start: "2026-01-02", end: "2026-01-08", lane: 1 },
|
{ id: "e-1", title: "Brand identity", start: "2026-01-02", end: "2026-01-08", lane: 1 },
|
||||||
{ id: "e-2", title: "UI mockups", start: "2026-01-06", end: "2026-01-14", lane: 2 },
|
{ id: "e-2", title: "UI mockups", start: "2026-01-06", end: "2026-01-14", lane: 2 },
|
||||||
{ id: "e-3", title: "Design review", start: "2026-01-20", end: "2026-01-22", lane: 1 },
|
{ id: "e-3", title: "Design review", start: "2026-01-20", end: "2026-01-22", lane: 1 },
|
||||||
|
{ id: "e-10", title: "Kickoff meeting", start: "2026-01-01", end: null, lane: 3 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -74,6 +75,7 @@
|
|||||||
{ id: "e-7", title: "QA testing", start: "2026-01-19", end: "2026-01-26", lane: 1 },
|
{ id: "e-7", title: "QA testing", start: "2026-01-19", end: "2026-01-26", lane: 1 },
|
||||||
{ id: "e-8", title: "Beta release", start: "2026-01-24", end: "2026-01-28", lane: 2 },
|
{ id: "e-8", title: "Beta release", start: "2026-01-24", end: "2026-01-28", lane: 2 },
|
||||||
{ id: "e-9", title: "Marketing prep", start: "2026-01-08", end: "2026-01-15", lane: 1 },
|
{ id: "e-9", title: "Marketing prep", start: "2026-01-08", end: "2026-01-15", lane: 1 },
|
||||||
|
{ id: "e-11", title: "Go-live", start: "2026-01-28", end: null, lane: 3 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -115,7 +117,7 @@
|
|||||||
entry.start = new Date(p.newStart).toISOString().split("T")[0];
|
entry.start = new Date(p.newStart).toISOString().split("T")[0];
|
||||||
entry.end = p.newEnd
|
entry.end = p.newEnd
|
||||||
? new Date(p.newEnd).toISOString().split("T")[0]
|
? new Date(p.newEnd).toISOString().split("T")[0]
|
||||||
: new Date(new Date(p.newStart).getTime() + (new Date(entry.end) - new Date(entry.start))).toISOString().split("T")[0];
|
: null;
|
||||||
|
|
||||||
// Preserve the lane from the drop target
|
// Preserve the lane from the drop target
|
||||||
entry.lane = p.newLane;
|
entry.lane = p.newLane;
|
||||||
|
|||||||
Reference in New Issue
Block a user