add basic timeline view

This commit is contained in:
2026-02-24 10:58:47 +01:00
parent 27d3cd364e
commit ae706c9a91
10 changed files with 510 additions and 9 deletions

View File

@@ -0,0 +1,100 @@
import { useState } from "react";
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,
} from "./ui/drawer";
export default function GroupFormDrawer({ timelineId }: { timelineId: string }) {
const [open, setOpen] = useState(false);
const queryClient = useQueryClient();
const form = useForm({
defaultValues: {
title: "",
},
onSubmit: async ({ value }) => {
try {
await createTimelineGroup({
data: { title: value.title, 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");
}
},
});
return (
<DrawerRoot open={open} onOpenChange={setOpen}>
<DrawerTrigger render={<Button variant="outline" />}>
Add Group
</DrawerTrigger>
<DrawerPortal>
<DrawerViewport>
<DrawerPopup>
<div className="flex items-center justify-between border-b border-border p-4">
<DrawerTitle>New Group</DrawerTitle>
<DrawerClose render={<Button variant="outline" size="sm" />}>
Close
</DrawerClose>
</div>
<DrawerContent className="p-4">
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
className="space-y-4"
>
<form.Field name="title">
{(field) => (
<FieldRoot>
<FieldLabel>Title</FieldLabel>
<FieldControl
required
placeholder="Group title"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) =>
field.handleChange((e.target as HTMLInputElement).value)
}
/>
<FieldError match="valueMissing">Title is required</FieldError>
</FieldRoot>
)}
</form.Field>
<form.Subscribe>
{(state) => (
<Button
type="submit"
disabled={!state.canSubmit || state.isSubmitting}
>
{state.isSubmitting ? "Creating..." : "Create Group"}
</Button>
)}
</form.Subscribe>
</form>
</DrawerContent>
</DrawerPopup>
</DrawerViewport>
</DrawerPortal>
</DrawerRoot>
);
}

View File

@@ -0,0 +1,166 @@
import { useState } from "react";
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,
} from "./ui/drawer";
export default function ItemFormDrawer({
timelineGroupId,
timelineId,
}: {
timelineGroupId: string;
timelineId: string;
}) {
const [open, setOpen] = useState(false);
const queryClient = useQueryClient();
const form = useForm({
defaultValues: {
title: "",
description: "",
start: new Date().toISOString().split("T")[0],
end: "",
},
onSubmit: async ({ value }) => {
try {
await createTimelineItem({
data: {
title: value.title,
description: value.description,
start: value.start,
end: value.end || undefined,
timelineGroupId,
},
});
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");
}
},
});
return (
<DrawerRoot open={open} onOpenChange={setOpen}>
<DrawerTrigger render={<Button variant="outline" size="sm" />}>
Add Item
</DrawerTrigger>
<DrawerPortal>
<DrawerViewport>
<DrawerPopup>
<div className="flex items-center justify-between border-b border-border p-4">
<DrawerTitle>New Item</DrawerTitle>
<DrawerClose render={<Button variant="outline" size="sm" />}>
Close
</DrawerClose>
</div>
<DrawerContent className="p-4">
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
className="space-y-4"
>
<form.Field name="title">
{(field) => (
<FieldRoot>
<FieldLabel>Title</FieldLabel>
<FieldControl
required
placeholder="Item title"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) =>
field.handleChange((e.target as HTMLInputElement).value)
}
/>
<FieldError match="valueMissing">Title is required</FieldError>
</FieldRoot>
)}
</form.Field>
<form.Field name="description">
{(field) => (
<FieldRoot>
<FieldLabel>Description</FieldLabel>
<FieldControl
placeholder="Optional description"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) =>
field.handleChange((e.target as HTMLInputElement).value)
}
/>
</FieldRoot>
)}
</form.Field>
<form.Field name="start">
{(field) => (
<FieldRoot>
<FieldLabel>Start date</FieldLabel>
<FieldControl
type="date"
required
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) =>
field.handleChange((e.target as HTMLInputElement).value)
}
/>
<FieldError match="valueMissing">Start date is required</FieldError>
</FieldRoot>
)}
</form.Field>
<form.Field name="end">
{(field) => (
<FieldRoot>
<FieldLabel>End date (optional)</FieldLabel>
<FieldControl
type="date"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) =>
field.handleChange((e.target as HTMLInputElement).value)
}
/>
</FieldRoot>
)}
</form.Field>
<form.Subscribe>
{(state) => (
<Button
type="submit"
disabled={!state.canSubmit || state.isSubmitting}
>
{state.isSubmitting ? "Creating..." : "Create Item"}
</Button>
)}
</form.Subscribe>
</form>
</DrawerContent>
</DrawerPopup>
</DrawerViewport>
</DrawerPortal>
</DrawerRoot>
);
}

