mirror of
https://github.com/lukevella/rallly.git
synced 2025-08-03 16:38:34 +02:00
Builder
This commit is contained in:
parent
4e67c783be
commit
c5cf9298cd
14 changed files with 584 additions and 11 deletions
|
@ -64,6 +64,8 @@
|
|||
"posthog-js": "^1.102.1",
|
||||
"posthog-node": "^3.6.0",
|
||||
"react-big-calendar": "^1.8.1",
|
||||
"react-day-picker": "^8.10.0",
|
||||
"date-fns": "^3.2.0",
|
||||
"react-hook-form": "^7.42.1",
|
||||
"react-hook-form-persist": "^3.0.0",
|
||||
"react-hot-toast": "^2.4.0",
|
||||
|
|
|
@ -27,7 +27,7 @@ export default async function Layout({
|
|||
<Trans t={t} i18nKey="polls" />
|
||||
</PageTitle>
|
||||
<Button asChild>
|
||||
<Link href="/new">
|
||||
<Link href="/create">
|
||||
<PenBoxIcon className="text-muted-foreground size-4" />
|
||||
<span className="hidden sm:inline">
|
||||
<Trans t={t} i18nKey="newPoll" />
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
"use client";
|
||||
import { cn } from "@rallly/ui";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
@ -19,7 +20,7 @@ export function MenuItem(props: { href: string; children: React.ReactNode }) {
|
|||
const pathname = usePathname();
|
||||
return (
|
||||
<Link
|
||||
className={clsx(
|
||||
className={cn(
|
||||
"flex min-w-0 items-center gap-x-2 rounded-none px-3 py-2 text-sm font-medium",
|
||||
pathname === props.href
|
||||
? "bg-white"
|
||||
|
|
453
apps/web/src/app/[locale]/create/create-form.tsx
Normal file
453
apps/web/src/app/[locale]/create/create-form.tsx
Normal file
|
@ -0,0 +1,453 @@
|
|||
"use client";
|
||||
|
||||
import { IfNeedBeIcon, NoIcon, YesIcon } from "@rallly/icons";
|
||||
import { Button } from "@rallly/ui/button";
|
||||
import { Calendar } from "@rallly/ui/calendar";
|
||||
import { Checkbox } from "@rallly/ui/checkbox";
|
||||
import { DropdownMenu, DropdownMenuTrigger } from "@rallly/ui/dropdown-menu";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} from "@rallly/ui/form";
|
||||
import { Input } from "@rallly/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@rallly/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@rallly/ui/tabs";
|
||||
import { Textarea } from "@rallly/ui/textarea";
|
||||
import dayjs from "dayjs";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
CalendarIcon,
|
||||
ListIcon,
|
||||
MoreHorizontalIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import React from "react";
|
||||
import { useForm, useFormContext } from "react-hook-form";
|
||||
import useFormPersist from "react-hook-form-persist";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
InviteCard,
|
||||
InviteCardForm,
|
||||
InviteCardGeneral,
|
||||
} from "@/app/[locale]/create/invite-card";
|
||||
import {
|
||||
PageContainer,
|
||||
PageContent,
|
||||
PageHeader,
|
||||
} from "@/app/components/page-layout";
|
||||
import { useQueryString } from "@/utils/query-string";
|
||||
|
||||
export function FieldGroup({ children }: { children: React.ReactNode }) {
|
||||
return <div className="space-y-4">{children}</div>;
|
||||
}
|
||||
|
||||
export function FieldGroupTitle({ children }: { children: React.ReactNode }) {
|
||||
return <h2 className="font-semibold">{children}</h2>;
|
||||
}
|
||||
|
||||
export function FieldGroupContent({ children }: { children: React.ReactNode }) {
|
||||
return <div className="space-y-6">{children}</div>;
|
||||
}
|
||||
|
||||
function SidebarTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<h3 className="sticky top-14 flex items-center gap-x-2 border-b bg-gray-50 px-5 py-4 text-sm font-bold">
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroup({ children }: { children: React.ReactNode }) {
|
||||
return <div className="space-y-4">{children}</div>;
|
||||
}
|
||||
|
||||
function SidebarSection({ children }: { children?: React.ReactNode }) {
|
||||
return <section>{children}</section>;
|
||||
}
|
||||
|
||||
function SidebarContent({ children }: { children?: React.ReactNode }) {
|
||||
return <div className="px-5 py-4">{children}</div>;
|
||||
}
|
||||
|
||||
function GeneralForm() {
|
||||
const form = useFormContext<FormData>();
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<SidebarSection>
|
||||
<SidebarContent>
|
||||
<FieldGroupContent>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="event.title"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Title</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder={t("titlePlaceholder")} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="event.description"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>{t("description")}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
{...field}
|
||||
placeholder={t("descriptionPlaceholder")}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="event.location"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>{t("location")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder={t("locationPlaceholder")} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FieldGroupContent>
|
||||
</SidebarContent>
|
||||
</SidebarSection>
|
||||
);
|
||||
}
|
||||
|
||||
type FormData = {
|
||||
event: {
|
||||
title: string;
|
||||
description?: string;
|
||||
location?: string;
|
||||
timezone?: string; // undefined if meeting is remote
|
||||
};
|
||||
form: {
|
||||
type: "poll";
|
||||
poll: {
|
||||
prompt: string;
|
||||
duration: number;
|
||||
options: {
|
||||
start: Date;
|
||||
}[];
|
||||
settings: {
|
||||
hideParticipants: boolean;
|
||||
hideScores: boolean;
|
||||
allowTentative: boolean;
|
||||
};
|
||||
};
|
||||
advanced: {
|
||||
requireParticipantEmail: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
function PollForm() {
|
||||
const form = useFormContext<FormData>();
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<SidebarSection>
|
||||
<SidebarContent>
|
||||
<FieldGroup>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="form.poll.prompt"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("prompt", {
|
||||
defaultValue: "Prompt",
|
||||
})}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder={t("pollPromptPlaceholder", {
|
||||
defaultValue: "Select as many dates as you can attend",
|
||||
})}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="form.poll.options"
|
||||
defaultValue={[]}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("options", {
|
||||
defaultValue: "Options",
|
||||
})}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Calendar
|
||||
selected={field.value.map((option) => option.start)}
|
||||
onSelectedChange={(selection) => {
|
||||
field.onChange(
|
||||
selection?.map(
|
||||
(date) =>
|
||||
({
|
||||
start: date,
|
||||
}) ?? [],
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="form.poll.duration"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("duration", {
|
||||
defaultValue: "Duration",
|
||||
})}
|
||||
</FormLabel>
|
||||
<Select
|
||||
value={field.value?.toString()}
|
||||
onValueChange={(newValue) => {
|
||||
field.onChange(parseInt(newValue));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger ref={field.ref}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">All-day</SelectItem>
|
||||
<SelectItem value="15">15 minutes</SelectItem>
|
||||
<SelectItem value="30">30 minutes</SelectItem>
|
||||
<SelectItem value="45">45 minutes</SelectItem>
|
||||
<SelectItem value="60">1 hour</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{form.watch("form.poll.duration") === 0 ? (
|
||||
<div>all-day</div>
|
||||
) : (
|
||||
<div>time</div>
|
||||
)}
|
||||
</FieldGroup>
|
||||
</SidebarContent>
|
||||
</SidebarSection>
|
||||
);
|
||||
}
|
||||
|
||||
function Poll({
|
||||
prompt,
|
||||
options,
|
||||
}: {
|
||||
prompt: string;
|
||||
options: { start: Date }[];
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<h3 className="sticky top-0 -mx-6 -mt-6 p-6 font-semibold">{prompt}</h3>
|
||||
<ul className="mb-4 flex gap-x-4">
|
||||
<li className="flex items-center gap-x-2">
|
||||
<YesIcon className="size-5 text-green-500" />
|
||||
<span className="text-sm">Yes</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-x-2">
|
||||
<IfNeedBeIcon className="size-5 text-amber-400" />
|
||||
<span className="text-sm">If need be</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-x-2">
|
||||
<NoIcon className="size-5 text-gray-400" />
|
||||
<span className="text-sm">No</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div className="flex grow gap-x-4">
|
||||
<div className="grow">
|
||||
<div className="grid grow gap-2">
|
||||
{options.map((option, i) => (
|
||||
<div
|
||||
className="flex items-center gap-x-4 rounded-md border bg-white px-4 py-3"
|
||||
key={i}
|
||||
>
|
||||
<Checkbox id={`option${i}`} name={`option${i}`} />
|
||||
<label className="grow font-medium" htmlFor={`option${i}`}>
|
||||
{dayjs(option.start).format("DD MMM YYYY")}
|
||||
</label>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<MoreHorizontalIcon className="text-muted-foreground size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* <div>
|
||||
<Calendar
|
||||
disabled={(date) => {
|
||||
return !options.some((option) =>
|
||||
dayjs(option.start).isSame(date, "day"),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateFormInput() {
|
||||
const form = useFormContext<FormData>();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<Tabs
|
||||
onValueChange={(newValue) => {
|
||||
const queryString = new URLSearchParams(searchParams?.toString());
|
||||
queryString.set("tab", newValue);
|
||||
router.replace(pathname + `?${queryString.toString()}`);
|
||||
}}
|
||||
value={searchParams?.get("tab") ?? "event"}
|
||||
>
|
||||
<div className="p-4">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger
|
||||
value="event"
|
||||
className="flex grow items-center gap-x-2"
|
||||
>
|
||||
<CalendarIcon className="size-4" /> Event
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="form"
|
||||
className="flex grow items-center gap-x-2"
|
||||
>
|
||||
<ListIcon className="size-4" />
|
||||
Form
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<TabsContent value="event">
|
||||
<GeneralForm />
|
||||
</TabsContent>
|
||||
<TabsContent value="form">
|
||||
<PollForm />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateFormPreview() {
|
||||
const form = useFormContext<FormData>();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<InviteCard className="mx-auto">
|
||||
<InviteCardGeneral
|
||||
title={form.watch("event.title") || t("titlePlaceholder")}
|
||||
location={form.watch("event.location") || t("locationPlaceholder")}
|
||||
description={
|
||||
form.watch("event.description") || t("descriptionPlaceholder")
|
||||
}
|
||||
/>
|
||||
<InviteCardForm>
|
||||
<div
|
||||
className="h-full rounded-md"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<div className="text-muted-foreground">Step 1 of 2</div>
|
||||
{form.watch("form.type") === "poll" && (
|
||||
<Poll
|
||||
prompt={
|
||||
form.watch("form.poll.prompt") ||
|
||||
t("pollPromptPlaceholder", {
|
||||
defaultValue: "Select as many dates as you can attend",
|
||||
})
|
||||
}
|
||||
options={form.watch("form.poll.options", [])}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</InviteCardForm>
|
||||
</InviteCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function CreateForm() {
|
||||
const form = useForm<FormData>();
|
||||
|
||||
const { clear } = useFormPersist("create-form", {
|
||||
watch: form.watch,
|
||||
setValue: form.setValue,
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form className="flex h-screen w-full flex-col" onSubmit={() => {}}>
|
||||
<PageHeader className="flex justify-between">
|
||||
<Button
|
||||
asChild
|
||||
onClick={() => {
|
||||
clear();
|
||||
}}
|
||||
>
|
||||
<Link href="/polls">
|
||||
<ArrowLeftIcon className="text-muted-foreground size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="primary">Create</Button>
|
||||
</PageHeader>
|
||||
|
||||
<div className="flex grow">
|
||||
<div className="shadow-huge m-4 w-96 rounded-md border bg-gray-50">
|
||||
<CreateFormInput />
|
||||
</div>
|
||||
<PageContainer className="sticky top-0 hidden grow items-center justify-center lg:flex">
|
||||
<PageContent>
|
||||
<CreateFormPreview />
|
||||
</PageContent>
|
||||
</PageContainer>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
69
apps/web/src/app/[locale]/create/invite-card.tsx
Normal file
69
apps/web/src/app/[locale]/create/invite-card.tsx
Normal file
|
@ -0,0 +1,69 @@
|
|||
import { cn } from "@rallly/ui";
|
||||
import { GlobeIcon, MapPinIcon } from "lucide-react";
|
||||
|
||||
import { LogoLink } from "@/app/components/logo-link";
|
||||
|
||||
export function InviteCardGeneral({
|
||||
title,
|
||||
location,
|
||||
description,
|
||||
}: {
|
||||
title?: React.ReactNode;
|
||||
location?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<aside className="border-primary w-72 shrink-0 p-6">
|
||||
<LogoLink />
|
||||
<h1 className="my-4 text-xl font-bold">{title}</h1>
|
||||
{description ? (
|
||||
<p className="text-muted-foreground leading-relaxed">{description}</p>
|
||||
) : null}
|
||||
<ul className="mt-6 space-y-2.5">
|
||||
{location ? (
|
||||
<li className="text-muted-foreground flex items-center text-sm leading-relaxed">
|
||||
<MapPinIcon className="mr-2 inline-block h-4 w-4" />
|
||||
<span>{location}</span>
|
||||
</li>
|
||||
) : null}
|
||||
<li className="text-muted-foreground flex items-center text-sm leading-relaxed">
|
||||
<GlobeIcon className="mr-2 inline-block h-4 w-4" />
|
||||
<span>Europe/London</span>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export function InviteCardForm({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("h-[400px] min-h-0 w-96 grow overflow-auto", className)}>
|
||||
<div className="p-6">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InviteCard({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"shadow-huge flex max-h-full min-h-0 max-w-4xl rounded-lg bg-white",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
3
apps/web/src/app/[locale]/create/loading.tsx
Normal file
3
apps/web/src/app/[locale]/create/loading.tsx
Normal file
|
@ -0,0 +1,3 @@
|
|||
export default function Loading() {
|
||||
return null;
|
||||
}
|
17
apps/web/src/app/[locale]/create/page.tsx
Normal file
17
apps/web/src/app/[locale]/create/page.tsx
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { CreateForm } from "@/app/[locale]/create/create-form";
|
||||
import { getTranslation } from "@/app/i18n";
|
||||
|
||||
export default async function Page({ params }: { params: { locale: string } }) {
|
||||
return <CreateForm />;
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: { locale: string };
|
||||
}) {
|
||||
const { t } = await getTranslation(params.locale);
|
||||
return {
|
||||
title: t("newPoll"),
|
||||
};
|
||||
}
|
19
apps/web/src/utils/query-string.ts
Normal file
19
apps/web/src/utils/query-string.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
"use client";
|
||||
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import React from "react";
|
||||
|
||||
export function useQueryString() {
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
const createQueryString = React.useCallback(
|
||||
(name: string, value: string) => {
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
params.set(name, value);
|
||||
|
||||
return pathname + "?" + params.toString();
|
||||
},
|
||||
[searchParams, pathname],
|
||||
);
|
||||
return { queryString: searchParams, createQueryString };
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue