handle drag n drop

This commit is contained in:
2026-03-02 09:09:00 +01:00
parent f3b645ac53
commit 22067c4904
8 changed files with 195 additions and 96 deletions

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from "react";
import type { FlutterEvent, FlutterTimelineState } from "@/lib/flutter-bridge";
declare global {
interface Window {
@@ -28,8 +29,8 @@ declare global {
}
type FlutterViewProps = {
state: Record<string, unknown>;
onEvent: (event: { type: string; payload?: Record<string, unknown> }) => void;
state: FlutterTimelineState;
onEvent: (event: FlutterEvent) => void;
className?: string;
height?: number;
};
@@ -60,10 +61,7 @@ export function FlutterView({
window.__zendegi__ = {
getState: () => JSON.stringify(stateRef.current),
onEvent: (json: string) => {
const event = JSON.parse(json) as {
type: string;
payload?: Record<string, unknown>;
};
const event = JSON.parse(json) as FlutterEvent;
onEventRef.current(event);
},
};

View File

@@ -13,7 +13,7 @@ export const updateTimelineItem = createServerFn({ method: "POST" })
start: z.string().transform((s) => new Date(s)),
end: z
.string()
.nullable()
.nullish()
.transform((s) => (s ? new Date(s) : null)),
timelineGroupId: z.string().uuid(),
lane: z.number().int().min(1),

View 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 });
},
});
}

View 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;
};
};

View File

@@ -1,9 +1,10 @@
import { useCallback, useMemo, useState } from "react";
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 { updateTimelineItem } from "@/functions/update-timeline-item";
import { FlutterView } from "@/components/flutter-view";
import { useEntryMovedMutation } from "@/hooks/use-entry-moved-mutation";
export const Route = createFileRoute("/timeline/$timelineId")({
loader: async ({ context, params }) => {
@@ -16,12 +17,12 @@ export const Route = createFileRoute("/timeline/$timelineId")({
function RouteComponent() {
const { timelineId } = Route.useParams();
const timelineQuery = useSuspenseQuery(timelineQueryOptions(timelineId));
const timeline = timelineQuery.data;
const queryClient = useQueryClient();
const { data: timeline } = useSuspenseQuery(timelineQueryOptions(timelineId));
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: {
id: timeline.id,
@@ -34,8 +35,8 @@ function RouteComponent() {
id: item.id,
title: item.title,
description: item.description,
start: item.start,
end: item.end,
start: item.start.toISOString(),
end: item.end?.toISOString() ?? null,
lane: item.lane,
})),
})),
@@ -45,89 +46,24 @@ function RouteComponent() {
[timeline, selectedItemId]
);
const [flutterHeight, setFlutterHeight] = useState<number | undefined>();
const handleEvent = useCallback(
(event: { type: string; payload?: Record<string, unknown> }) => {
(event: FlutterEvent) => {
switch (event.type) {
case "content_height":
setFlutterHeight(event.payload?.height as number);
setFlutterHeight(event.payload.height);
break;
case "item_selected":
// setSelectedItemId((event.payload?.itemId as string) ?? null);
setSelectedItemId(event.payload.itemId);
break;
case "item_deselected":
setSelectedItemId(null);
break;
case "entry_moved": {
const p = 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,
},
});
case "entry_moved":
entryMoved.mutate(event.payload);
break;
}
}
},
[queryClient, timelineId]
[entryMoved]
);
return (