add basic timeline view
This commit is contained in:
100
apps/web/src/components/group-form-drawer.tsx
Normal file
100
apps/web/src/components/group-form-drawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
166
apps/web/src/components/item-form-drawer.tsx
Normal file
166
apps/web/src/components/item-form-drawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
apps/web/src/functions/create-timeline-group.ts
Normal file
22
apps/web/src/functions/create-timeline-group.ts
Normal 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;
|
||||
});
|
||||
34
apps/web/src/functions/create-timeline-item.ts
Normal file
34
apps/web/src/functions/create-timeline-item.ts
Normal 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;
|
||||
});
|
||||
20
apps/web/src/functions/create-timeline.ts
Normal file
20
apps/web/src/functions/create-timeline.ts
Normal 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;
|
||||
});
|
||||
36
apps/web/src/functions/get-timeline.ts
Normal file
36
apps/web/src/functions/get-timeline.ts
Normal 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 } }),
|
||||
});
|
||||
@@ -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 (
|
||||
<section className="container mx-auto max-w-3xl px-4 py-2">
|
||||
<div className="space-y-7">
|
||||
@@ -21,6 +45,44 @@ function HomeComponent() {
|
||||
productivity, and transform how you experience your world.
|
||||
</p>
|
||||
</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>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -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 <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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,10 +5,5 @@ export const Route = createFileRoute("/timeline")({
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
return (
|
||||
<div>
|
||||
Shell of timeline
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user