aboutsummaryrefslogtreecommitdiffstats
path: root/src/app/groups
diff options
context:
space:
mode:
authorBrandon Eng <bce25@cornell.edu>2024-01-16 22:41:46 +0700
committerGitHub <noreply@github.com>2024-01-16 10:41:46 -0500
commit36cc4f1ef745a027540050ad668db3bcdc08d4b2 (patch)
tree73d45c30b1e17e92b676701ba9ee3f6fb1071870 /src/app/groups
parent1141501edb6611b7ac147b6ceefb963ba1f83606 (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/app/groups')
-rw-r--r--src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx8
-rw-r--r--src/app/groups/[groupId]/expenses/create/page.tsx10
-rw-r--r--src/app/groups/page.tsx13
-rw-r--r--src/app/groups/recent-group-list-card.tsx185
-rw-r--r--src/app/groups/recent-group-list.tsx263
-rw-r--r--src/app/groups/recent-groups-helpers.ts27
6 files changed, 331 insertions, 175 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)),
+ )
+}