diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json
index 8dcdf0a..b96fa43 100644
--- a/apps/nextjs/package.json
+++ b/apps/nextjs/package.json
@@ -6,7 +6,7 @@
"scripts": {
"build": "pnpm with-env next build",
"clean": "git clean -xdf .next .turbo node_modules",
- "dev": "pnpm with-env next dev --turbo",
+ "dev": "pnpm with-env next dev",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"start": "pnpm with-env next start",
@@ -29,10 +29,12 @@
"@trpc/react-query": "11.0.0-rc.364",
"@trpc/server": "11.0.0-rc.364",
"@xyflow/react": "^12.0.3",
+ "date-fns": "^3.6.0",
"geist": "^1.3.0",
"next": "^14.2.3",
"react": "18.3.1",
"react-dom": "18.3.1",
+ "react-notion": "^0.10.0",
"reactflow": "^11.11.4",
"recharts": "^2.12.7",
"superjson": "2.2.1",
diff --git a/apps/nextjs/src/app/(auth)/sign-in/page.tsx b/apps/nextjs/src/app/(auth)/sign-in/page.tsx
new file mode 100644
index 0000000..0f04bc4
--- /dev/null
+++ b/apps/nextjs/src/app/(auth)/sign-in/page.tsx
@@ -0,0 +1,72 @@
+import Link from "next/link";
+
+import { signIn } from "@amaxa/auth";
+import { Button } from "@amaxa/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@amaxa/ui/card";
+import { Input } from "@amaxa/ui/input";
+import { Label } from "@amaxa/ui/label";
+
+import { env } from "~/env";
+
+export default function LoginForm() {
+ return (
+
+
+
+ Login
+
+ Enter your email below to login to your account
+
+
+
+
+ {env.NODE_ENV === "development" ? (
+ <>
+
+ >
+ ) : null}
+
+
+
+
+ Have question's about how we use your data?{" "}
+
+ Read our privacy statement here
+
+
+
+
+
+ );
+}
diff --git a/apps/nextjs/src/app/(auth)/sign-out/page.tsx b/apps/nextjs/src/app/(auth)/sign-out/page.tsx
new file mode 100644
index 0000000..5691f40
--- /dev/null
+++ b/apps/nextjs/src/app/(auth)/sign-out/page.tsx
@@ -0,0 +1,73 @@
+import React from "react";
+import Link from "next/link";
+
+import { signIn } from "@amaxa/auth";
+import { Button } from "@amaxa/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@amaxa/ui/card";
+import { Input } from "@amaxa/ui/input";
+import { Label } from "@amaxa/ui/label";
+
+import { env } from "~/env";
+
+export default function LoginForm() {
+ return (
+
+
+
+ Login
+
+ Enter your email below to login to your account
+
+
+
+
+ {env.NODE_ENV === "development" ? (
+ <>
+
+ >
+ ) : null}
+
+
+
+
+ Have question's about how we use your data?{" "}
+
+ Read our privacy statement here
+
+
+
+
+
+ );
+}
diff --git a/apps/nextjs/src/app/(dashboard)/project/[id]/(root)/layout.tsx b/apps/nextjs/src/app/(dashboard)/project/[id]/(root)/layout.tsx
index 5ff4631..9622e86 100644
--- a/apps/nextjs/src/app/(dashboard)/project/[id]/(root)/layout.tsx
+++ b/apps/nextjs/src/app/(dashboard)/project/[id]/(root)/layout.tsx
@@ -1,7 +1,15 @@
import React, { cache } from "react";
import Link from "next/link";
import { notFound } from "next/navigation";
-import { House, User, Search, Settings, Workflow, ChartArea } from "lucide-react";
+import {
+ ChartArea,
+ House,
+ Search,
+ Settings,
+ User,
+ Workflow,
+} from "lucide-react";
+
import { db } from "@amaxa/db/client";
import {
Breadcrumb,
diff --git a/apps/nextjs/src/app/(dashboard)/project/[id]/(root)/permissions/_components/add-user-form.tsx b/apps/nextjs/src/app/(dashboard)/project/[id]/(root)/permissions/_components/add-user-form.tsx
new file mode 100644
index 0000000..114faa5
--- /dev/null
+++ b/apps/nextjs/src/app/(dashboard)/project/[id]/(root)/permissions/_components/add-user-form.tsx
@@ -0,0 +1,83 @@
+"use client";
+
+import React from "react";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+
+import { Button } from "@amaxa/ui/button";
+import { Combobox } from "@amaxa/ui/combobox";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@amaxa/ui/dialog";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@amaxa/ui/form";
+
+const addUserSchema = z.object({
+ userId: z.string(),
+ permissions: z.array(z.string()),
+});
+
+type AddUserForm = z.infer;
+
+export default function AddUserForm({
+ userMap,
+}: {
+ userMap: {
+ value: string;
+ label: string;
+ }[];
+}) {
+ const form = useForm({
+ resolver: zodResolver(addUserSchema),
+ });
+
+ function onSubmit(data: AddUserForm) {
+ console.log(data);
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/nextjs/src/app/(dashboard)/project/[id]/(root)/permissions/_components/add-user.tsx b/apps/nextjs/src/app/(dashboard)/project/[id]/(root)/permissions/_components/add-user.tsx
new file mode 100644
index 0000000..06c5203
--- /dev/null
+++ b/apps/nextjs/src/app/(dashboard)/project/[id]/(root)/permissions/_components/add-user.tsx
@@ -0,0 +1,19 @@
+import { api } from "~/trpc/server";
+import AddUserForm from "./add-user-form";
+
+export async function AddUser() {
+ const data = await api.users.usersNotInProject();
+
+ const userMap = data.map((user) => {
+ return {
+ value: user.id,
+ label: user.name ?? "No Name User",
+ };
+ });
+
+ return (
+
+ );
+}
diff --git a/apps/nextjs/src/app/(dashboard)/project/[id]/(root)/permissions/page.tsx b/apps/nextjs/src/app/(dashboard)/project/[id]/(root)/permissions/page.tsx
index a5d8979..fb208f5 100644
--- a/apps/nextjs/src/app/(dashboard)/project/[id]/(root)/permissions/page.tsx
+++ b/apps/nextjs/src/app/(dashboard)/project/[id]/(root)/permissions/page.tsx
@@ -32,6 +32,7 @@ import {
TableRow,
} from "@amaxa/ui/table";
+import { AddUser } from "./_components/add-user";
import PermissionsModal from "./_components/permissions-dialog";
export default function Page({
@@ -46,7 +47,7 @@ export default function Page({
Permissions
-
+
diff --git a/apps/nextjs/src/app/(dashboard)/project/[id]/(tasks)/layout.tsx b/apps/nextjs/src/app/(dashboard)/project/[id]/(tasks)/layout.tsx
index e69de29..1483f19 100644
--- a/apps/nextjs/src/app/(dashboard)/project/[id]/(tasks)/layout.tsx
+++ b/apps/nextjs/src/app/(dashboard)/project/[id]/(tasks)/layout.tsx
@@ -0,0 +1,5 @@
+import React from "react";
+
+export default function Layout({ children }: { children: React.ReactNode }) {
+ return {children}
;
+}
diff --git a/apps/nextjs/src/app/(home)/@modal/(...)guide/[id]/com.tsx b/apps/nextjs/src/app/(home)/@modal/(...)guide/[id]/com.tsx
new file mode 100644
index 0000000..ec6ed37
--- /dev/null
+++ b/apps/nextjs/src/app/(home)/@modal/(...)guide/[id]/com.tsx
@@ -0,0 +1,25 @@
+import React from "react";
+
+import "react-notion/src/styles.css";
+
+import type { BlockMapType } from "react-notion";
+import { NotionRenderer } from "react-notion";
+
+import { Card, CardContent, CardHeader, CardTitle } from "@amaxa/ui/card";
+
+export const Com = async (props: { id: string }) => {
+ const data = (await fetch(
+ `https://notion-api.splitbee.io/v1/page/${props.id}`,
+ ).then((res) => res.json())) as BlockMapType;
+
+ return (
+
+
+ Action Guide
+
+
+
+
+
+ );
+};
diff --git a/apps/nextjs/src/app/(home)/@modal/(...)guide/[id]/page.tsx b/apps/nextjs/src/app/(home)/@modal/(...)guide/[id]/page.tsx
new file mode 100644
index 0000000..e21eb9d
--- /dev/null
+++ b/apps/nextjs/src/app/(home)/@modal/(...)guide/[id]/page.tsx
@@ -0,0 +1,20 @@
+import React from "react";
+
+import Modal from "@amaxa/ui/modal";
+
+import { Com } from "./com";
+
+export default function Page(props: {
+ params: {
+ id: string;
+ };
+}) {
+ const { id } = props.params;
+ console.log(id);
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/nextjs/src/app/(home)/@modal/default.tsx b/apps/nextjs/src/app/(home)/@modal/default.tsx
new file mode 100644
index 0000000..6ddf1b7
--- /dev/null
+++ b/apps/nextjs/src/app/(home)/@modal/default.tsx
@@ -0,0 +1,3 @@
+export default function Default() {
+ return null;
+}
diff --git a/apps/nextjs/src/app/(home)/_components/sidebar-items.tsx b/apps/nextjs/src/app/(home)/_components/sidebar-items.tsx
new file mode 100644
index 0000000..ed8c919
--- /dev/null
+++ b/apps/nextjs/src/app/(home)/_components/sidebar-items.tsx
@@ -0,0 +1,92 @@
+"use client";
+
+import type { LucideIcon } from "lucide-react";
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+
+import { cn } from "@amaxa/ui";
+
+import { additionalLinks, getLink } from "~/lib/nav";
+
+export interface SidebarLink {
+ title: string;
+ href: string;
+ icon: LucideIcon;
+}
+
+const SidebarItems = () => {
+ const defaultLinks = getLink();
+ return (
+ <>
+
+ {additionalLinks.length > 0
+ ? additionalLinks.map((l) => (
+
+ ))
+ : null}
+ >
+ );
+};
+export default SidebarItems;
+
+const SidebarLinkGroup = ({
+ links,
+ title,
+ border,
+}: {
+ links: SidebarLink[];
+ title?: string;
+ border?: boolean;
+}) => {
+ const fullPathname = usePathname();
+ const pathname = "/" + fullPathname.split("/")[1];
+
+ return (
+
+ {title ? (
+
+ {title}
+
+ ) : null}
+
+ {links.map((link) => (
+ -
+
+
+ ))}
+
+
+ );
+};
+const SidebarLink = ({
+ link,
+ active,
+}: {
+ link: SidebarLink;
+ active: boolean;
+}) => {
+ return (
+
+
+
+ );
+};
diff --git a/apps/nextjs/src/app/(home)/_components/sidebar.tsx b/apps/nextjs/src/app/(home)/_components/sidebar.tsx
new file mode 100644
index 0000000..92dffa3
--- /dev/null
+++ b/apps/nextjs/src/app/(home)/_components/sidebar.tsx
@@ -0,0 +1,67 @@
+import { memo } from "react";
+import { Poppins } from "next/font/google";
+import Link from "next/link";
+
+import type { Session } from "@amaxa/auth";
+import { auth } from "@amaxa/auth";
+import { Avatar, AvatarFallback } from "@amaxa/ui/avatar";
+
+import SidebarItems from "./sidebar-items";
+
+const poppins = Poppins({
+ subsets: ["latin"],
+ display: "swap",
+ variable: "--font-poppins",
+ weight: ["100", "200", "300", "400", "500", "600", "700", "800", "900"],
+});
+
+const Sidebar = async () => {
+ const session = await auth();
+ if (session === null) return null;
+
+ return (
+
+ );
+};
+
+export default memo(Sidebar);
+
+const UserDetails = ({ session }: { session: Session }) => {
+ const user = session.user;
+
+ if (!user.name || user.name.length == 0) return null;
+
+ return (
+
+
+
+
{user.name ?? "John Doe"}
+
+ {user.email ?? "john@doe.com"}
+
+
+
+
+ {user.name
+ ? user.name
+ .split(" ")
+ .map((word) => word[0]?.toUpperCase())
+ .join("")
+ : "~"}
+
+
+
+
+ );
+};
diff --git a/apps/nextjs/src/app/(home)/action-guides/_components/GuideCard.tsx b/apps/nextjs/src/app/(home)/action-guides/_components/GuideCard.tsx
new file mode 100644
index 0000000..a880c5e
--- /dev/null
+++ b/apps/nextjs/src/app/(home)/action-guides/_components/GuideCard.tsx
@@ -0,0 +1,117 @@
+/* eslint-disable @typescript-eslint/no-unsafe-argument */
+// @ts-nocheck
+
+/* eslint-disable @typescript-eslint/no-unsafe-member-access */
+/* eslint-disable @typescript-eslint/no-explicit-any */
+"use client";
+
+import { useMemo, useState } from "react";
+import Link from "next/link";
+
+import { Badge } from "@amaxa/ui/badge";
+import {
+ Card,
+ CardContent,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "@amaxa/ui/card";
+import { Input } from "@amaxa/ui/input";
+
+export default function Guides() {
+ const [search, setSearch] = useState("");
+ const [selectedTags, setSelectedTags] = useState([]);
+ const actionGuides = [
+ {
+ id: 1,
+ title: "Test Action Guide 1",
+ description:
+ "Lorem ipsum dolor sit amet, qui minim labore adipisicing minim sint cillum sint consectetur cupidatat.",
+ tags: ["Fundraising"],
+ embedId: "Opening-Guide-Copy-7acb2eb37957405694b19afa43ae7b9c",
+ },
+ {
+ id: 2,
+ title: "Test Action Guide 2",
+ description:
+ "Lorem ipsum dolor sit amet, qui minim labore adipisicing minim sint cillum sint consectetur cupidatat.",
+ tags: ["Impact Outreach"],
+ embedId: "Opening-Guide-Copy-7acb2eb37957405694b19afa43ae7b9c",
+ },
+ ];
+
+ const filteredGuides = useMemo(() => {
+ return actionGuides.filter((guide) => {
+ const titleMatch = guide.title
+ .toLowerCase()
+ .includes(search.toLowerCase());
+ const tagMatch = selectedTags.every((tag) => guide.tags.includes(tag));
+ return titleMatch && tagMatch;
+ });
+ }, [search, selectedTags]);
+ const handleSearch = (e: any) => {
+ setSearch(e.target.value);
+ };
+ const handleTagClick = (tag: never) => {
+ if (selectedTags.includes(tag)) {
+ setSelectedTags(selectedTags.filter((t) => t !== tag));
+ } else {
+ setSelectedTags([...selectedTags, tag]);
+ }
+ };
+ const allTags = useMemo(() => {
+ return [...new Set(actionGuides.flatMap((guide) => guide.tags))];
+ }, [actionGuides]);
+
+ return (
+
+
Action Guides
+
+
+
+
+
Filter by Skill
+
+ {allTags.map((tag: string) => (
+ handleTagClick(tag)}
+ className="cursor-pointer"
+ >
+ {tag}
+
+ ))}
+
+
+
+ {filteredGuides.map((guide) => (
+
+
+
+ {guide.title}
+
+
+ {guide.description}
+
+
+
+ {guide.tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/apps/nextjs/src/app/(home)/action-guides/com.tsx b/apps/nextjs/src/app/(home)/action-guides/com.tsx
new file mode 100644
index 0000000..32b9a27
--- /dev/null
+++ b/apps/nextjs/src/app/(home)/action-guides/com.tsx
@@ -0,0 +1,18 @@
+import React from "react";
+
+import "react-notion/src/styles.css";
+
+import type { BlockMapType } from "react-notion";
+import { NotionRenderer } from "react-notion";
+
+export const Com = async (props: { id: string }) => {
+ const data = (await fetch(
+ `https://notion-api.splitbee.io/v1/page/${props.id}`,
+ ).then((res) => res.json())) as BlockMapType;
+
+ return (
+
+
+
+ );
+};
diff --git a/apps/nextjs/src/app/(home)/action-guides/page.tsx b/apps/nextjs/src/app/(home)/action-guides/page.tsx
new file mode 100644
index 0000000..e7ff82f
--- /dev/null
+++ b/apps/nextjs/src/app/(home)/action-guides/page.tsx
@@ -0,0 +1,11 @@
+import React from "react";
+
+import Guides from "./_components/GuideCard";
+
+export default function Page() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/nextjs/src/app/(home)/events/_components/CreateEvent.tsx b/apps/nextjs/src/app/(home)/events/_components/CreateEvent.tsx
new file mode 100644
index 0000000..5dff99e
--- /dev/null
+++ b/apps/nextjs/src/app/(home)/events/_components/CreateEvent.tsx
@@ -0,0 +1,246 @@
+"use client";
+
+import type { z } from "zod";
+import React from "react";
+import { useRouter } from "next/navigation";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { format } from "date-fns";
+import { CalendarIcon } from "lucide-react";
+import { useForm } from "react-hook-form";
+
+import { createEventSchema } from "@amaxa/db/schema";
+import { cn } from "@amaxa/ui";
+import { Button } from "@amaxa/ui/button";
+import { Calendar } from "@amaxa/ui/calendar";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@amaxa/ui/dialog";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@amaxa/ui/form";
+import { Input } from "@amaxa/ui/input";
+import { Popover, PopoverContent, PopoverTrigger } from "@amaxa/ui/popover";
+import { Switch } from "@amaxa/ui/switch";
+import { Textarea } from "@amaxa/ui/textarea";
+import { TimePickerDemo } from "@amaxa/ui/time-picker/time-picker-demo";
+import { toast } from "@amaxa/ui/toast";
+
+import { showErrorToast } from "~/lib/handle-error";
+import { api } from "~/trpc/react";
+
+type CreateEventProps = z.infer;
+
+export const CreateEvent = () => {
+ const router = useRouter();
+
+ const { mutate: create } = api.events.create.useMutation({
+ onSuccess: () => {
+ toast.success("Event created");
+ router.refresh();
+ },
+ onError: (error) => {
+ showErrorToast(error);
+ },
+ });
+
+ const form = useForm({
+ resolver: zodResolver(createEventSchema),
+ });
+
+ function handleSubmit(data: CreateEventProps) {
+ create(data);
+ }
+
+ return (
+
+
+
+ );
+};
diff --git a/apps/nextjs/src/app/(home)/events/_components/EventCard.tsx b/apps/nextjs/src/app/(home)/events/_components/EventCard.tsx
new file mode 100644
index 0000000..37530d4
--- /dev/null
+++ b/apps/nextjs/src/app/(home)/events/_components/EventCard.tsx
@@ -0,0 +1,45 @@
+import Link from "next/link";
+import { CalendarIcon, MapPinIcon } from "lucide-react";
+
+import type { RouterOutputs } from "@amaxa/api";
+import { Button } from "@amaxa/ui/button";
+import { Card } from "@amaxa/ui/card";
+
+export function EventCard({
+ event,
+}: {
+ event: RouterOutputs["events"]["all"][0];
+}) {
+ return (
+
+
+
+
{event.name}
+
{event.desc}
+
+
+
+
+
+
+
+ {event.time.toLocaleDateString()} {event.time.toLocaleTimeString()}
+
+
+
+
+
+ {event.isVirtual ? "Online" : "In-Person"}
+
+ Join Event
+
+
+
+
+
+ );
+}
diff --git a/apps/nextjs/src/app/(home)/events/_components/NoEvents.tsx b/apps/nextjs/src/app/(home)/events/_components/NoEvents.tsx
new file mode 100644
index 0000000..9d7f97b
--- /dev/null
+++ b/apps/nextjs/src/app/(home)/events/_components/NoEvents.tsx
@@ -0,0 +1,21 @@
+"use client";
+
+import { CreateEvent } from "./CreateEvent";
+
+export function NoEvents() {
+ return (
+
+
+
+ There are no events yet
+
+
+ You can add a new event by clicking the button below
+
+
+
+
+
+
+ );
+}
diff --git a/apps/nextjs/src/app/(home)/events/page.tsx b/apps/nextjs/src/app/(home)/events/page.tsx
new file mode 100644
index 0000000..fb6b7e0
--- /dev/null
+++ b/apps/nextjs/src/app/(home)/events/page.tsx
@@ -0,0 +1,51 @@
+import { unstable_noStore as noStore } from "next/cache";
+import { z } from "zod";
+
+import { api } from "~/trpc/server";
+import { CreateEvent } from "./_components/CreateEvent";
+import { EventCard } from "./_components/EventCard";
+import { NoEvents } from "./_components/NoEvents";
+
+const searchParamsSchema = z.object({
+ name: z.string().optional(),
+});
+
+export default async function Home(props: {
+ searchParams: Record;
+}) {
+ noStore();
+ const { name } = searchParamsSchema.parse(props.searchParams);
+
+ const data = await api.events.all({
+ name: name,
+ });
+
+ return (
+
+
+
+
Events
+
RSVP to upcoming events
+
+
+
+
{data.length > 0 && }
+
+
+ {data.length === 0 && (
+
+
+
+
+
+ )}
+
+
+ {data.map((event) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/nextjs/src/app/(home)/layout.tsx b/apps/nextjs/src/app/(home)/layout.tsx
index 20d9ed1..43beb86 100644
--- a/apps/nextjs/src/app/(home)/layout.tsx
+++ b/apps/nextjs/src/app/(home)/layout.tsx
@@ -1,9 +1,23 @@
import React from "react";
-export default function Layout({ children }: { children?: React.ReactNode }) {
+import Sidebar from "./_components/sidebar";
+
+export default function Layout({
+ children,
+ modal,
+}: {
+ children: React.ReactNode;
+ modal: React.ReactNode;
+}) {
return (
-
{children}
+
+
+
+ {children}
+
+ {modal}
+
);
}
diff --git a/apps/nextjs/src/app/api/project/[id]/route.ts b/apps/nextjs/src/app/api/project/[id]/route.ts
index 11339e7..49d53c8 100644
--- a/apps/nextjs/src/app/api/project/[id]/route.ts
+++ b/apps/nextjs/src/app/api/project/[id]/route.ts
@@ -1,7 +1,73 @@
-//TODO: the ablility for our landing site to fetch project data
+import type { NextApiRequest } from "next";
+import { NextResponse } from "next/server";
-export const GET = async () => {
- // find the data
- //TODO: find the project with this id, do a join and group the users by their roles, and parse that into an object then return it as json (users must have the isPublic option set to true)
- // return: { project: { name: string, users: { [role: string]: User[] }, coaches: { [role: string]: User } } }
-};
+import { and, eq } from "@amaxa/db";
+import { db } from "@amaxa/db/client";
+import { project_tracker, Projects, User } from "@amaxa/db/schema";
+
+export async function GET(req: NextApiRequest) {
+ try {
+ const { id } = req.query;
+
+ if (!id) {
+ return NextResponse.json(
+ { error: "Project ID is required" },
+ { status: 400 },
+ );
+ }
+
+ const projectId = String(id);
+
+ const result = await db.transaction(async (tx) => {
+ const project = await tx.query.Projects.findFirst({
+ where: eq(Projects.id, projectId),
+ });
+
+ if (!project) {
+ return null;
+ }
+
+ const users = await tx
+ .select({
+ id: User.id,
+ name: User.name,
+ role: User.role,
+ image: User.image,
+ })
+ .from(project_tracker)
+ .where(and(eq(project_tracker.projectId, projectId)))
+ .innerJoin(User, eq(project_tracker.userId, User.id))
+ .innerJoin(Projects, eq(project_tracker.projectId, Projects.id));
+
+ const coaches = await tx
+ .select({
+ id: User.id,
+ name: User.name,
+ role: User.role,
+ image: User.image,
+ })
+ .from(project_tracker)
+ .where(and(eq(project_tracker.projectId, projectId)))
+ .innerJoin(User, eq(project_tracker.userId, User.id))
+ .innerJoin(Projects, eq(project_tracker.projectId, Projects.id));
+
+ return { project, users, coaches };
+ });
+
+ if (!result) {
+ return NextResponse.json({ error: "Project not found" }, { status: 404 });
+ }
+
+ return NextResponse.json({
+ ...result.project,
+ users: result.users,
+ coaches: result.coaches,
+ });
+ } catch (error) {
+ console.error("Error fetching project data:", error);
+ return NextResponse.json(
+ { error: "Internal Server Error" },
+ { status: 500 },
+ );
+ }
+}
diff --git a/apps/nextjs/src/lib/nav.tsx b/apps/nextjs/src/lib/nav.tsx
new file mode 100644
index 0000000..50e96fc
--- /dev/null
+++ b/apps/nextjs/src/lib/nav.tsx
@@ -0,0 +1,26 @@
+import { BookIcon, Calendar, HomeIcon } from "lucide-react";
+
+import type { SidebarLink } from "~/app/(home)/_components/sidebar-items";
+
+interface AdditionalLinks {
+ title: string;
+ links: SidebarLink[];
+}
+
+export function getLink() {
+ const defaultLinks: SidebarLink[] = [
+ { href: "/", title: "Home", icon: HomeIcon },
+ {
+ href: "/events",
+ title: "Events",
+ icon: Calendar,
+ },
+ {
+ href: "/action-guides",
+ title: "Action Guides",
+ icon: BookIcon,
+ },
+ ];
+ return defaultLinks;
+}
+export const additionalLinks: AdditionalLinks[] = [];
diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts
index 11dacbd..598f041 100644
--- a/packages/api/src/root.ts
+++ b/packages/api/src/root.ts
@@ -1,12 +1,18 @@
+import { actionGuideRouter } from "./router/action-guides";
import { authRouter } from "./router/auth";
+import { eventsRouter } from "./router/events";
import { projectsRouter } from "./router/projects";
import { tasksRouter } from "./router/tasks";
+import { userRouter } from "./router/users";
import { createTRPCRouter } from "./trpc";
export const appRouter = createTRPCRouter({
auth: authRouter,
projects: projectsRouter,
+ users: userRouter,
tasks: tasksRouter,
+ events: eventsRouter,
+ actionGuides: actionGuideRouter,
});
// export type definition of API
diff --git a/packages/api/src/router/action-guides.ts b/packages/api/src/router/action-guides.ts
new file mode 100644
index 0000000..6080fee
--- /dev/null
+++ b/packages/api/src/router/action-guides.ts
@@ -0,0 +1,20 @@
+import { z } from "zod";
+
+import { createTRPCRouter, publicProcedure } from "../trpc";
+
+export const actionGuideRouter = createTRPCRouter({
+ getActionGuides: publicProcedure
+ .input(
+ z.object({
+ title: z.string().optional(),
+ skillId: z.string().optional(),
+ }),
+ )
+ .query(async ({ input, ctx }) => {
+ const { title } = input;
+ return await ctx.db.query.guides.findMany({
+ where: (guides, { and, ilike }) =>
+ and(title ? ilike(guides.title, `%${title}%`) : undefined),
+ });
+ }),
+});
diff --git a/packages/api/src/router/events.ts b/packages/api/src/router/events.ts
new file mode 100644
index 0000000..0019637
--- /dev/null
+++ b/packages/api/src/router/events.ts
@@ -0,0 +1,26 @@
+import { z } from "zod";
+
+import { createEventSchema, events } from "@amaxa/db/schema";
+
+import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
+
+export const eventsRouter = createTRPCRouter({
+ all: publicProcedure
+ .input(
+ z.object({
+ name: z.string().optional(),
+ }),
+ )
+ .query(async ({ ctx, input }) => {
+ const { name } = input;
+ return await ctx.db.query.events.findMany({
+ where: (events, { and, ilike }) =>
+ and(name ? ilike(events.name, name) : undefined),
+ });
+ }),
+ create: protectedProcedure
+ .input(createEventSchema)
+ .mutation(async ({ ctx, input }) => {
+ await ctx.db.insert(events).values(input);
+ }),
+});
diff --git a/packages/api/src/router/users.ts b/packages/api/src/router/users.ts
new file mode 100644
index 0000000..a460d98
--- /dev/null
+++ b/packages/api/src/router/users.ts
@@ -0,0 +1,14 @@
+import { User } from "@amaxa/db/schema";
+
+import { createTRPCRouter, protectedProcedure } from "../trpc";
+
+export const userRouter = createTRPCRouter({
+ usersNotInProject: protectedProcedure.mutation(async ({ ctx }) => {
+ return await ctx.db
+ .select({
+ id: User.id,
+ name: User.name,
+ })
+ .from(User);
+ }),
+});
diff --git a/packages/auth/src/actions.ts b/packages/auth/src/actions.ts
new file mode 100644
index 0000000..c5b22a1
--- /dev/null
+++ b/packages/auth/src/actions.ts
@@ -0,0 +1,49 @@
+import { eq, sql } from "drizzle-orm";
+
+import type { ProjectPermission } from "@amaxa/db/schema";
+import { db } from "@amaxa/db/client";
+import { project_tracker, User } from "@amaxa/db/schema";
+
+const preparedGetUserInfo = db
+ .select({
+ status: User.status,
+ role: User.role,
+ })
+ .from(User)
+ .where(eq(User.id, sql.placeholder("id")))
+ .prepare("getUserInfo");
+
+const preparedGetUserProjectTrackers = db
+ .select({
+ projectId: project_tracker.projectId,
+ permission: project_tracker.permission,
+ createdAt: project_tracker.createdAt,
+ })
+ .from(project_tracker)
+ .where(eq(project_tracker.userId, sql.placeholder("userId")))
+ .prepare("getUserProjectTrackers");
+
+async function getUserInformation(id: string) {
+ const userData = await preparedGetUserInfo.execute({ id });
+ const user = userData[0] ?? {
+ status: "Unverified",
+ role: "Student",
+ };
+
+ const projectTrackersData = await preparedGetUserProjectTrackers.execute({
+ userId: id,
+ });
+
+ const project_permissions: Record = {};
+
+ for (const tracker of projectTrackersData) {
+ project_permissions[tracker.projectId] = tracker.permission;
+ }
+
+ return {
+ ...user,
+ project_permissions,
+ };
+}
+
+export { getUserInformation };
diff --git a/packages/auth/src/config.ts b/packages/auth/src/config.ts
index 12f6279..a0a3855 100644
--- a/packages/auth/src/config.ts
+++ b/packages/auth/src/config.ts
@@ -8,17 +8,19 @@ import { skipCSRFCheck } from "@auth/core";
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import Google from "next-auth/providers/google";
+import type { ProjectPermission, UserRole, UserStatus } from "@amaxa/db/schema";
import { db } from "@amaxa/db/client";
-import { Account, Session, User, UserRoleEnum, UserStatusEnum } from "@amaxa/db/schema";
+import { Account, Session, User } from "@amaxa/db/schema";
import { env } from "../env";
+import { getUserInformation } from "./actions";
declare module "next-auth" {
interface Session {
user: {
- role: typeof UserRoleEnum;
- project_permissions?: string;
- status: typeof UserStatusEnum;
+ role: UserRole;
+ project_permissions?: Record;
+ status: UserStatus;
id: string;
} & DefaultSession["user"];
}
@@ -37,30 +39,26 @@ export const authConfig = {
// In development, we need to skip checks to allow Expo to work
...(!isSecureContext
? {
- skipCSRFCheck: skipCSRFCheck,
- trustHost: true,
- }
+ skipCSRFCheck: skipCSRFCheck,
+ trustHost: true,
+ }
: {}),
secret: env.AUTH_SECRET,
providers: [Google],
+ pages: {
+ signIn: "/auth/sign-in",
+ signOut: "/auth/sign-out",
+ },
callbacks: {
- session: async (opts) => {
+ session: (opts) => {
if (!("user" in opts))
throw new Error("unreachable with session strategy");
- const data = await db.query.User.findFirst({
- columns: {
- status: true,
- role: true,
- }
- })
return {
...opts.session,
user: {
...opts.session.user,
id: opts.user.id,
- status: data?.status,
- role: data?.role,
},
};
},
@@ -73,23 +71,20 @@ export const validateToken = async (
const sessionToken = token.slice("Bearer ".length);
const session = await adapter.getSessionAndUser?.(sessionToken);
- const data = await db.query.User.findFirst({
- where: (User, { eq }) => eq(User.id, session?.user.id!),
- columns: {
- status: true,
- role: true,
- }
- })
- return session
- ? {
+ if (session) {
+ const data = await getUserInformation(session.user.id);
+ return {
user: {
...session.user,
- role: data?.role! as unknown as typeof UserRoleEnum,
- status: data?.status! as unknown as typeof UserStatusEnum,
+ role: data.role,
+ status: data.status,
+ project_permissions: data.project_permissions,
},
expires: session.session.expires.toISOString(),
- }
- : null;
+ };
+ } else {
+ return null;
+ }
};
export const invalidateSessionToken = async (token: string) => {
diff --git a/packages/auth/src/permissions.ts b/packages/auth/src/permissions.ts
index d21fd49..6d0abce 100644
--- a/packages/auth/src/permissions.ts
+++ b/packages/auth/src/permissions.ts
@@ -12,8 +12,8 @@ export const TEST_USER: E2EUsers = {
user: {
id: "test_user",
name: "Jane Doe",
- permissions: new Set(["basics"]),
- project_permissions: "",
+ role: "Student",
+ status: "Unverified",
},
// expires in +1 day
expires: new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString(),
@@ -26,7 +26,8 @@ export const TEST_ADMIN: E2EUsers = {
user: {
id: "test_user",
name: "test user",
- permissions: new Set(["activities"]),
+ role: "Admin",
+ status: "Verified",
},
expires: new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString(),
},
diff --git a/packages/db/src/perms.ts b/packages/db/src/perms.ts
new file mode 100644
index 0000000..9de2f18
--- /dev/null
+++ b/packages/db/src/perms.ts
@@ -0,0 +1,3 @@
+export const permissions = ["write:tasks", "write:permissions"] as const;
+
+export type Permission = (typeof permissions)[number];
diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts
index 29591c8..2f06306 100644
--- a/packages/db/src/schema.ts
+++ b/packages/db/src/schema.ts
@@ -13,6 +13,8 @@ import {
} from "drizzle-orm/pg-core";
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
+import type { Permission } from "./perms";
+
export const User = pgTable("user", {
id: text("id")
.notNull()
@@ -23,10 +25,10 @@ export const User = pgTable("user", {
isPublic: boolean("is_public").notNull().default(true),
status: varchar("status", {
length: 30,
- enum: ["Verified", "Unverifed", "Pending"],
+ enum: ["Verified", "Unverified", "Pending"],
})
.notNull()
- .default("Unverifed"),
+ .default("Unverified"),
role: varchar("role", { length: 30, enum: ["Admin", "Coach", "Student"] })
.notNull()
.default("Student"),
@@ -37,15 +39,9 @@ export const User = pgTable("user", {
image: varchar("image", { length: 255 }),
});
-
-
-export const UserSchema = createSelectSchema(User);
-export const CreateUserSchema = createInsertSchema(User).omit({ id: true });
-
-export const UserStatusEnum = User.status.enumValues;
-export const UserRoleEnum = User.role.enumValues;
-
-
+export type User = typeof User.$inferSelect;
+export type UserStatus = User["status"];
+export type UserRole = User["role"];
export const UserRelations = relations(User, ({ many }) => ({
accounts: many(Account),
@@ -214,3 +210,121 @@ export const createProjectSchema = createInsertSchema(Projects).omit({
export type CreateProjectSchema = z.infer;
export const statusValues = tasks.status.enumValues;
+
+export const project_tracker = pgTable(
+ "project_tracker",
+ {
+ userId: text("user_id").notNull(),
+ projectId: text("project_id").notNull(),
+ permission: text("permissions").array().notNull().$type(),
+ createdAt: timestamp("created_at")
+ .default(sql`CURRENT_TIMESTAMP`)
+ .notNull(),
+ },
+ (table) => ({
+ pk: primaryKey({ columns: [table.userId, table.projectId] }),
+ }),
+);
+
+export const projectTrackerRelations = relations(
+ project_tracker,
+ ({ one }) => ({
+ project: one(Projects, {
+ fields: [project_tracker.projectId],
+ references: [Projects.id],
+ }),
+ user: one(User, {
+ fields: [project_tracker.userId],
+ references: [User.id],
+ }),
+ }),
+);
+export type ProjectTracker = typeof project_tracker.$inferSelect;
+export type ProjectPermission = ProjectTracker["permission"];
+
+export const skills = pgTable("skills", {
+ id: text("id")
+ .primaryKey()
+ .$defaultFn(() => createId()),
+ name: text("name").notNull(),
+ createdAt: timestamp("created_at")
+ .default(sql`CURRENT_TIMESTAMP`)
+ .notNull(),
+ updatedAt: timestamp("updatedAt"),
+});
+
+export const skillsRelations = relations(skills, ({ many }) => ({
+ guides: many(skillsToGuide),
+}));
+
+export const skillsToGuide = pgTable(
+ "skills_to_guide",
+ {
+ skillId: text("skill_id")
+ .notNull()
+ .references(() => skills.id),
+ guideId: text("guide_id")
+ .notNull()
+ .references(() => guides.id),
+ createdAt: timestamp("created_at")
+ .default(sql`CURRENT_TIMESTAMP`)
+ .notNull(),
+ updatedAt: timestamp("updatedAt"),
+ },
+ (table) => ({
+ pk: primaryKey({ columns: [table.skillId, table.guideId] }),
+ }),
+);
+
+export const skillsToGuideRelations = relations(skillsToGuide, ({ one }) => ({
+ skill: one(skills, {
+ fields: [skillsToGuide.skillId],
+ references: [skills.id],
+ }),
+ guide: one(guides, {
+ fields: [skillsToGuide.guideId],
+ references: [guides.id],
+ }),
+}));
+
+export const guides = pgTable("guides", {
+ id: text("id")
+ .primaryKey()
+ .$defaultFn(() => createId()),
+ title: text("title"),
+ desc: text("description").notNull(),
+ embedId: text("embed_id"),
+ createdAt: timestamp("created_at")
+ .default(sql`CURRENT_TIMESTAMP`)
+ .notNull(),
+ updatedAt: timestamp("updatedAt"),
+});
+
+export const guidesRelations = relations(guides, ({ many }) => ({
+ skills: many(skillsToGuide),
+}));
+
+export const events = pgTable("events", {
+ id: text("id")
+ .primaryKey()
+ .$defaultFn(() => createId()),
+ name: varchar("name", { length: 256 }).notNull(),
+ time: timestamp("date")
+ .default(sql`now()`)
+ .notNull(),
+ isVirtual: boolean("boolean").default(false).notNull(),
+ desc: text("description").notNull().notNull(),
+ image: text("image").notNull().default("https://placehold.co/600x400"),
+ isPublic: boolean("is_public").notNull().default(false),
+ registrationLink: text("registration_link").notNull(),
+ createdAt: timestamp("created_at")
+ .default(sql`CURRENT_TIMESTAMP`)
+ .notNull(),
+ updatedAt: timestamp("updatedAt"),
+});
+
+export const createEventSchema = createInsertSchema(events).omit({
+ id: true,
+ createdAt: true,
+ updatedAt: true,
+});
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 2a67d60..27a3b12 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -32,13 +32,16 @@
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-slot": "^1.1.0",
+ "@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.2",
"class-variance-authority": "^0.7.0",
"cmdk": "^1.0.0",
+ "date-fns": "^3.6.0",
"embla-carousel-react": "^8.1.7",
"lucide-react": "^0.396.0",
"next-themes": "^0.3.0",
+ "react-day-picker": "8.10.1",
"react-hook-form": "^7.51.4",
"recharts": "^2.12.7",
"sonner": "^1.4.41",
diff --git a/packages/ui/src/badge.tsx b/packages/ui/src/badge.tsx
index f8a0927..af6d990 100644
--- a/packages/ui/src/badge.tsx
+++ b/packages/ui/src/badge.tsx
@@ -2,7 +2,7 @@ import type { VariantProps } from "class-variance-authority";
import * as React from "react";
import { cva } from "class-variance-authority";
-import { cn } from ".";
+import { cn } from "@amaxa/ui";
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
diff --git a/packages/ui/src/calendar.tsx b/packages/ui/src/calendar.tsx
new file mode 100644
index 0000000..a14e40c
--- /dev/null
+++ b/packages/ui/src/calendar.tsx
@@ -0,0 +1,73 @@
+"use client";
+
+import * as React from "react";
+import { ChevronLeftIcon, ChevronRightIcon } from "@radix-ui/react-icons";
+import { DayPicker } from "react-day-picker";
+
+import { cn } from "@amaxa/ui";
+
+import { buttonVariants } from "./button";
+
+export type CalendarProps = React.ComponentProps;
+
+function Calendar({
+ className,
+ classNames,
+ showOutsideDays = true,
+ ...props
+}: CalendarProps) {
+ return (
+ .day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
+ : "[&:has([aria-selected])]:rounded-md",
+ ),
+ day: cn(
+ buttonVariants({ variant: "ghost" }),
+ "h-8 w-8 p-0 font-normal aria-selected:opacity-100",
+ ),
+ day_range_start: "day-range-start",
+ day_range_end: "day-range-end",
+ day_selected:
+ "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
+ day_today: "bg-accent text-accent-foreground",
+ day_outside:
+ "day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
+ day_disabled: "text-muted-foreground opacity-50",
+ day_range_middle:
+ "aria-selected:bg-accent aria-selected:text-accent-foreground",
+ day_hidden: "invisible",
+ ...classNames,
+ }}
+ components={{
+ IconLeft: ({ ...props }) => ,
+ IconRight: ({ ...props }) => ,
+ }}
+ {...props}
+ />
+ );
+}
+Calendar.displayName = "Calendar";
+
+export { Calendar };
diff --git a/packages/ui/src/combobox.tsx b/packages/ui/src/combobox.tsx
index c4a5e24..37a0f74 100644
--- a/packages/ui/src/combobox.tsx
+++ b/packages/ui/src/combobox.tsx
@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
-import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
+import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from ".";
import { Button } from "./button";
@@ -14,32 +14,14 @@ import {
} from "./command";
import { Popover, PopoverContent, PopoverTrigger } from "./popover";
-const frameworks = [
- {
- value: "next.js",
- label: "Next.js",
- },
- {
- value: "sveltekit",
- label: "SvelteKit",
- },
- {
- value: "nuxt.js",
- label: "Nuxt.js",
- },
- {
- value: "remix",
- label: "Remix",
- },
- {
- value: "astro",
- label: "Astro",
- },
-];
+interface ComboboxProps {
+ options: { label: string; value: string }[];
+ value?: string;
+ onChange: (value: string) => void;
+}
-export function ComboboxDemo() {
+export const Combobox = ({ options, value, onChange }: ComboboxProps) => {
const [open, setOpen] = React.useState(false);
- const [value, setValue] = React.useState("");
return (
@@ -48,35 +30,34 @@ export function ComboboxDemo() {
variant="outline"
role="combobox"
aria-expanded={open}
- className="w-[200px] justify-between"
+ className="w-full justify-between"
>
{value
- ? frameworks.find((framework) => framework.value === value)?.label
- : "Select framework..."}
-
+ ? options.find((option) => option.value === value)?.label
+ : "Select option..."}
+
-
+
-
- No framework found.
+
+ No option found.
- {frameworks.map((framework) => (
+ {options.map((option) => (
{
- setValue(currentValue === value ? "" : currentValue);
+ key={option.value}
+ onSelect={() => {
+ onChange(option.value === value ? "" : option.value);
setOpen(false);
}}
>
- {framework.label}
-
+ {option.label}
))}
@@ -84,4 +65,4 @@ export function ComboboxDemo() {
);
-}
+};
diff --git a/packages/ui/src/modal.tsx b/packages/ui/src/modal.tsx
new file mode 100644
index 0000000..3ce0c7c
--- /dev/null
+++ b/packages/ui/src/modal.tsx
@@ -0,0 +1,33 @@
+"use client";
+
+import type { FC, ReactNode } from "react";
+import { useRouter } from "next/navigation";
+import * as Dialog from "@radix-ui/react-dialog";
+
+interface ModalProps {
+ children: ReactNode;
+}
+
+const Modal: FC = ({ children }) => {
+ const router = useRouter();
+
+ const handleOnOpenChange = (open: boolean) => {
+ if (!open) {
+ router.back();
+ }
+ };
+
+ return (
+
+
+
+
+
+ {children}
+
+
+
+ );
+};
+
+export default Modal;
diff --git a/packages/ui/src/switch.tsx b/packages/ui/src/switch.tsx
new file mode 100644
index 0000000..1a20b27
--- /dev/null
+++ b/packages/ui/src/switch.tsx
@@ -0,0 +1,29 @@
+"use client";
+
+import * as React from "react";
+import * as SwitchPrimitives from "@radix-ui/react-switch";
+
+import { cn } from ".";
+
+const Switch = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+));
+Switch.displayName = SwitchPrimitives.Root.displayName;
+
+export { Switch };
diff --git a/packages/ui/src/time-picker/time-picker-demo.tsx b/packages/ui/src/time-picker/time-picker-demo.tsx
new file mode 100644
index 0000000..c2d08d9
--- /dev/null
+++ b/packages/ui/src/time-picker/time-picker-demo.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import * as React from "react";
+import { Clock } from "lucide-react";
+
+import { Label } from "../label";
+import { TimePickerInput } from "./time-picker-input";
+
+interface TimePickerDemoProps {
+ date: Date | undefined;
+ setDate: (date: Date | undefined) => void;
+}
+
+export function TimePickerDemo({ date, setDate }: TimePickerDemoProps) {
+ const minuteRef = React.useRef(null);
+ const hourRef = React.useRef(null);
+ const secondRef = React.useRef(null);
+
+ return (
+
+
+
+ minuteRef.current?.focus()}
+ />
+
+
+
+ hourRef.current?.focus()}
+ onRightFocus={() => secondRef.current?.focus()}
+ />
+
+
+
+ minuteRef.current?.focus()}
+ />
+
+
+
+
+
+ );
+}
diff --git a/packages/ui/src/time-picker/time-picker-input.tsx b/packages/ui/src/time-picker/time-picker-input.tsx
new file mode 100644
index 0000000..d188936
--- /dev/null
+++ b/packages/ui/src/time-picker/time-picker-input.tsx
@@ -0,0 +1,131 @@
+import React from "react";
+
+import { cn } from "..";
+import { Input } from "../input";
+import {
+ getArrowByType,
+ getDateByType,
+ Period,
+ setDateByType,
+ TimePickerType,
+} from "./time-picker-utils";
+
+export interface TimePickerInputProps
+ extends React.InputHTMLAttributes {
+ picker: TimePickerType;
+ date: Date | undefined;
+ setDate: (date: Date | undefined) => void;
+ period?: Period;
+ onRightFocus?: () => void;
+ onLeftFocus?: () => void;
+}
+
+const TimePickerInput = React.forwardRef<
+ HTMLInputElement,
+ TimePickerInputProps
+>(
+ (
+ {
+ className,
+ type = "tel",
+ value,
+ id,
+ name,
+ date = new Date(new Date().setHours(0, 0, 0, 0)),
+ setDate,
+ onChange,
+ onKeyDown,
+ picker,
+ period,
+ onLeftFocus,
+ onRightFocus,
+ ...props
+ },
+ ref,
+ ) => {
+ const [flag, setFlag] = React.useState(false);
+ const [prevIntKey, setPrevIntKey] = React.useState("0");
+
+ /**
+ * allow the user to enter the second digit within 2 seconds
+ * otherwise start again with entering first digit
+ */
+ React.useEffect(() => {
+ if (flag) {
+ const timer = setTimeout(() => {
+ setFlag(false);
+ }, 2000);
+
+ return () => clearTimeout(timer);
+ }
+ }, [flag]);
+
+ const calculatedValue = React.useMemo(() => {
+ return getDateByType(date, picker);
+ }, [date, picker]);
+
+ const calculateNewValue = (key: string) => {
+ /*
+ * If picker is '12hours' and the first digit is 0, then the second digit is automatically set to 1.
+ * The second entered digit will break the condition and the value will be set to 10-12.
+ */
+ if (picker === "12hours") {
+ if (flag && calculatedValue.slice(1, 2) === "1" && prevIntKey === "0")
+ return "0" + key;
+ }
+
+ return !flag ? "0" + key : calculatedValue.slice(1, 2) + key;
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Tab") return;
+ e.preventDefault();
+ if (e.key === "ArrowRight") onRightFocus?.();
+ if (e.key === "ArrowLeft") onLeftFocus?.();
+ if (["ArrowUp", "ArrowDown"].includes(e.key)) {
+ const step = e.key === "ArrowUp" ? 1 : -1;
+ const newValue = getArrowByType(calculatedValue, step, picker);
+ if (flag) setFlag(false);
+ const tempDate = new Date(date);
+ setDate(setDateByType(tempDate, newValue, picker, period));
+ }
+ if (e.key >= "0" && e.key <= "9") {
+ if (picker === "12hours") setPrevIntKey(e.key);
+
+ const newValue = calculateNewValue(e.key);
+ if (flag) onRightFocus?.();
+ setFlag((prev) => !prev);
+ const tempDate = new Date(date);
+ setDate(setDateByType(tempDate, newValue, picker, period));
+ }
+ };
+
+ return (
+ {
+ e.preventDefault();
+ onChange?.(e);
+ }}
+ type={type}
+ inputMode="decimal"
+ onKeyDown={(e) => {
+ onKeyDown?.(e);
+ handleKeyDown(e);
+ }}
+ {...props}
+ />
+ );
+ },
+);
+
+TimePickerInput.displayName = "TimePickerInput";
+
+export { TimePickerInput };
diff --git a/packages/ui/src/time-picker/time-picker-utils.tsx b/packages/ui/src/time-picker/time-picker-utils.tsx
new file mode 100644
index 0000000..7b8cbc7
--- /dev/null
+++ b/packages/ui/src/time-picker/time-picker-utils.tsx
@@ -0,0 +1,203 @@
+/**
+ * regular expression to check for valid hour format (01-23)
+ */
+export function isValidHour(value: string) {
+ return /^(0[0-9]|1[0-9]|2[0-3])$/.test(value);
+}
+
+/**
+ * regular expression to check for valid 12 hour format (01-12)
+ */
+export function isValid12Hour(value: string) {
+ return /^(0[1-9]|1[0-2])$/.test(value);
+}
+
+/**
+ * regular expression to check for valid minute format (00-59)
+ */
+export function isValidMinuteOrSecond(value: string) {
+ return /^[0-5][0-9]$/.test(value);
+}
+
+type GetValidNumberConfig = { max: number; min?: number; loop?: boolean };
+
+export function getValidNumber(
+ value: string,
+ { max, min = 0, loop = false }: GetValidNumberConfig,
+) {
+ let numericValue = parseInt(value, 10);
+
+ if (!isNaN(numericValue)) {
+ if (!loop) {
+ if (numericValue > max) numericValue = max;
+ if (numericValue < min) numericValue = min;
+ } else {
+ if (numericValue > max) numericValue = min;
+ if (numericValue < min) numericValue = max;
+ }
+ return numericValue.toString().padStart(2, "0");
+ }
+
+ return "00";
+}
+
+export function getValidHour(value: string) {
+ if (isValidHour(value)) return value;
+ return getValidNumber(value, { max: 23 });
+}
+
+export function getValid12Hour(value: string) {
+ if (isValid12Hour(value)) return value;
+ return getValidNumber(value, { min: 1, max: 12 });
+}
+
+export function getValidMinuteOrSecond(value: string) {
+ if (isValidMinuteOrSecond(value)) return value;
+ return getValidNumber(value, { max: 59 });
+}
+
+type GetValidArrowNumberConfig = {
+ min: number;
+ max: number;
+ step: number;
+};
+
+export function getValidArrowNumber(
+ value: string,
+ { min, max, step }: GetValidArrowNumberConfig,
+) {
+ let numericValue = parseInt(value, 10);
+ if (!isNaN(numericValue)) {
+ numericValue += step;
+ return getValidNumber(String(numericValue), { min, max, loop: true });
+ }
+ return "00";
+}
+
+export function getValidArrowHour(value: string, step: number) {
+ return getValidArrowNumber(value, { min: 0, max: 23, step });
+}
+
+export function getValidArrow12Hour(value: string, step: number) {
+ return getValidArrowNumber(value, { min: 1, max: 12, step });
+}
+
+export function getValidArrowMinuteOrSecond(value: string, step: number) {
+ return getValidArrowNumber(value, { min: 0, max: 59, step });
+}
+
+export function setMinutes(date: Date, value: string) {
+ const minutes = getValidMinuteOrSecond(value);
+ date.setMinutes(parseInt(minutes, 10));
+ return date;
+}
+
+export function setSeconds(date: Date, value: string) {
+ const seconds = getValidMinuteOrSecond(value);
+ date.setSeconds(parseInt(seconds, 10));
+ return date;
+}
+
+export function setHours(date: Date, value: string) {
+ const hours = getValidHour(value);
+ date.setHours(parseInt(hours, 10));
+ return date;
+}
+
+export function set12Hours(date: Date, value: string, period: Period) {
+ const hours = parseInt(getValid12Hour(value), 10);
+ const convertedHours = convert12HourTo24Hour(hours, period);
+ date.setHours(convertedHours);
+ return date;
+}
+
+export type TimePickerType = "minutes" | "seconds" | "hours" | "12hours";
+export type Period = "AM" | "PM";
+
+export function setDateByType(
+ date: Date,
+ value: string,
+ type: TimePickerType,
+ period?: Period,
+) {
+ switch (type) {
+ case "minutes":
+ return setMinutes(date, value);
+ case "seconds":
+ return setSeconds(date, value);
+ case "hours":
+ return setHours(date, value);
+ case "12hours": {
+ if (!period) return date;
+ return set12Hours(date, value, period);
+ }
+ default:
+ return date;
+ }
+}
+
+export function getDateByType(date: Date, type: TimePickerType) {
+ switch (type) {
+ case "minutes":
+ return getValidMinuteOrSecond(String(date.getMinutes()));
+ case "seconds":
+ return getValidMinuteOrSecond(String(date.getSeconds()));
+ case "hours":
+ return getValidHour(String(date.getHours()));
+ case "12hours":
+ const hours = display12HourValue(date.getHours());
+ return getValid12Hour(String(hours));
+ default:
+ return "00";
+ }
+}
+
+export function getArrowByType(
+ value: string,
+ step: number,
+ type: TimePickerType,
+) {
+ switch (type) {
+ case "minutes":
+ return getValidArrowMinuteOrSecond(value, step);
+ case "seconds":
+ return getValidArrowMinuteOrSecond(value, step);
+ case "hours":
+ return getValidArrowHour(value, step);
+ case "12hours":
+ return getValidArrow12Hour(value, step);
+ default:
+ return "00";
+ }
+}
+
+/**
+ * handles value change of 12-hour input
+ * 12:00 PM is 12:00
+ * 12:00 AM is 00:00
+ */
+export function convert12HourTo24Hour(hour: number, period: Period) {
+ if (period === "PM") {
+ if (hour <= 11) {
+ return hour + 12;
+ } else {
+ return hour;
+ }
+ } else if (period === "AM") {
+ if (hour === 12) return 0;
+ return hour;
+ }
+ return hour;
+}
+
+/**
+ * time is stored in the 24-hour form,
+ * but needs to be displayed to the user
+ * in its 12-hour representation
+ */
+export function display12HourValue(hours: number) {
+ if (hours === 0 || hours === 12) return "12";
+ if (hours >= 22) return `${hours - 12}`;
+ if (hours % 12 > 9) return `${hours}`;
+ return `0${hours % 12}`;
+}
diff --git a/packages/validators/src/index.ts b/packages/validators/src/index.ts
index 4fb967c..8bcadfa 100644
--- a/packages/validators/src/index.ts
+++ b/packages/validators/src/index.ts
@@ -6,3 +6,9 @@ export const unused = z.string().describe(
with back and frontend, you can put them in here
`,
);
+
+export const addUserSchema = z.object({
+ userId: z.string(),
+ projectId: z.string(),
+ permissions: z.array(z.string()),
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 013ff16..063d4e0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -112,6 +112,9 @@ importers:
'@xyflow/react':
specifier: ^12.0.3
version: 12.0.3(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1)
+ date-fns:
+ specifier: ^3.6.0
+ version: 3.6.0
geist:
specifier: ^1.3.0
version: 1.3.0(next@14.2.4)
@@ -124,6 +127,9 @@ importers:
react-dom:
specifier: 18.3.1
version: 18.3.1(react@18.3.1)
+ react-notion:
+ specifier: ^0.10.0
+ version: 0.10.0(react@18.3.1)
reactflow:
specifier: ^11.11.4
version: 11.11.4(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1)
@@ -432,6 +438,9 @@ importers:
'@radix-ui/react-slot':
specifier: ^1.1.0
version: 1.1.0(@types/react@18.3.3)(react@18.3.1)
+ '@radix-ui/react-switch':
+ specifier: ^1.1.0
+ version: 1.1.0(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-tabs':
specifier: ^1.1.0
version: 1.1.0(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1)
@@ -444,6 +453,9 @@ importers:
cmdk:
specifier: ^1.0.0
version: 1.0.0(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1)
+ date-fns:
+ specifier: ^3.6.0
+ version: 3.6.0
embla-carousel-react:
specifier: ^8.1.7
version: 8.1.7(react@18.3.1)
@@ -453,6 +465,9 @@ importers:
next-themes:
specifier: ^0.3.0
version: 0.3.0(react-dom@18.3.1)(react@18.3.1)
+ react-day-picker:
+ specifier: 8.10.1
+ version: 8.10.1(date-fns@3.6.0)(react@18.3.1)
react-hook-form:
specifier: ^7.51.4
version: 7.52.0(react@18.3.1)
@@ -3215,6 +3230,31 @@ packages:
react: 18.3.1
dev: false
+ /@radix-ui/react-switch@1.1.0(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1):
+ resolution: {integrity: sha512-OBzy5WAj641k0AOSpKQtreDMe+isX0MQJ1IVyF03ucdF3DunOnROVrjWs8zsXUxC3zfZ6JL9HFVCUlMghz9dJw==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+ dependencies:
+ '@radix-ui/primitive': 1.1.0
+ '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.3)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.0(@types/react@18.3.3)(react@18.3.1)
+ '@radix-ui/react-primitive': 2.0.0(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1)
+ '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.3)(react@18.3.1)
+ '@radix-ui/react-use-previous': 1.1.0(@types/react@18.3.3)(react@18.3.1)
+ '@radix-ui/react-use-size': 1.1.0(@types/react@18.3.3)(react@18.3.1)
+ '@types/react': 18.3.3
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ dev: false
+
/@radix-ui/react-tabs@1.1.0(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1):
resolution: {integrity: sha512-bZgOKB/LtZIij75FSuPzyEti/XBhJH52ExgtdVqjCIh+Nx/FW+LhnbXtbCzIi34ccyMsyOja8T0thCzoHFXNKA==}
peerDependencies:
@@ -5615,6 +5655,10 @@ packages:
is-data-view: 1.0.1
dev: false
+ /date-fns@3.6.0:
+ resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==}
+ dev: false
+
/db0@0.1.4:
resolution: {integrity: sha512-Ft6eCwONYxlwLjBXSJxw0t0RYtA5gW9mq8JfBXn9TtC0nDPlqePAhpv9v4g9aONBi6JI1OXHTKKkUYGd+BOrCA==}
peerDependencies:
@@ -8900,6 +8944,11 @@ packages:
resolution: {integrity: sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==}
dev: false
+ /prismjs@1.29.0:
+ resolution: {integrity: sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==}
+ engines: {node: '>=6'}
+ dev: false
+
/process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
dev: true
@@ -9146,6 +9195,16 @@ packages:
strip-json-comments: 2.0.1
dev: true
+ /react-day-picker@8.10.1(date-fns@3.6.0)(react@18.3.1):
+ resolution: {integrity: sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==}
+ peerDependencies:
+ date-fns: ^2.28.0 || ^3.0.0
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0
+ dependencies:
+ date-fns: 3.6.0
+ react: 18.3.1
+ dev: false
+
/react-dom@18.3.1(react@18.3.1):
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
peerDependencies:
@@ -9169,6 +9228,16 @@ packages:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
dev: false
+ /react-notion@0.10.0(react@18.3.1):
+ resolution: {integrity: sha512-i5lVAxzSs7GnTpIbKtivPQWxGZiKCO5xmCmMJyn6F91y8X1IIJqnypkK+31F2acb1l1Qd8Yv0ktuvXwz9g49NQ==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ react: '>=16'
+ dependencies:
+ prismjs: 1.29.0
+ react: 18.3.1
+ dev: false
+
/react-remove-scroll-bar@2.3.6(@types/react@18.3.3)(react@18.3.1):
resolution: {integrity: sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==}
engines: {node: '>=10'}
diff --git a/turbo.json b/turbo.json
index a9a7b85..32e4884 100644
--- a/turbo.json
+++ b/turbo.json
@@ -59,7 +59,8 @@
"AUTH_SECRET",
"PORT",
"TEST_USER_PASSWORD",
- "TEST_ADMIN_PASSWORD"
+ "TEST_ADMIN_PASSWORD",
+ "TIPTAP_EDITOR"
],
"globalPassThroughEnv": [
"NODE_ENV",