added groups

This commit is contained in:
mr-shortman 2025-04-13 22:19:54 +02:00
parent a2492fefbd
commit 3fc08a3c10
21 changed files with 635 additions and 40 deletions

View File

@ -0,0 +1,22 @@
import React from "react";
import GroupPage from "@/app/_components/group/group-page";
import { api, HydrateClient } from "@/trpc/server";
async function Page({
params,
}: {
params: Promise<{
id: string;
}>;
}) {
const { id } = await params;
void api.group.get.prefetch({ id });
return (
<HydrateClient>
<GroupPage groupId={id} />
</HydrateClient>
);
}
export default Page;

View File

@ -1,13 +1,29 @@
import CreateGroupDrawer from "@/app/_components/group/create-group-drawer";
import Header from "@/components/header";
import React from "react";
import GroupFormDrawer from "@/app/_components/group/group-form-drawer";
import Header from "@/components/header";
import Section from "@/components/section";
import { api } from "@/trpc/server";
import Link from "next/link";
import GroupCard from "@/app/_components/group/group-card";
export default function Page() {
export default async function Page() {
const groups = await api.group.getAll();
return (
<>
<Header text="Groups">
<CreateGroupDrawer />
<GroupFormDrawer />
</Header>
<Section>
<menu className="space-y-4">
{groups.map((group) => (
<li key={group.id}>
<Link href={`/group/${group.id}`}>
<GroupCard group={group} />
</Link>
</li>
))}
</menu>
</Section>
</>
);
}

View File

@ -31,10 +31,12 @@ function ExpenseCard({ expense }: { expense: Expense }) {
return (
<Drawer>
<DrawerTrigger className="w-full">
<Card className="bg-card/25 border-0 shadow-none gap-2 pb-0">
<Card className=" border-0 shadow-none gap-2 pb-0">
<CardHeader className="px-4">
<CardTitle className="flex items-center w-full">
<p className="w-full max-w-64 truncate ">{expense.description}</p>
<p className="w-full max-w-64 truncate text-start ">
{expense.description}
</p>
<span className="ml-auto">
{getAmount(Number(expense.amount))}
</span>

View File

@ -15,8 +15,8 @@ import { Input } from "@/components/ui/input";
import { useDebounce } from "use-debounce";
import { Skeleton } from "@/components/ui/skeleton";
import { toast } from "sonner";
import type { PublicUser } from "next-auth";
import FriendCard from "./friend-card";
import UserCard from "../user-card";
import type { User } from "@/server/db/schema";
const SearchResultCard = ({
addFriend,
@ -25,10 +25,10 @@ const SearchResultCard = ({
}: {
addFriend: (userId: string) => void;
pending: boolean;
user: PublicUser;
user: User;
}) => {
return (
<FriendCard user={user}>
<UserCard user={user}>
<Button
size={"sm"}
className="ml-auto"
@ -40,7 +40,7 @@ const SearchResultCard = ({
>
{pending ? "Pending" : "Add Friend"}
</Button>
</FriendCard>
</UserCard>
);
};
@ -50,11 +50,7 @@ export default function AddFriendDrawer() {
const [value, setValue] = React.useState("");
const [searchValue] = useDebounce(value, 1000);
const {
data: searchResult,
isFetching,
refetch,
} = api.friend.search.useQuery(
const { data: searchResult, isFetching } = api.friend.search.useQuery(
{
search: searchValue,
},
@ -70,6 +66,7 @@ export default function AddFriendDrawer() {
utils.friend.getAll.invalidate();
},
});
const handleAddFriend = (userId: string) => {
addFriend.mutate({
userId,

View File

@ -1,6 +1,6 @@
"use client";
import React from "react";
import FriendCard from "@/app/_components/friend/friend-card";
import UserCard from "@/app/_components/user-card";
import PendingRequestButton from "@/app/_components/friend/pending-request-button";
import Section from "@/components/section";
import { Button } from "@/components/ui/button";
@ -16,19 +16,19 @@ function FriendList() {
{pendingFriends.length ? (
<Section heading="Requests">
{pendingFriends.map(({ user, requestedBy, id }) => (
<FriendCard key={user.id} user={user}>
<UserCard key={user.id} user={user}>
<PendingRequestButton
friendshipId={id}
requestedBy={requestedBy}
/>
</FriendCard>
</UserCard>
))}
</Section>
) : null}
<Section heading={`Friends (${friends?.length ?? 0})`}>
{friends?.length ? (
friends.map(({ user }) => (
<FriendCard key={user.id} user={user}>
<UserCard key={user.id} user={user}>
<Button
size={"sm"}
className="ml-auto"
@ -37,7 +37,7 @@ function FriendList() {
>
<Link href={`/add?userId=${user.id}`}>Add Expense</Link>
</Button>
</FriendCard>
</UserCard>
))
) : (
<p className="text-muted-foreground text-sm">No friends yet</p>

View File

@ -0,0 +1,126 @@
"use client";
import React from "react";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { Button } from "@/components/ui/button";
import { api } from "@/trpc/react";
import { Input } from "@/components/ui/input";
import { useDebounce } from "use-debounce";
import { Skeleton } from "@/components/ui/skeleton";
import { toast } from "sonner";
import type { User } from "@/server/db/schema";
import UserCard from "../user-card";
const SearchResultCard = ({
addUser,
user,
}: {
addUser: (userId: string) => void;
user: User;
}) => {
return (
<UserCard user={user}>
<Button
size={"sm"}
className="ml-auto"
variant={"outline"}
onClick={() => {
addUser(user.id!);
}}
>
Add
</Button>
</UserCard>
);
};
export default function AddToGroupDrawer({
groupId,
className,
}: {
groupId: string;
className?: string;
}) {
const utils = api.useUtils();
const [open, setOpen] = React.useState(false);
const [value, setValue] = React.useState("");
const [searchValue] = useDebounce(value, 1000);
const { data: searchResult, isFetching } = api.user.search.useQuery(
{
search: searchValue,
},
{
enabled: !!searchValue,
}
);
const addFriend = api.group.addMember.useMutation({
onSuccess() {
toast.success("User added to group.");
setOpen(false);
setValue("");
utils.group.get.invalidate();
utils.group.getAll.invalidate();
},
});
const handleAddFriend = (userId: string) => {
addFriend.mutate({
groupId,
userId,
});
};
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<Button size={"sm"} className={className}>
Add People
</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Add a user to group</DrawerTitle>
</DrawerHeader>
<div className="px-4 space-y-4">
<Input
autoFocus
value={value}
onChange={(e) => setValue(e.currentTarget.value)}
placeholder="Search for a user"
/>
<ul className="space-y-2">
{isFetching ? (
<Skeleton className="h-6 w-full" />
) : searchResult?.length ? (
searchResult.map((user) => (
<li key={user.id}>
<SearchResultCard addUser={handleAddFriend} user={user} />
</li>
))
) : (
<p className="text-muted-foreground text-sm">
{searchValue.length
? "No results found"
: "Start typing to search for a user"}
</p>
)}
</ul>
</div>
<DrawerFooter>
<DrawerClose asChild>
<Button variant="outline">Cancel</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
}

View File

@ -0,0 +1,15 @@
import { Card, CardHeader, CardTitle } from "@/components/ui/card";
import type { Group } from "@/server/db/schema";
import React from "react";
function GroupCard({ group }: { group: Group }) {
return (
<Card>
<CardHeader>
<CardTitle>{group.name}</CardTitle>
</CardHeader>
</Card>
);
}
export default GroupCard;

View File

@ -10,24 +10,28 @@ import {
DrawerTrigger,
} from "@/components/ui/drawer";
import { Button } from "@/components/ui/button";
import GroupForm from "./group-form";
import type { Group } from "@/server/db/schema";
export default function CreateGroupDrawer() {
export default function GroupFormDrawer({
initialGroup,
}: {
initialGroup?: Group;
}) {
const [open, setOpen] = React.useState(false);
const existing = Boolean(initialGroup?.id?.length);
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<Button size={"sm"}>Create Group</Button>
<Button size={"sm"}>
{existing ? "Update" : "Create a new"} Group
</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Create Group</DrawerTitle>
<DrawerTitle>{existing ? "Update" : "Create"} Group</DrawerTitle>
<GroupForm initialGroup={initialGroup} />
</DrawerHeader>
<DrawerFooter>
<DrawerClose asChild>
<Button variant="outline">Cancel</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
);

View File

@ -0,0 +1,100 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@/components/ui/form";
const formSchema = z.object({
username: z.string().min(2, {
message: "Username must be at least 2 characters.",
}),
});
import React from "react";
import { Input } from "@/components/ui/input";
import { groupSchema } from "@/lib/validations/group";
import { api } from "@/trpc/react";
import { useRouter } from "next/navigation";
import type { Group } from "@/server/db/schema";
import { Textarea } from "@/components/ui/textarea";
function GroupForm({ initialGroup }: { initialGroup?: Group }) {
const router = useRouter();
const utils = api.useUtils();
const createGroup = api.group.create.useMutation({
onSuccess(group) {
router.push(`/group/${group?.id}`);
utils.invalidate();
},
});
const updateGroup = api.group.update.useMutation({
onSuccess() {
utils.group.get.invalidate({ id: initialGroup?.id });
},
});
const form = useForm<z.infer<typeof groupSchema>>({
resolver: zodResolver(groupSchema),
defaultValues: {
name: initialGroup?.name ?? "",
description: initialGroup?.description ?? "",
},
});
const existing = Boolean(initialGroup?.id?.length);
function onSubmit(group: z.infer<typeof groupSchema>) {
if (existing) {
updateGroup.mutate({ group, groupId: initialGroup?.id! });
} else {
createGroup.mutate({ group });
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
autoFocus={!existing}
placeholder="What is your groups name?"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormControl>
<Textarea placeholder="What is your groups name?" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full">
{existing ? "Update" : "Create"} Group
</Button>
</form>
</Form>
);
}
export default GroupForm;

View File

@ -0,0 +1,76 @@
"use client";
import React from "react";
import { api } from "@/trpc/react";
import AddToGroupDrawer from "@/app/_components/group/add-to-group-drawer";
import RemoveFromGroupButton from "@/app/_components/group/remove-from-group-button";
import UserCard from "@/app/_components/user-card";
import Header from "@/components/header";
import Section from "@/components/section";
import GroupFormDrawer from "./group-form-drawer";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Icons } from "@/components/icons";
function GroupPage({ groupId }: { groupId: string }) {
const [group] = api.group.get.useSuspenseQuery({ id: groupId });
if (!group) return <GroupFormDrawer />;
return (
<>
<Header text={group.name}>
<GroupFormDrawer initialGroup={group} />
</Header>
<Section>
<p className="text-muted-foreground text-sm">{group.description}</p>
<div className="flex gap-4 w-full items-center">
<h4 className="text-lg font-semibold">
Members ({group.members.length})
</h4>
<AddToGroupDrawer groupId={group.id} className="ml-auto" />
</div>
<ul className="space-y-2">
{group.members.map((member) => (
<li key={member.id}>
<UserCard className="border bg-background" user={member.user!}>
<span className="text-xs text-muted-foreground">
{member.role}
</span>
<RemoveFromGroupButton
userId={member.user.id}
groupId={group.id}
/>
</UserCard>
</li>
))}
</ul>
<div className="flex gap-4 w-full items-center">
<h4 className="text-lg font-semibold">Expenses</h4>
<Button asChild size={"sm"} className="ml-auto">
<Link href={`/add?groupId=${group.id}`}>
<Icons.wallet />
<span>Add Expense</span>
</Link>
</Button>
</div>
<ul className="space-y-2">
{/* {group.members.map((member) => (
<li key={member.id}>
<UserCard className="border bg-background" user={member.user!}>
<span className="text-xs text-muted-foreground">
{member.role}
</span>
<RemoveFromGroupButton
userId={member.user.id}
groupId={group.id}
/>
</UserCard>
</li>
))} */}
</ul>
</Section>
</>
);
}
export default GroupPage;

View File

@ -0,0 +1,45 @@
"use client";
import React from "react";
import { Icons } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { api } from "@/trpc/react";
import { toast } from "sonner";
function RemoveFromGroupButton({
groupId,
userId,
}: {
groupId: string;
userId: string;
}) {
const utils = api.useUtils();
const removeMember = api.group.removeMember.useMutation({
onSuccess() {
toast.success("User removed from group.");
utils.invalidate();
},
});
const handleClick = () => {
removeMember.mutate({
groupId,
userId,
});
};
return (
<Button
variant={"destructive"}
className="ml-auto text-sm p-1 px-2 size-max"
onClick={handleClick}
disabled={removeMember.isPending}
>
{!removeMember.isPending ? (
<Icons.trash className="size-4" />
) : (
<Icons.loading className="size-4" />
)}
<span>Remove</span>
</Button>
);
}
export default RemoveFromGroupButton;

View File

@ -1,21 +1,21 @@
import React from "react";
import Avatar from "@/components/avatar";
import { cn } from "@/lib/utils";
import type { PublicUser } from "next-auth";
import React from "react";
import type { User } from "@/server/db/schema";
export default function FriendCard({
export default function UserCard({
user: { name, image },
children,
className,
}: {
user: PublicUser;
user: User;
children?: React.ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"flex items-center gap-2 p-2 hover:bg-accent/25 rounded-md",
"flex items-center gap-2 p-2 hover:bg-accent/25 rounded-xl",
className
)}
>

View File

@ -13,7 +13,8 @@ type IconName =
| "trash"
| "user"
| "addSquare"
| "check";
| "check"
| "loading";
export const Icons: Record<IconName, IconComponent> = {
logo(props) {
@ -301,4 +302,64 @@ export const Icons: Record<IconName, IconComponent> = {
</svg>
);
},
loading(props) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" {...props}>
<circle
fill="currentColor"
stroke="currentColor"
strokeWidth="15"
r="15"
cx="40"
cy="100"
>
<animate
attributeName="opacity"
calcMode="spline"
dur="2"
values="1;0;1;"
keySplines=".5 0 .5 1;.5 0 .5 1"
repeatCount="indefinite"
begin="-.4"
></animate>
</circle>
<circle
fill="currentColor"
stroke="currentColor"
strokeWidth="15"
r="15"
cx="100"
cy="100"
>
<animate
attributeName="opacity"
calcMode="spline"
dur="2"
values="1;0;1;"
keySplines=".5 0 .5 1;.5 0 .5 1"
repeatCount="indefinite"
begin="-.2"
></animate>
</circle>
<circle
fill="currentColor"
stroke="currentColor"
strokeWidth="15"
r="15"
cx="160"
cy="100"
>
<animate
attributeName="opacity"
calcMode="spline"
dur="2"
values="1;0;1;"
keySplines=".5 0 .5 1;.5 0 .5 1"
repeatCount="indefinite"
begin="0"
></animate>
</circle>
</svg>
);
},
};

View File

@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
"bg-card/25 text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}

2
src/index.d.ts vendored
View File

@ -5,3 +5,5 @@ type NavLink = {
icon?: string;
big?: boolean;
};
type GroupRoles = "admin" | "member";

View File

@ -1,7 +1,10 @@
import { z } from "zod";
export const groupSchema = z.object({
name: z.string().optional(),
name: z
.string()
.min(2, { message: "Group name must be at least 2 characters." }),
description: z.string().optional(),
});
export const groupMemberSchema = z.object({

View File

@ -1,5 +1,10 @@
import { createCallerFactory, createTRPCRouter } from "@/server/api/trpc";
import { expenseRouter, friendRouter, userRouter } from "./routers";
import {
expenseRouter,
friendRouter,
groupRouter,
userRouter,
} from "./routers";
/**
* This is the primary router for your server.
@ -10,6 +15,7 @@ export const appRouter = createTRPCRouter({
expense: expenseRouter,
friend: friendRouter,
user: userRouter,
group: groupRouter,
});
// export type definition of API

View File

@ -0,0 +1,89 @@
import { groupSchema } from "@/lib/validations/group";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { groupMembers, groups } from "@/server/db/schema";
import { z } from "zod";
import { and, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
export const groupRouter = createTRPCRouter({
get: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const group = await ctx.db.query.groups.findFirst({
where: eq(groups.id, input.id),
with: {
members: {
with: {
user: true,
},
},
},
});
if (group?.members?.find((member) => member.userId === ctx.auth.userId))
return group;
return undefined;
}),
getAll: protectedProcedure.query(async ({ ctx }) => {
const membersShips = await ctx.db.query.groupMembers.findMany({
where: eq(groupMembers.userId, ctx.auth.userId),
with: {
group: true,
},
});
return membersShips?.map(({ group }) => group!) ?? [];
}),
// mutations
create: protectedProcedure
.input(z.object({ group: groupSchema }))
.mutation(async ({ ctx, input }) => {
const [group] = await ctx.db
.insert(groups)
.values({
...input.group,
createdById: ctx.auth.userId,
})
.returning({ id: groups.id });
if (!group?.id?.length) throw new Error("Group cant get created");
await ctx.db.insert(groupMembers).values({
groupId: group.id,
userId: ctx.auth.userId,
});
return group;
}),
update: protectedProcedure
.input(z.object({ group: groupSchema, groupId: z.string() }))
.mutation(async ({ ctx, input }) => {
console.log("Check if groupmember is admin!!");
return await ctx.db
.update(groups)
.set(input.group)
.where(eq(groups.id, input.groupId))
.returning({ id: groups.id });
}),
addMember: protectedProcedure
.input(z.object({ groupId: z.string(), userId: z.string() }))
.mutation(async ({ ctx, input }) => {
await ctx.db.insert(groupMembers).values({
groupId: input.groupId,
userId: input.userId,
});
revalidatePath(`/group/${input.groupId}`);
}),
removeMember: protectedProcedure
.input(z.object({ groupId: z.string(), userId: z.string() }))
.mutation(async ({ ctx, input }) => {
await ctx.db
.delete(groupMembers)
.where(
and(
eq(groupMembers.groupId, input.groupId),
eq(groupMembers.userId, input.userId)
)
);
revalidatePath(`/group/${input.groupId}`);
}),
});

View File

@ -1,3 +1,4 @@
export { friendRouter } from "./friend";
export { expenseRouter } from "./expense";
export { userRouter } from "./user";
export { groupRouter } from "./group";

View File

@ -1,6 +1,7 @@
import { eq } from "drizzle-orm";
import { and, eq, ilike, inArray, not } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { users } from "@/server/db/schema";
import { z } from "zod";
export const userRouter = createTRPCRouter({
getSessionUser: protectedProcedure.query(async ({ ctx }) => {
@ -9,4 +10,27 @@ export const userRouter = createTRPCRouter({
});
return user;
}),
search: protectedProcedure
.input(
z.object({
search: z.string(),
excludedIds: z.array(z.string()).optional(),
})
)
.query(async ({ ctx, input }) => {
const userId = ctx.auth.userId;
const searchResult = await ctx.db.query.users.findMany({
where: and(
not(eq(users.id, userId)),
ilike(users.name, `%${input.search}%`),
input.excludedIds?.length
? not(inArray(users.id, input.excludedIds))
: undefined
),
});
console.log("search results", searchResult);
return searchResult;
}),
});

View File

@ -151,6 +151,7 @@ export const groups = createTable(
(d) => ({
id: d.varchar().primaryKey().$defaultFn(createId),
name: d.varchar({ length: 255 }).notNull(),
description: d.text(),
createdById: d
.varchar()
.notNull()
@ -189,6 +190,11 @@ export const groupMembers = createTable(
.varchar()
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
role: d
.varchar({ length: 10 })
.notNull()
.$type<GroupRoles>()
.default("admin"),
joinedAt: d
.timestamp({ withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)