format and fix

This commit is contained in:
2026-02-24 23:01:11 +01:00
parent 1bb9241116
commit 81a6daaa75
17 changed files with 133 additions and 59 deletions

View File

@@ -5,4 +5,4 @@
"url": "http://localhost:3001/api/mcp" "url": "http://localhost:3001/api/mcp"
} }
} }
} }

View File

@@ -1,3 +1,5 @@
**/routeTree.gen.ts **/routeTree.gen.ts
pnpm-lock.yaml pnpm-lock.yaml
**/.claude/** **/.claude/**
**/public/flutter/**
**/z-timeline/**

View File

@@ -3,3 +3,5 @@
Zendegi is a web app for creating and exploring timelines. They could be personal or professional. Zendegi is a web app for creating and exploring timelines. They could be personal or professional.
The timelines are shown horizontally and are interactive. The timelines are shown horizontally and are interactive.
The app is built with Tanstack Start. The timeline is implemented in flutter (packages/z-timeline) and embedded in the web page.

View File

@@ -1,3 +1,3 @@
import reactConfig from "@zendegi/eslint-config/react"; import reactConfig from "@zendegi/eslint-config/react";
export default reactConfig; export default [{ ignores: ["public/flutter/**"] }, ...reactConfig];

View File

@@ -31,12 +31,14 @@ type FlutterViewProps = {
state: Record<string, unknown>; state: Record<string, unknown>;
onEvent: (event: { type: string; payload?: Record<string, unknown> }) => void; onEvent: (event: { type: string; payload?: Record<string, unknown> }) => void;
className?: string; className?: string;
height?: number;
}; };
export function FlutterView({ export function FlutterView({
state, state,
onEvent, onEvent,
className, className,
height,
}: FlutterViewProps) { }: FlutterViewProps) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [status, setStatus] = useState<"loading" | "ready" | "error">( const [status, setStatus] = useState<"loading" | "ready" | "error">(
@@ -142,7 +144,7 @@ export function FlutterView({
}, [state]); }, [state]);
return ( return (
<div className={className} style={{ height: "100%", position: "relative" }}> <div className={className} style={{ position: "relative" }}>
{status === "loading" && ( {status === "loading" && (
<div <div
style={{ style={{
@@ -170,7 +172,10 @@ export function FlutterView({
Failed to load Flutter view Failed to load Flutter view
</div> </div>
)} )}
<div ref={containerRef} style={{ width: "100%", height: "100%" }} /> <div
ref={containerRef}
style={{ width: "100%", height: height ?? 400 }}
/>
</div> </div>
); );
} }

View File

@@ -3,21 +3,25 @@ import { useForm } from "@tanstack/react-form";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { createTimelineGroup } from "@/functions/create-timeline-group";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { FieldControl, FieldError, FieldLabel, FieldRoot } from "./ui/field"; import { FieldControl, FieldError, FieldLabel, FieldRoot } from "./ui/field";
import { import {
DrawerRoot,
DrawerTrigger,
DrawerPortal,
DrawerViewport,
DrawerPopup,
DrawerTitle,
DrawerContent,
DrawerClose, DrawerClose,
DrawerContent,
DrawerPopup,
DrawerPortal,
DrawerRoot,
DrawerTitle,
DrawerTrigger,
DrawerViewport,
} from "./ui/drawer"; } from "./ui/drawer";
import { createTimelineGroup } from "@/functions/create-timeline-group";
export default function GroupFormDrawer({ timelineId }: { timelineId: string }) { export default function GroupFormDrawer({
timelineId,
}: {
timelineId: string;
}) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -30,12 +34,16 @@ export default function GroupFormDrawer({ timelineId }: { timelineId: string })
await createTimelineGroup({ await createTimelineGroup({
data: { title: value.title, timelineId }, data: { title: value.title, timelineId },
}); });
await queryClient.invalidateQueries({ queryKey: ["timeline", timelineId] }); await queryClient.invalidateQueries({
queryKey: ["timeline", timelineId],
});
setOpen(false); setOpen(false);
form.reset(); form.reset();
toast.success("Group created"); toast.success("Group created");
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to create group"); toast.error(
error instanceof Error ? error.message : "Failed to create group"
);
} }
}, },
}); });
@@ -73,10 +81,14 @@ export default function GroupFormDrawer({ timelineId }: { timelineId: string })
value={field.state.value} value={field.state.value}
onBlur={field.handleBlur} onBlur={field.handleBlur}
onChange={(e) => onChange={(e) =>
field.handleChange((e.target as HTMLInputElement).value) field.handleChange(
(e.target as HTMLInputElement).value
)
} }
/> />
<FieldError match="valueMissing">Title is required</FieldError> <FieldError match="valueMissing">
Title is required
</FieldError>
</FieldRoot> </FieldRoot>
)} )}
</form.Field> </form.Field>

View File

@@ -3,19 +3,19 @@ import { useForm } from "@tanstack/react-form";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { createTimelineItem } from "@/functions/create-timeline-item";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { FieldControl, FieldError, FieldLabel, FieldRoot } from "./ui/field"; import { FieldControl, FieldError, FieldLabel, FieldRoot } from "./ui/field";
import { import {
DrawerRoot,
DrawerTrigger,
DrawerPortal,
DrawerViewport,
DrawerPopup,
DrawerTitle,
DrawerContent,
DrawerClose, DrawerClose,
DrawerContent,
DrawerPopup,
DrawerPortal,
DrawerRoot,
DrawerTitle,
DrawerTrigger,
DrawerViewport,
} from "./ui/drawer"; } from "./ui/drawer";
import { createTimelineItem } from "@/functions/create-timeline-item";
export default function ItemFormDrawer({ export default function ItemFormDrawer({
timelineGroupId, timelineGroupId,
@@ -45,12 +45,16 @@ export default function ItemFormDrawer({
timelineGroupId, timelineGroupId,
}, },
}); });
await queryClient.invalidateQueries({ queryKey: ["timeline", timelineId] }); await queryClient.invalidateQueries({
queryKey: ["timeline", timelineId],
});
setOpen(false); setOpen(false);
form.reset(); form.reset();
toast.success("Item created"); toast.success("Item created");
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to create item"); toast.error(
error instanceof Error ? error.message : "Failed to create item"
);
} }
}, },
}); });
@@ -88,10 +92,14 @@ export default function ItemFormDrawer({
value={field.state.value} value={field.state.value}
onBlur={field.handleBlur} onBlur={field.handleBlur}
onChange={(e) => onChange={(e) =>
field.handleChange((e.target as HTMLInputElement).value) field.handleChange(
(e.target as HTMLInputElement).value
)
} }
/> />
<FieldError match="valueMissing">Title is required</FieldError> <FieldError match="valueMissing">
Title is required
</FieldError>
</FieldRoot> </FieldRoot>
)} )}
</form.Field> </form.Field>
@@ -105,7 +113,9 @@ export default function ItemFormDrawer({
value={field.state.value} value={field.state.value}
onBlur={field.handleBlur} onBlur={field.handleBlur}
onChange={(e) => onChange={(e) =>
field.handleChange((e.target as HTMLInputElement).value) field.handleChange(
(e.target as HTMLInputElement).value
)
} }
/> />
</FieldRoot> </FieldRoot>
@@ -122,10 +132,14 @@ export default function ItemFormDrawer({
value={field.state.value} value={field.state.value}
onBlur={field.handleBlur} onBlur={field.handleBlur}
onChange={(e) => onChange={(e) =>
field.handleChange((e.target as HTMLInputElement).value) field.handleChange(
(e.target as HTMLInputElement).value
)
} }
/> />
<FieldError match="valueMissing">Start date is required</FieldError> <FieldError match="valueMissing">
Start date is required
</FieldError>
</FieldRoot> </FieldRoot>
)} )}
</form.Field> </form.Field>
@@ -139,7 +153,9 @@ export default function ItemFormDrawer({
value={field.state.value} value={field.state.value}
onBlur={field.handleBlur} onBlur={field.handleBlur}
onChange={(e) => onChange={(e) =>
field.handleChange((e.target as HTMLInputElement).value) field.handleChange(
(e.target as HTMLInputElement).value
)
} }
/> />
</FieldRoot> </FieldRoot>

View File

@@ -11,5 +11,5 @@ export const Route = createFileRoute("/.well-known/oauth-authorization-server")(
GET: ({ request }) => handler(request), GET: ({ request }) => handler(request),
}, },
}, },
}, }
); );

View File

@@ -18,7 +18,7 @@ export const Route = createFileRoute("/.well-known/oauth-protected-resource")({
"Cache-Control": "Cache-Control":
"public, max-age=15, stale-while-revalidate=15, stale-if-error=86400", "public, max-age=15, stale-while-revalidate=15, stale-if-error=86400",
}, },
}, }
); );
}, },
}, },

View File

@@ -47,7 +47,7 @@ function RootDocument() {
<HeadContent /> <HeadContent />
</head> </head>
<body> <body>
<div className="grid h-svh grid-rows-[auto_1fr]"> <div className="grid min-h-svh grid-rows-[auto_1fr]">
<Header /> <Header />
<Outlet /> <Outlet />
</div> </div>

View File

@@ -5,8 +5,8 @@ import { Button } from "@/components/ui/button";
export const Route = createFileRoute("/consent")({ export const Route = createFileRoute("/consent")({
validateSearch: (search: Record<string, unknown>) => ({ validateSearch: (search: Record<string, unknown>) => ({
client_id: (search.client_id as string) ?? "", client_id: (search.client_id as string | undefined) ?? "",
scope: (search.scope as string) ?? "", scope: (search.scope as string | undefined) ?? "",
}), }),
component: ConsentComponent, component: ConsentComponent,
}); });

View File

@@ -5,7 +5,12 @@ import { toast } from "sonner";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { createTimeline } from "@/functions/create-timeline"; import { createTimeline } from "@/functions/create-timeline";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { FieldControl, FieldError, FieldLabel, FieldRoot } from "@/components/ui/field"; import {
FieldControl,
FieldError,
FieldLabel,
FieldRoot,
} from "@/components/ui/field";
export const Route = createFileRoute("/")({ export const Route = createFileRoute("/")({
component: HomeComponent, component: HomeComponent,
@@ -22,9 +27,14 @@ function HomeComponent() {
try { try {
await authClient.signIn.anonymous(); await authClient.signIn.anonymous();
const timeline = await createTimeline({ data: { title: value.title } }); const timeline = await createTimeline({ data: { title: value.title } });
navigate({ to: "/timeline/$timelineId", params: { timelineId: timeline.id } }); navigate({
to: "/timeline/$timelineId",
params: { timelineId: timeline.id },
});
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to create timeline"); toast.error(
error instanceof Error ? error.message : "Failed to create timeline"
);
} }
}, },
}); });
@@ -66,9 +76,7 @@ function HomeComponent() {
field.handleChange((e.target as HTMLInputElement).value) field.handleChange((e.target as HTMLInputElement).value)
} }
/> />
<FieldError match="valueMissing"> <FieldError match="valueMissing">Name is required</FieldError>
Name is required
</FieldError>
</FieldRoot> </FieldRoot>
)} )}
</form.Field> </form.Field>

View File

@@ -2,8 +2,6 @@ import { useCallback, useMemo, useState } from "react";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { useSuspenseQuery } from "@tanstack/react-query"; import { useSuspenseQuery } from "@tanstack/react-query";
import { timelineQueryOptions } from "@/functions/get-timeline"; import { timelineQueryOptions } from "@/functions/get-timeline";
import GroupFormDrawer from "@/components/group-form-drawer";
import ItemFormDrawer from "@/components/item-form-drawer";
import { FlutterView } from "@/components/flutter-view"; import { FlutterView } from "@/components/flutter-view";
export const Route = createFileRoute("/timeline/$timelineId")({ export const Route = createFileRoute("/timeline/$timelineId")({
@@ -44,11 +42,16 @@ 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: { type: string; payload?: Record<string, unknown> }) => {
switch (event.type) { switch (event.type) {
case "content_height":
setFlutterHeight(event.payload?.height as number);
break;
case "item_selected": case "item_selected":
setSelectedItemId((event.payload?.itemId as string) ?? null); // setSelectedItemId((event.payload?.itemId as string) ?? null);
break; break;
case "item_deselected": case "item_deselected":
setSelectedItemId(null); setSelectedItemId(null);
@@ -59,13 +62,16 @@ function RouteComponent() {
); );
return ( return (
<div className="flex h-screen flex-col px-4 py-6"> <div className="flex flex-col">
<h1 className="text-3xl font-serif font-bold mb-6">{timeline.title}</h1> <h1 className="text-3xl font-serif font-bold mb-6 mx-4">
{timeline.title}
</h1>
<FlutterView <FlutterView
state={flutterState} state={flutterState}
onEvent={handleEvent} onEvent={handleEvent}
className="min-h-0 flex-1 rounded-lg overflow-hidden" className="overflow-hidden"
height={flutterHeight}
/> />
</div> </div>
); );

View File

@@ -1,12 +1,12 @@
import { relations, sql } from "drizzle-orm"; import { relations, sql } from "drizzle-orm";
import { import {
boolean,
index,
jsonb,
pgTable, pgTable,
text, text,
timestamp, timestamp,
boolean,
uuid, uuid,
jsonb,
index,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
export const user = pgTable("user", { export const user = pgTable("user", {
@@ -43,7 +43,7 @@ export const session = pgTable(
.notNull() .notNull()
.references(() => user.id, { onDelete: "cascade" }), .references(() => user.id, { onDelete: "cascade" }),
}, },
(table) => [index("session_userId_idx").on(table.userId)], (table) => [index("session_userId_idx").on(table.userId)]
); );
export const account = pgTable( export const account = pgTable(
@@ -69,7 +69,7 @@ export const account = pgTable(
.$onUpdate(() => /* @__PURE__ */ new Date()) .$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(), .notNull(),
}, },
(table) => [index("account_userId_idx").on(table.userId)], (table) => [index("account_userId_idx").on(table.userId)]
); );
export const verification = pgTable( export const verification = pgTable(
@@ -87,7 +87,7 @@ export const verification = pgTable(
.$onUpdate(() => /* @__PURE__ */ new Date()) .$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(), .notNull(),
}, },
(table) => [index("verification_identifier_idx").on(table.identifier)], (table) => [index("verification_identifier_idx").on(table.identifier)]
); );
export const jwks = pgTable("jwks", { export const jwks = pgTable("jwks", {
@@ -240,7 +240,7 @@ export const oauthRefreshTokenRelations = relations(
references: [user.id], references: [user.id],
}), }),
oauthAccessTokens: many(oauthAccessToken), oauthAccessTokens: many(oauthAccessToken),
}), })
); );
export const oauthAccessTokenRelations = relations( export const oauthAccessTokenRelations = relations(
@@ -262,7 +262,7 @@ export const oauthAccessTokenRelations = relations(
fields: [oauthAccessToken.refreshId], fields: [oauthAccessToken.refreshId],
references: [oauthRefreshToken.id], references: [oauthRefreshToken.id],
}), }),
}), })
); );
export const oauthConsentRelations = relations(oauthConsent, ({ one }) => ({ export const oauthConsentRelations = relations(oauthConsent, ({ one }) => ({

View File

@@ -59,6 +59,8 @@ class _MainAppState extends State<MainApp> {
end: domain.end, end: domain.end,
); );
}); });
_emitContentHeight();
} }
List<TimelineGroup> _convertGroups(List<TimelineGroupData> groups) { List<TimelineGroup> _convertGroups(List<TimelineGroupData> groups) {
@@ -168,6 +170,25 @@ class _MainAppState extends State<MainApp> {
e, e,
]; ];
}); });
_emitContentHeight();
}
void _emitContentHeight() {
var totalHeight = 0.0;
for (final group in _groups) {
totalHeight += ZTimelineConstants.groupHeaderHeight;
final groupEntries = _entries.where((e) => e.groupId == group.id);
var maxLane = 0;
for (final e in groupEntries) {
if (e.lane > maxLane) maxLane = e.lane;
}
final lanesCount = maxLane.clamp(0, 1000);
totalHeight += lanesCount * ZTimelineConstants.laneHeight
+ (lanesCount > 0 ? (lanesCount - 1) * ZTimelineConstants.laneVerticalSpacing : 0)
+ ZTimelineConstants.verticalOuterPadding * 2;
}
emitEvent('content_height', {'height': totalHeight});
} }
String _labelForEntry(TimelineEntry entry) { String _labelForEntry(TimelineEntry entry) {

View File

@@ -75,6 +75,8 @@ class ZTimelineView extends StatelessWidget {
: ZTimelineConstants.minContentWidth; : ZTimelineConstants.minContentWidth;
return ListView.builder( return ListView.builder(
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
itemCount: groups.length, itemCount: groups.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final group = groups[index]; final group = groups[index];
@@ -128,6 +130,7 @@ class _GroupHeader extends StatelessWidget {
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
return Container( return Container(
height: height, height: height,
padding: const EdgeInsets.only(left: 16.0),
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
decoration: BoxDecoration( decoration: BoxDecoration(
color: scheme.surfaceContainerHighest, color: scheme.surfaceContainerHighest,

Submodule timeline_poc deleted from cd7679dca0