diff --git a/apps/web/src/components/group-form-drawer.tsx b/apps/web/src/components/group-form-drawer.tsx new file mode 100644 index 0000000..b4ecc7d --- /dev/null +++ b/apps/web/src/components/group-form-drawer.tsx @@ -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 ( + + }> + Add Group + + + + +
+ New Group + }> + Close + +
+ +
{ + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }} + className="space-y-4" + > + + {(field) => ( + + Title + + field.handleChange((e.target as HTMLInputElement).value) + } + /> + Title is required + + )} + + + {(state) => ( + + )} + +
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/components/item-form-drawer.tsx b/apps/web/src/components/item-form-drawer.tsx new file mode 100644 index 0000000..fb0dde2 --- /dev/null +++ b/apps/web/src/components/item-form-drawer.tsx @@ -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 ( + + }> + Add Item + + + + +
+ New Item + }> + Close + +
+ +
{ + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }} + className="space-y-4" + > + + {(field) => ( + + Title + + field.handleChange((e.target as HTMLInputElement).value) + } + /> + Title is required + + )} + + + + {(field) => ( + + Description + + field.handleChange((e.target as HTMLInputElement).value) + } + /> + + )} + + + + {(field) => ( + + Start date + + field.handleChange((e.target as HTMLInputElement).value) + } + /> + Start date is required + + )} + + + + {(field) => ( + + End date (optional) + + field.handleChange((e.target as HTMLInputElement).value) + } + /> + + )} + + + + {(state) => ( + + )} + +
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/functions/create-timeline-group.ts b/apps/web/src/functions/create-timeline-group.ts new file mode 100644 index 0000000..c55f0c2 --- /dev/null +++ b/apps/web/src/functions/create-timeline-group.ts @@ -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; + }); diff --git a/apps/web/src/functions/create-timeline-item.ts b/apps/web/src/functions/create-timeline-item.ts new file mode 100644 index 0000000..bb6cfd2 --- /dev/null +++ b/apps/web/src/functions/create-timeline-item.ts @@ -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; + }); diff --git a/apps/web/src/functions/create-timeline.ts b/apps/web/src/functions/create-timeline.ts new file mode 100644 index 0000000..ac6498f --- /dev/null +++ b/apps/web/src/functions/create-timeline.ts @@ -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; + }); diff --git a/apps/web/src/functions/get-timeline.ts b/apps/web/src/functions/get-timeline.ts new file mode 100644 index 0000000..68e637e --- /dev/null +++ b/apps/web/src/functions/get-timeline.ts @@ -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 } }), + }); diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 2be3d9c..1b16eb4 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -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("/")({ component: 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 (
@@ -21,6 +45,44 @@ function HomeComponent() { productivity, and transform how you experience your world.

+
{ + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }} + className="flex items-end gap-3" + > + + {(field) => ( + + Timeline name + + field.handleChange((e.target as HTMLInputElement).value) + } + /> + + Name is required + + + )} + + + {(state) => ( + + )} + +
); diff --git a/apps/web/src/routes/timeline.$timelineId.tsx b/apps/web/src/routes/timeline.$timelineId.tsx index 7cdb3d2..2c8c917 100644 --- a/apps/web/src/routes/timeline.$timelineId.tsx +++ b/apps/web/src/routes/timeline.$timelineId.tsx @@ -1,9 +1,64 @@ 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")({ + loader: async ({ context, params }) => { + await context.queryClient.ensureQueryData( + timelineQueryOptions(params.timelineId) + ); + }, component: RouteComponent, }); function RouteComponent() { - return
Timeline detail
; + const { timelineId } = Route.useParams(); + const timelineQuery = useSuspenseQuery(timelineQueryOptions(timelineId)); + const timeline = timelineQuery.data; + + return ( +
+

{timeline.title}

+ + {timeline.groups.length === 0 && ( +

+ No groups yet. Add a group to start building your timeline. +

+ )} + +
+ {timeline.groups.map((group) => ( +
+
+

{group.title}

+ +
+ + {group.items.length === 0 && ( +

No items in this group yet.

+ )} + +
    + {group.items.map((item) => ( +
  • +
    {item.title}
    + {item.description && ( +

    {item.description}

    + )} +

    + {new Date(item.start).toLocaleDateString()} + {item.end && ` — ${new Date(item.end).toLocaleDateString()}`} +

    +
  • + ))} +
+
+ ))} +
+ + +
+ ); } diff --git a/apps/web/src/routes/timeline.tsx b/apps/web/src/routes/timeline.tsx index 7525046..ec869a2 100644 --- a/apps/web/src/routes/timeline.tsx +++ b/apps/web/src/routes/timeline.tsx @@ -5,10 +5,5 @@ export const Route = createFileRoute("/timeline")({ }); function RouteComponent() { - return ( -
- Shell of timeline - -
- ); + return ; } diff --git a/packages/db/src/schema/timeline.ts b/packages/db/src/schema/timeline.ts index 7dec5c2..f582942 100644 --- a/packages/db/src/schema/timeline.ts +++ b/packages/db/src/schema/timeline.ts @@ -49,6 +49,17 @@ export const timelineRelations = relations(timeline, ({ many }) => ({ 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), })); + +export const itemRelations = relations(timelineItem, ({ one }) => ({ + timelineGroup: one(timelineGroup, { + fields: [timelineItem.timelineGroupId], + references: [timelineGroup.id], + }), +}));