added groups
This commit is contained in:
parent
a2492fefbd
commit
3fc08a3c10
22
src/app/(router)/group/[id]/page.tsx
Normal file
22
src/app/(router)/group/[id]/page.tsx
Normal 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;
|
||||||
@ -1,13 +1,29 @@
|
|||||||
import CreateGroupDrawer from "@/app/_components/group/create-group-drawer";
|
|
||||||
import Header from "@/components/header";
|
|
||||||
import React from "react";
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header text="Groups">
|
<Header text="Groups">
|
||||||
<CreateGroupDrawer />
|
<GroupFormDrawer />
|
||||||
</Header>
|
</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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,10 +31,12 @@ function ExpenseCard({ expense }: { expense: Expense }) {
|
|||||||
return (
|
return (
|
||||||
<Drawer>
|
<Drawer>
|
||||||
<DrawerTrigger className="w-full">
|
<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">
|
<CardHeader className="px-4">
|
||||||
<CardTitle className="flex items-center w-full">
|
<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">
|
<span className="ml-auto">
|
||||||
{getAmount(Number(expense.amount))}
|
{getAmount(Number(expense.amount))}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -15,8 +15,8 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { useDebounce } from "use-debounce";
|
import { useDebounce } from "use-debounce";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { PublicUser } from "next-auth";
|
import UserCard from "../user-card";
|
||||||
import FriendCard from "./friend-card";
|
import type { User } from "@/server/db/schema";
|
||||||
|
|
||||||
const SearchResultCard = ({
|
const SearchResultCard = ({
|
||||||
addFriend,
|
addFriend,
|
||||||
@ -25,10 +25,10 @@ const SearchResultCard = ({
|
|||||||
}: {
|
}: {
|
||||||
addFriend: (userId: string) => void;
|
addFriend: (userId: string) => void;
|
||||||
pending: boolean;
|
pending: boolean;
|
||||||
user: PublicUser;
|
user: User;
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<FriendCard user={user}>
|
<UserCard user={user}>
|
||||||
<Button
|
<Button
|
||||||
size={"sm"}
|
size={"sm"}
|
||||||
className="ml-auto"
|
className="ml-auto"
|
||||||
@ -40,7 +40,7 @@ const SearchResultCard = ({
|
|||||||
>
|
>
|
||||||
{pending ? "Pending" : "Add Friend"}
|
{pending ? "Pending" : "Add Friend"}
|
||||||
</Button>
|
</Button>
|
||||||
</FriendCard>
|
</UserCard>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -50,11 +50,7 @@ export default function AddFriendDrawer() {
|
|||||||
const [value, setValue] = React.useState("");
|
const [value, setValue] = React.useState("");
|
||||||
const [searchValue] = useDebounce(value, 1000);
|
const [searchValue] = useDebounce(value, 1000);
|
||||||
|
|
||||||
const {
|
const { data: searchResult, isFetching } = api.friend.search.useQuery(
|
||||||
data: searchResult,
|
|
||||||
isFetching,
|
|
||||||
refetch,
|
|
||||||
} = api.friend.search.useQuery(
|
|
||||||
{
|
{
|
||||||
search: searchValue,
|
search: searchValue,
|
||||||
},
|
},
|
||||||
@ -70,6 +66,7 @@ export default function AddFriendDrawer() {
|
|||||||
utils.friend.getAll.invalidate();
|
utils.friend.getAll.invalidate();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleAddFriend = (userId: string) => {
|
const handleAddFriend = (userId: string) => {
|
||||||
addFriend.mutate({
|
addFriend.mutate({
|
||||||
userId,
|
userId,
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import React from "react";
|
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 PendingRequestButton from "@/app/_components/friend/pending-request-button";
|
||||||
import Section from "@/components/section";
|
import Section from "@/components/section";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@ -16,19 +16,19 @@ function FriendList() {
|
|||||||
{pendingFriends.length ? (
|
{pendingFriends.length ? (
|
||||||
<Section heading="Requests">
|
<Section heading="Requests">
|
||||||
{pendingFriends.map(({ user, requestedBy, id }) => (
|
{pendingFriends.map(({ user, requestedBy, id }) => (
|
||||||
<FriendCard key={user.id} user={user}>
|
<UserCard key={user.id} user={user}>
|
||||||
<PendingRequestButton
|
<PendingRequestButton
|
||||||
friendshipId={id}
|
friendshipId={id}
|
||||||
requestedBy={requestedBy}
|
requestedBy={requestedBy}
|
||||||
/>
|
/>
|
||||||
</FriendCard>
|
</UserCard>
|
||||||
))}
|
))}
|
||||||
</Section>
|
</Section>
|
||||||
) : null}
|
) : null}
|
||||||
<Section heading={`Friends (${friends?.length ?? 0})`}>
|
<Section heading={`Friends (${friends?.length ?? 0})`}>
|
||||||
{friends?.length ? (
|
{friends?.length ? (
|
||||||
friends.map(({ user }) => (
|
friends.map(({ user }) => (
|
||||||
<FriendCard key={user.id} user={user}>
|
<UserCard key={user.id} user={user}>
|
||||||
<Button
|
<Button
|
||||||
size={"sm"}
|
size={"sm"}
|
||||||
className="ml-auto"
|
className="ml-auto"
|
||||||
@ -37,7 +37,7 @@ function FriendList() {
|
|||||||
>
|
>
|
||||||
<Link href={`/add?userId=${user.id}`}>Add Expense</Link>
|
<Link href={`/add?userId=${user.id}`}>Add Expense</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</FriendCard>
|
</UserCard>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<p className="text-muted-foreground text-sm">No friends yet</p>
|
<p className="text-muted-foreground text-sm">No friends yet</p>
|
||||||
|
|||||||
126
src/app/_components/group/add-to-group-drawer.tsx
Normal file
126
src/app/_components/group/add-to-group-drawer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
src/app/_components/group/group-card.tsx
Normal file
15
src/app/_components/group/group-card.tsx
Normal 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;
|
||||||
@ -10,24 +10,28 @@ import {
|
|||||||
DrawerTrigger,
|
DrawerTrigger,
|
||||||
} from "@/components/ui/drawer";
|
} from "@/components/ui/drawer";
|
||||||
import { Button } from "@/components/ui/button";
|
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 [open, setOpen] = React.useState(false);
|
||||||
|
const existing = Boolean(initialGroup?.id?.length);
|
||||||
return (
|
return (
|
||||||
<Drawer open={open} onOpenChange={setOpen}>
|
<Drawer open={open} onOpenChange={setOpen}>
|
||||||
<DrawerTrigger asChild>
|
<DrawerTrigger asChild>
|
||||||
<Button size={"sm"}>Create Group</Button>
|
<Button size={"sm"}>
|
||||||
|
{existing ? "Update" : "Create a new"} Group
|
||||||
|
</Button>
|
||||||
</DrawerTrigger>
|
</DrawerTrigger>
|
||||||
<DrawerContent>
|
<DrawerContent>
|
||||||
<DrawerHeader>
|
<DrawerHeader>
|
||||||
<DrawerTitle>Create Group</DrawerTitle>
|
<DrawerTitle>{existing ? "Update" : "Create"} Group</DrawerTitle>
|
||||||
|
<GroupForm initialGroup={initialGroup} />
|
||||||
</DrawerHeader>
|
</DrawerHeader>
|
||||||
|
|
||||||
<DrawerFooter>
|
|
||||||
<DrawerClose asChild>
|
|
||||||
<Button variant="outline">Cancel</Button>
|
|
||||||
</DrawerClose>
|
|
||||||
</DrawerFooter>
|
|
||||||
</DrawerContent>
|
</DrawerContent>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
);
|
);
|
||||||
100
src/app/_components/group/group-form.tsx
Normal file
100
src/app/_components/group/group-form.tsx
Normal 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;
|
||||||
76
src/app/_components/group/group-page.tsx
Normal file
76
src/app/_components/group/group-page.tsx
Normal 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;
|
||||||
45
src/app/_components/group/remove-from-group-button.tsx
Normal file
45
src/app/_components/group/remove-from-group-button.tsx
Normal 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;
|
||||||
@ -1,21 +1,21 @@
|
|||||||
|
import React from "react";
|
||||||
import Avatar from "@/components/avatar";
|
import Avatar from "@/components/avatar";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { PublicUser } from "next-auth";
|
import type { User } from "@/server/db/schema";
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
export default function FriendCard({
|
export default function UserCard({
|
||||||
user: { name, image },
|
user: { name, image },
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
user: PublicUser;
|
user: User;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@ -13,7 +13,8 @@ type IconName =
|
|||||||
| "trash"
|
| "trash"
|
||||||
| "user"
|
| "user"
|
||||||
| "addSquare"
|
| "addSquare"
|
||||||
| "check";
|
| "check"
|
||||||
|
| "loading";
|
||||||
|
|
||||||
export const Icons: Record<IconName, IconComponent> = {
|
export const Icons: Record<IconName, IconComponent> = {
|
||||||
logo(props) {
|
logo(props) {
|
||||||
@ -301,4 +302,64 @@ export const Icons: Record<IconName, IconComponent> = {
|
|||||||
</svg>
|
</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>
|
||||||
|
);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
<div
|
<div
|
||||||
data-slot="card"
|
data-slot="card"
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
2
src/index.d.ts
vendored
2
src/index.d.ts
vendored
@ -5,3 +5,5 @@ type NavLink = {
|
|||||||
icon?: string;
|
icon?: string;
|
||||||
big?: boolean;
|
big?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type GroupRoles = "admin" | "member";
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const groupSchema = z.object({
|
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({
|
export const groupMemberSchema = z.object({
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
import { createCallerFactory, createTRPCRouter } from "@/server/api/trpc";
|
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.
|
* This is the primary router for your server.
|
||||||
@ -10,6 +15,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
expense: expenseRouter,
|
expense: expenseRouter,
|
||||||
friend: friendRouter,
|
friend: friendRouter,
|
||||||
user: userRouter,
|
user: userRouter,
|
||||||
|
group: groupRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
|||||||
89
src/server/api/routers/group.ts
Normal file
89
src/server/api/routers/group.ts
Normal 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}`);
|
||||||
|
}),
|
||||||
|
});
|
||||||
@ -1,3 +1,4 @@
|
|||||||
export { friendRouter } from "./friend";
|
export { friendRouter } from "./friend";
|
||||||
export { expenseRouter } from "./expense";
|
export { expenseRouter } from "./expense";
|
||||||
export { userRouter } from "./user";
|
export { userRouter } from "./user";
|
||||||
|
export { groupRouter } from "./group";
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { and, eq, ilike, inArray, not } from "drizzle-orm";
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
import { users } from "@/server/db/schema";
|
import { users } from "@/server/db/schema";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
export const userRouter = createTRPCRouter({
|
export const userRouter = createTRPCRouter({
|
||||||
getSessionUser: protectedProcedure.query(async ({ ctx }) => {
|
getSessionUser: protectedProcedure.query(async ({ ctx }) => {
|
||||||
@ -9,4 +10,27 @@ export const userRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
return user;
|
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;
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -151,6 +151,7 @@ export const groups = createTable(
|
|||||||
(d) => ({
|
(d) => ({
|
||||||
id: d.varchar().primaryKey().$defaultFn(createId),
|
id: d.varchar().primaryKey().$defaultFn(createId),
|
||||||
name: d.varchar({ length: 255 }).notNull(),
|
name: d.varchar({ length: 255 }).notNull(),
|
||||||
|
description: d.text(),
|
||||||
createdById: d
|
createdById: d
|
||||||
.varchar()
|
.varchar()
|
||||||
.notNull()
|
.notNull()
|
||||||
@ -189,6 +190,11 @@ export const groupMembers = createTable(
|
|||||||
.varchar()
|
.varchar()
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id, { onDelete: "cascade" }),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
role: d
|
||||||
|
.varchar({ length: 10 })
|
||||||
|
.notNull()
|
||||||
|
.$type<GroupRoles>()
|
||||||
|
.default("admin"),
|
||||||
joinedAt: d
|
joinedAt: d
|
||||||
.timestamp({ withTimezone: true })
|
.timestamp({ withTimezone: true })
|
||||||
.default(sql`CURRENT_TIMESTAMP`)
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user