diff options
| author | Brandon Eng <bce25@cornell.edu> | 2024-01-16 22:41:46 +0700 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2024-01-16 10:41:46 -0500 |
| commit | 36cc4f1ef745a027540050ad668db3bcdc08d4b2 (patch) | |
| tree | 73d45c30b1e17e92b676701ba9ee3f6fb1071870 /src | |
| parent | 1141501edb6611b7ac147b6ceefb963ba1f83606 (diff) | |
Ability to archive groups when they’re settled up (#45)
* Settled up icon on group card
* remove logs
* archived groups
* remove settled up
* remove more settled up
* recent-group-list-card
* sortGroups
* archiveGroup
* unarchiveGroup
* clean up
* more clean up
* Prettier, fix TS errors, add titles
---------
Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx | 8 | ||||
| -rw-r--r-- | src/app/groups/[groupId]/expenses/create/page.tsx | 10 | ||||
| -rw-r--r-- | src/app/groups/page.tsx | 13 | ||||
| -rw-r--r-- | src/app/groups/recent-group-list-card.tsx | 185 | ||||
| -rw-r--r-- | src/app/groups/recent-group-list.tsx | 263 | ||||
| -rw-r--r-- | src/app/groups/recent-groups-helpers.ts | 27 | ||||
| -rw-r--r-- | src/lib/api.ts | 6 |
7 files changed, 336 insertions, 176 deletions
diff --git a/src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx b/src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx index bb32da0..984d30f 100644 --- a/src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx +++ b/src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx @@ -1,5 +1,11 @@ import { ExpenseForm } from '@/components/expense-form' -import { deleteExpense, getExpense, getCategories, getGroup, updateExpense } from '@/lib/api' +import { + deleteExpense, + getCategories, + getExpense, + getGroup, + updateExpense, +} from '@/lib/api' import { expenseFormSchema } from '@/lib/schemas' import { Metadata } from 'next' import { notFound, redirect } from 'next/navigation' diff --git a/src/app/groups/[groupId]/expenses/create/page.tsx b/src/app/groups/[groupId]/expenses/create/page.tsx index 405db26..adbdff9 100644 --- a/src/app/groups/[groupId]/expenses/create/page.tsx +++ b/src/app/groups/[groupId]/expenses/create/page.tsx @@ -1,5 +1,5 @@ import { ExpenseForm } from '@/components/expense-form' -import { createExpense, getGroup, getCategories } from '@/lib/api' +import { createExpense, getCategories, getGroup } from '@/lib/api' import { expenseFormSchema } from '@/lib/schemas' import { Metadata } from 'next' import { notFound, redirect } from 'next/navigation' @@ -24,5 +24,11 @@ export default async function ExpensePage({ redirect(`/groups/${groupId}`) } - return <ExpenseForm group={group} categories={categories} onSubmit={createExpenseAction} /> + return ( + <ExpenseForm + group={group} + categories={categories} + onSubmit={createExpenseAction} + /> + ) } diff --git a/src/app/groups/page.tsx b/src/app/groups/page.tsx index 8aeeb59..967c6b7 100644 --- a/src/app/groups/page.tsx +++ b/src/app/groups/page.tsx @@ -11,18 +11,19 @@ export const metadata: Metadata = { export default async function GroupsPage() { return ( <> - <div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 items-start"> + <div className="flex justify-between items-center gap-4"> <h1 className="font-bold text-2xl"> - <Link href="/groups">Recently visited groups</Link> + <Link href="/groups">My groups</Link> </h1> - <Button asChild> + <Button asChild size="icon"> <Link href="/groups/create"> - <Plus className="w-4 h-4 mr-2" /> - Create group + <Plus className="w-4 h-4" /> </Link> </Button> </div> - <RecentGroupList /> + <div> + <RecentGroupList /> + </div> </> ) } diff --git a/src/app/groups/recent-group-list-card.tsx b/src/app/groups/recent-group-list-card.tsx new file mode 100644 index 0000000..7809566 --- /dev/null +++ b/src/app/groups/recent-group-list-card.tsx @@ -0,0 +1,185 @@ +'use client' +import { RecentGroupsState } from '@/app/groups/recent-group-list' +import { + RecentGroup, + archiveGroup, + deleteRecentGroup, + getArchivedGroups, + getStarredGroups, + saveRecentGroup, + starGroup, + unarchiveGroup, + unstarGroup, +} from '@/app/groups/recent-groups-helpers' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { Skeleton } from '@/components/ui/skeleton' +import { ToastAction } from '@/components/ui/toast' +import { useToast } from '@/components/ui/use-toast' +import { StarFilledIcon } from '@radix-ui/react-icons' +import { Calendar, MoreHorizontal, Star, Users } from 'lucide-react' +import Link from 'next/link' +import { useRouter } from 'next/navigation' +import { SetStateAction } from 'react' + +export function RecentGroupListCard({ + group, + state, + setState, +}: { + group: RecentGroup + state: RecentGroupsState + setState: (state: SetStateAction<RecentGroupsState>) => void +}) { + const router = useRouter() + const toast = useToast() + + const details = + state.status === 'complete' + ? state.groupsDetails.find((d) => d.id === group.id) + : null + + if (state.status === 'pending') return null + + const refreshGroupsFromStorage = () => + setState({ + ...state, + starredGroups: getStarredGroups(), + archivedGroups: getArchivedGroups(), + }) + + const isStarred = state.starredGroups.includes(group.id) + const isArchived = state.archivedGroups.includes(group.id) + + return ( + <li key={group.id}> + <Button variant="outline" className="h-fit w-full py-3" asChild> + <div + className="text-base" + onClick={() => router.push(`/groups/${group.id}`)} + > + <div className="w-full flex flex-col gap-1"> + <div className="text-base flex gap-2 justify-between"> + <Link + href={`/groups/${group.id}`} + className="flex-1 overflow-hidden text-ellipsis" + > + {group.name} + </Link> + <span className="flex-shrink-0"> + <Button + size="icon" + variant="ghost" + className="-my-3 -ml-3 -mr-1.5" + onClick={(event) => { + event.stopPropagation() + if (isStarred) { + unstarGroup(group.id) + } else { + starGroup(group.id) + unarchiveGroup(group.id) + } + refreshGroupsFromStorage() + }} + > + {isStarred ? ( + <StarFilledIcon className="w-4 h-4 text-orange-400" /> + ) : ( + <Star className="w-4 h-4 text-muted-foreground" /> + )} + </Button> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + size="icon" + variant="ghost" + className="-my-3 -mr-3 -ml-1.5" + > + <MoreHorizontal className="w-4 h-4" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem + className="text-destructive" + onClick={(event) => { + event.stopPropagation() + deleteRecentGroup(group) + setState({ + ...state, + groups: state.groups.filter((g) => g.id !== group.id), + }) + toast.toast({ + title: 'Group has been removed', + description: + 'The group was removed from your recent groups list.', + action: ( + <ToastAction + altText="Undo group removal" + onClick={() => { + saveRecentGroup(group) + setState({ + ...state, + groups: state.groups, + }) + }} + > + Undo + </ToastAction> + ), + }) + }} + > + Remove from recent groups + </DropdownMenuItem> + <DropdownMenuItem + onClick={(event) => { + event.stopPropagation() + if (isArchived) { + unarchiveGroup(group.id) + } else { + archiveGroup(group.id) + unstarGroup(group.id) + } + refreshGroupsFromStorage() + }} + > + {isArchived ? <>Unarchive group</> : <>Archive group</>} + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + </span> + </div> + <div className="text-muted-foreground font-normal text-xs"> + {details ? ( + <div className="w-full flex items-center justify-between"> + <div className="flex items-center"> + <Users className="w-3 h-3 inline mr-1" /> + <span>{details._count.participants}</span> + </div> + <div className="flex items-center"> + <Calendar className="w-3 h-3 inline mx-1" /> + <span> + {new Date(details.createdAt).toLocaleDateString('en-US', { + dateStyle: 'medium', + })} + </span> + </div> + </div> + ) : ( + <div className="flex justify-between"> + <Skeleton className="h-4 w-6 rounded-full" /> + <Skeleton className="h-4 w-24 rounded-full" /> + </div> + )} + </div> + </div> + </div> + </Button> + </li> + ) +} diff --git a/src/app/groups/recent-group-list.tsx b/src/app/groups/recent-group-list.tsx index 92a8067..5f41fdb 100644 --- a/src/app/groups/recent-group-list.tsx +++ b/src/app/groups/recent-group-list.tsx @@ -1,73 +1,80 @@ 'use client' import { getGroupsAction } from '@/app/groups/actions' import { - deleteRecentGroup, + RecentGroups, + getArchivedGroups, getRecentGroups, getStarredGroups, - saveRecentGroup, - starGroup, - unstarGroup, } from '@/app/groups/recent-groups-helpers' import { Button } from '@/components/ui/button' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu' -import { Skeleton } from '@/components/ui/skeleton' -import { ToastAction } from '@/components/ui/toast' -import { useToast } from '@/components/ui/use-toast' import { getGroups } from '@/lib/api' -import { StarFilledIcon } from '@radix-ui/react-icons' -import { Calendar, Loader2, MoreHorizontal, Star, Users } from 'lucide-react' +import { Loader2 } from 'lucide-react' import Link from 'next/link' -import { useRouter } from 'next/navigation' -import { useEffect, useState } from 'react' -import { z } from 'zod' - -const recentGroupsSchema = z.array( - z.object({ - id: z.string().min(1), - name: z.string(), - }), -) -type RecentGroups = z.infer<typeof recentGroupsSchema> +import { SetStateAction, useEffect, useState } from 'react' +import { RecentGroupListCard } from './recent-group-list-card' -type State = +export type RecentGroupsState = | { status: 'pending' } - | { status: 'partial'; groups: RecentGroups; starredGroups: string[] } + | { + status: 'partial' + groups: RecentGroups + starredGroups: string[] + archivedGroups: string[] + } | { status: 'complete' groups: RecentGroups groupsDetails: Awaited<ReturnType<typeof getGroups>> starredGroups: string[] + archivedGroups: string[] } -type Props = { - getGroupsAction: (groupIds: string[]) => ReturnType<typeof getGroups> +function sortGroups( + state: RecentGroupsState & { status: 'complete' | 'partial' }, +) { + const starredGroupInfo = [] + const groupInfo = [] + const archivedGroupInfo = [] + for (const group of state.groups) { + if (state.starredGroups.includes(group.id)) { + starredGroupInfo.push(group) + } else if (state.archivedGroups.includes(group.id)) { + archivedGroupInfo.push(group) + } else { + groupInfo.push(group) + } + } + return { + starredGroupInfo, + groupInfo, + archivedGroupInfo, + } } export function RecentGroupList() { - const [state, setState] = useState<State>({ status: 'pending' }) + const [state, setState] = useState<RecentGroupsState>({ status: 'pending' }) useEffect(() => { const groupsInStorage = getRecentGroups() const starredGroups = getStarredGroups() - setState({ status: 'partial', groups: groupsInStorage, starredGroups }) + const archivedGroups = getArchivedGroups() + setState({ + status: 'partial', + groups: groupsInStorage, + starredGroups, + archivedGroups, + }) getGroupsAction(groupsInStorage.map((g) => g.id)).then((groupsDetails) => { setState({ status: 'complete', groups: groupsInStorage, groupsDetails, starredGroups, + archivedGroups, }) }) }, []) - const router = useRouter() - const toast = useToast() - if (state.status === 'pending') { return ( <p> @@ -91,139 +98,63 @@ export function RecentGroupList() { ) } + const { starredGroupInfo, groupInfo, archivedGroupInfo } = sortGroups(state) + + return ( + <> + {starredGroupInfo.length > 0 && ( + <> + <h2 className="mb-2">Starred groups</h2> + <GroupList + groups={starredGroupInfo} + state={state} + setState={setState} + /> + </> + )} + + {groupInfo.length > 0 && ( + <> + <h2 className="mt-6 mb-2">Recent groups</h2> + <GroupList groups={groupInfo} state={state} setState={setState} /> + </> + )} + + {archivedGroupInfo.length > 0 && ( + <> + <h2 className="mt-6 mb-2 opacity-50">Archived groups</h2> + <div className="opacity-50"> + <GroupList + groups={archivedGroupInfo} + state={state} + setState={setState} + /> + </div> + </> + )} + </> + ) +} + +function GroupList({ + groups, + state, + setState, +}: { + groups: RecentGroups + state: RecentGroupsState + setState: (state: SetStateAction<RecentGroupsState>) => void +}) { return ( <ul className="grid grid-cols-1 gap-2 sm:grid-cols-3"> - {state.groups - .toSorted( - (first, second) => - (state.starredGroups.includes(second.id) ? 2 : 0) - - (state.starredGroups.includes(first.id) ? 1 : 0), - ) - .map((group) => { - const details = - state.status === 'complete' - ? state.groupsDetails.find((d) => d.id === group.id) - : null - return ( - <li key={group.id}> - <Button variant="outline" className="h-fit w-full py-3" asChild> - <div - className="text-base" - onClick={() => router.push(`/groups/${group.id}`)} - > - <div className="w-full flex flex-col gap-1"> - <div className="text-base flex gap-2 justify-between"> - <Link - href={`/groups/${group.id}`} - className="flex-1 overflow-hidden text-ellipsis" - > - {group.name} - </Link> - <span className="flex-shrink-0"> - <Button - size="icon" - variant="ghost" - className="-my-3 -ml-3 -mr-1.5" - onClick={(event) => { - event.stopPropagation() - if (state.starredGroups.includes(group.id)) { - unstarGroup(group.id) - } else { - starGroup(group.id) - } - setState({ - ...state, - starredGroups: getStarredGroups(), - }) - }} - > - {state.starredGroups.includes(group.id) ? ( - <StarFilledIcon className="w-4 h-4 text-orange-400" /> - ) : ( - <Star className="w-4 h-4 text-muted-foreground" /> - )} - </Button> - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - size="icon" - variant="ghost" - className="-my-3 -mr-3 -ml-1.5" - > - <MoreHorizontal className="w-4 h-4" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end"> - <DropdownMenuItem - className="text-destructive" - onClick={(event) => { - event.stopPropagation() - deleteRecentGroup(group) - setState({ - ...state, - groups: state.groups.filter( - (g) => g.id !== group.id, - ), - }) - toast.toast({ - title: 'Group has been removed', - description: - 'The group was removed from your recent groups list.', - action: ( - <ToastAction - altText="Undo group removal" - onClick={() => { - saveRecentGroup(group) - setState({ - ...state, - groups: state.groups, - }) - }} - > - Undo - </ToastAction> - ), - }) - }} - > - Remove from recent groups - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - </span> - </div> - <div className="text-muted-foreground font-normal text-xs"> - {details ? ( - <div className="w-full flex items-center justify-between"> - <div className="flex items-center"> - <Users className="w-3 h-3 inline mr-1" /> - <span>{details._count.participants}</span> - </div> - <div className="flex items-center"> - <Calendar className="w-3 h-3 inline mx-1" /> - <span> - {new Date(details.createdAt).toLocaleDateString( - 'en-US', - { - dateStyle: 'medium', - }, - )} - </span> - </div> - </div> - ) : ( - <div className="flex justify-between"> - <Skeleton className="h-4 w-6 rounded-full" /> - <Skeleton className="h-4 w-24 rounded-full" /> - </div> - )} - </div> - </div> - </div> - </Button> - </li> - ) - })} + {groups.map((group) => ( + <RecentGroupListCard + key={group.id} + group={group} + state={state} + setState={setState} + /> + ))} </ul> ) } diff --git a/src/app/groups/recent-groups-helpers.ts b/src/app/groups/recent-groups-helpers.ts index 72c5d28..0d718e2 100644 --- a/src/app/groups/recent-groups-helpers.ts +++ b/src/app/groups/recent-groups-helpers.ts @@ -8,12 +8,14 @@ export const recentGroupsSchema = z.array( ) export const starredGroupsSchema = z.array(z.string()) +export const archivedGroupsSchema = z.array(z.string()) export type RecentGroups = z.infer<typeof recentGroupsSchema> export type RecentGroup = RecentGroups[number] const STORAGE_KEY = 'recentGroups' const STARRED_GROUPS_STORAGE_KEY = 'starredGroups' +const ARCHIVED_GROUPS_STORAGE_KEY = 'archivedGroups' export function getRecentGroups() { const groupsInStorageJson = localStorage.getItem(STORAGE_KEY) @@ -64,3 +66,28 @@ export function unstarGroup(groupId: string) { JSON.stringify(starredGroups.filter((g) => g !== groupId)), ) } + +export function getArchivedGroups() { + const archivedGroupsJson = localStorage.getItem(ARCHIVED_GROUPS_STORAGE_KEY) + const archivedGroupsRaw = archivedGroupsJson + ? JSON.parse(archivedGroupsJson) + : [] + const parseResult = archivedGroupsSchema.safeParse(archivedGroupsRaw) + return parseResult.success ? parseResult.data : [] +} + +export function archiveGroup(groupId: string) { + const archivedGroups = getArchivedGroups() + localStorage.setItem( + ARCHIVED_GROUPS_STORAGE_KEY, + JSON.stringify([...archivedGroups, groupId]), + ) +} + +export function unarchiveGroup(groupId: string) { + const archivedGroups = getArchivedGroups() + localStorage.setItem( + ARCHIVED_GROUPS_STORAGE_KEY, + JSON.stringify(archivedGroups.filter((g) => g !== groupId)), + ) +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 2a03ded..c27e584 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -218,7 +218,11 @@ export async function getGroupExpenses(groupId: string) { const prisma = await getPrisma() return prisma.expense.findMany({ where: { groupId }, - include: { paidFor: { include: { participant: true } }, paidBy: true, category: true }, + include: { + paidFor: { include: { participant: true } }, + paidBy: true, + category: true, + }, orderBy: { expenseDate: 'desc' }, }) } |
