diff --git a/.mcp.json b/.mcp.json index 014fd0d..8551b28 100644 --- a/.mcp.json +++ b/.mcp.json @@ -5,4 +5,4 @@ "url": "http://localhost:3001/api/mcp" } } -} \ No newline at end of file +} diff --git a/.prettierignore b/.prettierignore index 6c27eaa..3fc32ed 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1,5 @@ **/routeTree.gen.ts pnpm-lock.yaml **/.claude/** +**/public/flutter/** +**/z-timeline/** diff --git a/apps/web/CLAUDE.md b/apps/web/CLAUDE.md index d87b4f6..5d79fc1 100644 --- a/apps/web/CLAUDE.md +++ b/apps/web/CLAUDE.md @@ -3,3 +3,5 @@ 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 app is built with Tanstack Start. The timeline is implemented in flutter (packages/z-timeline) and embedded in the web page. diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs index dde0695..2124554 100644 --- a/apps/web/eslint.config.mjs +++ b/apps/web/eslint.config.mjs @@ -1,3 +1,3 @@ import reactConfig from "@zendegi/eslint-config/react"; -export default reactConfig; +export default [{ ignores: ["public/flutter/**"] }, ...reactConfig]; diff --git a/apps/web/src/components/flutter-view.tsx b/apps/web/src/components/flutter-view.tsx index 0aa4341..c1aec5a 100644 --- a/apps/web/src/components/flutter-view.tsx +++ b/apps/web/src/components/flutter-view.tsx @@ -31,12 +31,14 @@ type FlutterViewProps = { state: Record; onEvent: (event: { type: string; payload?: Record }) => void; className?: string; + height?: number; }; export function FlutterView({ state, onEvent, className, + height, }: FlutterViewProps) { const containerRef = useRef(null); const [status, setStatus] = useState<"loading" | "ready" | "error">( @@ -142,7 +144,7 @@ export function FlutterView({ }, [state]); return ( -
+
{status === "loading" && (
)} -
+
); } diff --git a/apps/web/src/components/group-form-drawer.tsx b/apps/web/src/components/group-form-drawer.tsx index b4ecc7d..504d449 100644 --- a/apps/web/src/components/group-form-drawer.tsx +++ b/apps/web/src/components/group-form-drawer.tsx @@ -3,21 +3,25 @@ import { useForm } from "@tanstack/react-form"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; -import { createTimelineGroup } from "@/functions/create-timeline-group"; import { Button } from "./ui/button"; import { FieldControl, FieldError, FieldLabel, FieldRoot } from "./ui/field"; import { - DrawerRoot, - DrawerTrigger, - DrawerPortal, - DrawerViewport, - DrawerPopup, - DrawerTitle, - DrawerContent, DrawerClose, + DrawerContent, + DrawerPopup, + DrawerPortal, + DrawerRoot, + DrawerTitle, + DrawerTrigger, + DrawerViewport, } 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 queryClient = useQueryClient(); @@ -30,12 +34,16 @@ export default function GroupFormDrawer({ timelineId }: { timelineId: string }) await createTimelineGroup({ data: { title: value.title, timelineId }, }); - await queryClient.invalidateQueries({ queryKey: ["timeline", timelineId] }); + await queryClient.invalidateQueries({ + queryKey: ["timeline", timelineId], + }); setOpen(false); form.reset(); toast.success("Group created"); } 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} onBlur={field.handleBlur} onChange={(e) => - field.handleChange((e.target as HTMLInputElement).value) + field.handleChange( + (e.target as HTMLInputElement).value + ) } /> - Title is required + + Title is required + )} diff --git a/apps/web/src/components/item-form-drawer.tsx b/apps/web/src/components/item-form-drawer.tsx index fb0dde2..a5fbfd0 100644 --- a/apps/web/src/components/item-form-drawer.tsx +++ b/apps/web/src/components/item-form-drawer.tsx @@ -3,19 +3,19 @@ import { useForm } from "@tanstack/react-form"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; -import { createTimelineItem } from "@/functions/create-timeline-item"; import { Button } from "./ui/button"; import { FieldControl, FieldError, FieldLabel, FieldRoot } from "./ui/field"; import { - DrawerRoot, - DrawerTrigger, - DrawerPortal, - DrawerViewport, - DrawerPopup, - DrawerTitle, - DrawerContent, DrawerClose, + DrawerContent, + DrawerPopup, + DrawerPortal, + DrawerRoot, + DrawerTitle, + DrawerTrigger, + DrawerViewport, } from "./ui/drawer"; +import { createTimelineItem } from "@/functions/create-timeline-item"; export default function ItemFormDrawer({ timelineGroupId, @@ -45,12 +45,16 @@ export default function ItemFormDrawer({ timelineGroupId, }, }); - await queryClient.invalidateQueries({ queryKey: ["timeline", timelineId] }); + await queryClient.invalidateQueries({ + queryKey: ["timeline", timelineId], + }); setOpen(false); form.reset(); toast.success("Item created"); } 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} onBlur={field.handleBlur} onChange={(e) => - field.handleChange((e.target as HTMLInputElement).value) + field.handleChange( + (e.target as HTMLInputElement).value + ) } /> - Title is required + + Title is required + )} @@ -105,7 +113,9 @@ export default function ItemFormDrawer({ value={field.state.value} onBlur={field.handleBlur} onChange={(e) => - field.handleChange((e.target as HTMLInputElement).value) + field.handleChange( + (e.target as HTMLInputElement).value + ) } /> @@ -122,10 +132,14 @@ export default function ItemFormDrawer({ value={field.state.value} onBlur={field.handleBlur} onChange={(e) => - field.handleChange((e.target as HTMLInputElement).value) + field.handleChange( + (e.target as HTMLInputElement).value + ) } /> - Start date is required + + Start date is required + )} @@ -139,7 +153,9 @@ export default function ItemFormDrawer({ value={field.state.value} onBlur={field.handleBlur} onChange={(e) => - field.handleChange((e.target as HTMLInputElement).value) + field.handleChange( + (e.target as HTMLInputElement).value + ) } /> diff --git a/apps/web/src/routes/[.well-known]/oauth-authorization-server.ts b/apps/web/src/routes/[.well-known]/oauth-authorization-server.ts index 6d94460..7c7ed45 100644 --- a/apps/web/src/routes/[.well-known]/oauth-authorization-server.ts +++ b/apps/web/src/routes/[.well-known]/oauth-authorization-server.ts @@ -11,5 +11,5 @@ export const Route = createFileRoute("/.well-known/oauth-authorization-server")( GET: ({ request }) => handler(request), }, }, - }, + } ); diff --git a/apps/web/src/routes/[.well-known]/oauth-protected-resource.ts b/apps/web/src/routes/[.well-known]/oauth-protected-resource.ts index 3c8f191..a98b920 100644 --- a/apps/web/src/routes/[.well-known]/oauth-protected-resource.ts +++ b/apps/web/src/routes/[.well-known]/oauth-protected-resource.ts @@ -18,7 +18,7 @@ export const Route = createFileRoute("/.well-known/oauth-protected-resource")({ "Cache-Control": "public, max-age=15, stale-while-revalidate=15, stale-if-error=86400", }, - }, + } ); }, }, diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 407d5a1..572e857 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -47,7 +47,7 @@ function RootDocument() { -
+
diff --git a/apps/web/src/routes/consent.tsx b/apps/web/src/routes/consent.tsx index 02d6daa..d18dd86 100644 --- a/apps/web/src/routes/consent.tsx +++ b/apps/web/src/routes/consent.tsx @@ -5,8 +5,8 @@ import { Button } from "@/components/ui/button"; export const Route = createFileRoute("/consent")({ validateSearch: (search: Record) => ({ - client_id: (search.client_id as string) ?? "", - scope: (search.scope as string) ?? "", + client_id: (search.client_id as string | undefined) ?? "", + scope: (search.scope as string | undefined) ?? "", }), component: ConsentComponent, }); diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 1b16eb4..9aec129 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -5,7 +5,12 @@ import { toast } from "sonner"; import { authClient } from "@/lib/auth-client"; import { createTimeline } from "@/functions/create-timeline"; 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("/")({ component: HomeComponent, @@ -22,9 +27,14 @@ function HomeComponent() { try { await authClient.signIn.anonymous(); 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) { - 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) } /> - - Name is required - + Name is required )} diff --git a/apps/web/src/routes/timeline.$timelineId.tsx b/apps/web/src/routes/timeline.$timelineId.tsx index 59c42df..a320c41 100644 --- a/apps/web/src/routes/timeline.$timelineId.tsx +++ b/apps/web/src/routes/timeline.$timelineId.tsx @@ -2,8 +2,6 @@ import { useCallback, useMemo, useState } from "react"; import { createFileRoute } from "@tanstack/react-router"; import { useSuspenseQuery } from "@tanstack/react-query"; 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"; export const Route = createFileRoute("/timeline/$timelineId")({ @@ -44,11 +42,16 @@ function RouteComponent() { [timeline, selectedItemId] ); + const [flutterHeight, setFlutterHeight] = useState(); + const handleEvent = useCallback( (event: { type: string; payload?: Record }) => { switch (event.type) { + case "content_height": + setFlutterHeight(event.payload?.height as number); + break; case "item_selected": - setSelectedItemId((event.payload?.itemId as string) ?? null); + // setSelectedItemId((event.payload?.itemId as string) ?? null); break; case "item_deselected": setSelectedItemId(null); @@ -59,13 +62,16 @@ function RouteComponent() { ); return ( -
-

{timeline.title}

+
+

+ {timeline.title} +

); diff --git a/packages/db/src/schema/auth.ts b/packages/db/src/schema/auth.ts index 57a4701..1d94866 100644 --- a/packages/db/src/schema/auth.ts +++ b/packages/db/src/schema/auth.ts @@ -1,12 +1,12 @@ import { relations, sql } from "drizzle-orm"; import { + boolean, + index, + jsonb, pgTable, text, timestamp, - boolean, uuid, - jsonb, - index, } from "drizzle-orm/pg-core"; export const user = pgTable("user", { @@ -43,7 +43,7 @@ export const session = pgTable( .notNull() .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( @@ -69,7 +69,7 @@ export const account = pgTable( .$onUpdate(() => /* @__PURE__ */ new Date()) .notNull(), }, - (table) => [index("account_userId_idx").on(table.userId)], + (table) => [index("account_userId_idx").on(table.userId)] ); export const verification = pgTable( @@ -87,7 +87,7 @@ export const verification = pgTable( .$onUpdate(() => /* @__PURE__ */ new Date()) .notNull(), }, - (table) => [index("verification_identifier_idx").on(table.identifier)], + (table) => [index("verification_identifier_idx").on(table.identifier)] ); export const jwks = pgTable("jwks", { @@ -240,7 +240,7 @@ export const oauthRefreshTokenRelations = relations( references: [user.id], }), oauthAccessTokens: many(oauthAccessToken), - }), + }) ); export const oauthAccessTokenRelations = relations( @@ -262,7 +262,7 @@ export const oauthAccessTokenRelations = relations( fields: [oauthAccessToken.refreshId], references: [oauthRefreshToken.id], }), - }), + }) ); export const oauthConsentRelations = relations(oauthConsent, ({ one }) => ({ diff --git a/packages/z-timeline/lib/main.dart b/packages/z-timeline/lib/main.dart index 7f6aa90..4907bf5 100644 --- a/packages/z-timeline/lib/main.dart +++ b/packages/z-timeline/lib/main.dart @@ -59,6 +59,8 @@ class _MainAppState extends State { end: domain.end, ); }); + + _emitContentHeight(); } List _convertGroups(List groups) { @@ -168,6 +170,25 @@ class _MainAppState extends State { 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) { diff --git a/packages/z-timeline/lib/src/widgets/timeline_view.dart b/packages/z-timeline/lib/src/widgets/timeline_view.dart index a808ee4..8927171 100644 --- a/packages/z-timeline/lib/src/widgets/timeline_view.dart +++ b/packages/z-timeline/lib/src/widgets/timeline_view.dart @@ -75,6 +75,8 @@ class ZTimelineView extends StatelessWidget { : ZTimelineConstants.minContentWidth; return ListView.builder( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, itemCount: groups.length, itemBuilder: (context, index) { final group = groups[index]; @@ -128,6 +130,7 @@ class _GroupHeader extends StatelessWidget { final scheme = Theme.of(context).colorScheme; return Container( height: height, + padding: const EdgeInsets.only(left: 16.0), alignment: Alignment.centerLeft, decoration: BoxDecoration( color: scheme.surfaceContainerHighest, diff --git a/timeline_poc b/timeline_poc deleted file mode 160000 index cd7679d..0000000 --- a/timeline_poc +++ /dev/null @@ -1 +0,0 @@ -Subproject commit cd7679dca0803beb3ae7bafd565005f9426f44d9