View File

@@ -0,0 +1,22 @@
import { db } from "@zendegi/db";
import { timelineGroup } from "@zendegi/db/schema/timeline";
import { createServerFn } from "@tanstack/react-start";
import { z } from "zod";
import { authMiddleware } from "@/middleware/auth";
export const createTimelineGroup = createServerFn({ method: "POST" })
.middleware([authMiddleware])
.inputValidator(
z.object({ title: z.string().min(1), timelineId: z.string().uuid() })
)
.handler(async ({ data }) => {
const [newGroup] = await db
.insert(timelineGroup)
.values({
title: data.title,
timelineId: data.timelineId,
})
.returning();
return newGroup;
});

View File

@@ -0,0 +1,34 @@
import { db } from "@zendegi/db";
import { timelineItem } from "@zendegi/db/schema/timeline";
import { createServerFn } from "@tanstack/react-start";
import { z } from "zod";
import { authMiddleware } from "@/middleware/auth";
export const createTimelineItem = createServerFn({ method: "POST" })
.middleware([authMiddleware])
.inputValidator(
z.object({
title: z.string().min(1),
description: z.string().default(""),
start: z.string().transform((s) => new Date(s)),
end: z
.string()
.optional()
.transform((s) => (s ? new Date(s) : undefined)),
timelineGroupId: z.string().uuid(),
})
)
.handler(async ({ data }) => {
const [newItem] = await db
.insert(timelineItem)
.values({
title: data.title,
description: data.description,
start: data.start,
end: data.end,
timelineGroupId: data.timelineGroupId,
})
.returning();
return newItem;
});

View File

@@ -0,0 +1,20 @@
import { db } from "@zendegi/db";
import { timeline } from "@zendegi/db/schema/timeline";
import { createServerFn } from "@tanstack/react-start";
import { z } from "zod";
import { authMiddleware } from "@/middleware/auth";
export const createTimeline = createServerFn({ method: "POST" })
.middleware([authMiddleware])
.inputValidator(z.object({ title: z.string().min(1) }))
.handler(async ({ data, context }) => {
const [newTimeline] = await db
.insert(timeline)
.values({
title: data.title,
ownerId: context.session!.user.id,
})
.returning();
return newTimeline;
});

View File

@@ -0,0 +1,36 @@
import { eq } from "drizzle-orm";
import { db } from "@zendegi/db";
import { timeline } from "@zendegi/db/schema/timeline";
import { createServerFn } from "@tanstack/react-start";
import { queryOptions } from "@tanstack/react-query";
import { z } from "zod";
export const getTimeline = createServerFn({ method: "GET" })
.inputValidator(z.object({ id: z.string().uuid() }))
.handler(async ({ data }) => {
const result = await db.query.timeline.findFirst({
where: eq(timeline.id, data.id),
with: {
groups: {
orderBy: (g, { asc }) => [asc(g.sortOrder)],
with: {
items: {
orderBy: (i, { asc }) => [asc(i.start)],
},
},
},
},
});
if (!result) {
throw new Error("Timeline not found");
}
return result;
});
export const timelineQueryOptions = (timelineId: string) =>
queryOptions({
queryKey: ["timeline", timelineId],
queryFn: () => getTimeline({ data: { id: timelineId } }),
});

View File

