handle drag n drop
This commit is contained in:
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
|
||||
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 { 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 (
|
||||
|
||||
Reference in New Issue
Block a user