@@ -1,10 +1,34 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useForm } from "@tanstack/react-form";
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";
export const Route = createFileRoute("/")({ export const Route = createFileRoute("/")({
component: HomeComponent, component: HomeComponent,
}); });
function HomeComponent() { function HomeComponent() {
const navigate = useNavigate();
const form = useForm({
defaultValues: {
title: "",
},
onSubmit: async ({ value }) => {
try {
await authClient.signIn.anonymous();
const timeline = await createTimeline({ data: { title: value.title } });
navigate({ to: "/timeline/$timelineId", params: { timelineId: timeline.id } });
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to create timeline");
}
},
});
return ( return (
<section className="container mx-auto max-w-3xl px-4 py-2"> <section className="container mx-auto max-w-3xl px-4 py-2">
<div className="space-y-7"> <div className="space-y-7">
@@ -21,6 +45,44 @@ function HomeComponent() {
productivity, and transform how you experience your world. productivity, and transform how you experience your world.
</p> </p>
</div> </div>
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
className="flex items-end gap-3"
>
<form.Field name="title">
{(field) => (
<FieldRoot className="flex-1">
<FieldLabel>Timeline name</FieldLabel>
<FieldControl
required
placeholder="My Timeline"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) =>
field.handleChange((e.target as HTMLInputElement).value)
}
/>
<FieldError match="valueMissing">
Name is required
</FieldError>
</FieldRoot>
)}
</form.Field>
<form.Subscribe>
{(state) => (
<Button
type="submit"
disabled={!state.canSubmit || state.isSubmitting}
>
{state.isSubmitting ? "Creating..." : "Create"}
</Button>
)}
</form.Subscribe>
</form>
</div> </div>
</section> </section>
); );

View File

@@ -1,9 +1,64 @@
import { createFileRoute } from "@tanstack/react-router"; 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";
export const Route = createFileRoute("/timeline/$timelineId")({ export const Route = createFileRoute("/timeline/$timelineId")({
loader: async ({ context, params }) => {
await context.queryClient.ensureQueryData(
timelineQueryOptions(params.timelineId)
);
},
component: RouteComponent, component: RouteComponent,
}); });
function RouteComponent() { function RouteComponent() {
return <div>Timeline detail</div>; const { timelineId } = Route.useParams();
const timelineQuery = useSuspenseQuery(timelineQueryOptions(timelineId));
const timeline = timelineQuery.data;
return (
<div className="container mx-auto max-w-3xl px-4 py-6 space-y-6">
<h1 className="text-3xl font-serif font-bold">{timeline.title}</h1>
{timeline.groups.length === 0 && (
<p className="text-muted-foreground">
No groups yet. Add a group to start building your timeline.
</p>
)}
<div className="space-y-6">
{timeline.groups.map((group) => (
<div key={group.id} className="border border-border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">{group.title}</h2>
<ItemFormDrawer timelineGroupId={group.id} timelineId={timelineId} />
</div>
{group.items.length === 0 && (
<p className="text-sm text-muted-foreground">No items in this group yet.</p>
)}
<ul className="space-y-2">
{group.items.map((item) => (
<li key={item.id} className="border border-border rounded p-3">
<div className="font-medium">{item.title}</div>
{item.description && (
<p className="text-sm text-muted-foreground">{item.description}</p>
)}
<p className="text-xs text-muted-foreground mt-1">
{new Date(item.start).toLocaleDateString()}
{item.end && `${new Date(item.end).toLocaleDateString()}`}
</p>
</li>
))}
</ul>
</div>
))}
</div>
<GroupFormDrawer timelineId={timelineId} />
</div>
);
} }

View File

@@ -5,10 +5,5 @@ export const Route = createFileRoute("/timeline")({
}); });
function RouteComponent() { function RouteComponent() {
return ( return <Outlet />;
<div>
Shell of timeline
<Outlet />
</div>
);
} }

View File

@@ -49,6 +49,17 @@ export const timelineRelations = relations(timeline, ({ many }) => ({
groups: many(timelineGroup), groups: many(timelineGroup),
})); }));
export const groupRelations = relations(timelineGroup, ({ many }) => ({ export const groupRelations = relations(timelineGroup, ({ one, many }) => ({
timeline: one(timeline, {
fields: [timelineGroup.timelineId],
references: [timeline.id],
}),
items: many(timelineItem), items: many(timelineItem),
})); }));
export const itemRelations = relations(timelineItem, ({ one }) => ({
timelineGroup: one(timelineGroup, {
fields: [timelineItem.timelineGroupId],
references: [timelineGroup.id],
}),
}));