Skip to content

Commit 44a2836

Browse files
committed
feat: basic borrowing
1 parent 6d45c01 commit 44a2836

File tree

19 files changed

+1757
-19
lines changed

19 files changed

+1757
-19
lines changed

app/borrows/new/page.tsx

Lines changed: 490 additions & 0 deletions
Large diffs are not rendered by default.

app/borrows/page.tsx

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Badge } from "@/components/ui/badge";
12
import {
23
Breadcrumb,
34
BreadcrumbItem,
@@ -6,6 +7,7 @@ import {
67
BreadcrumbPage,
78
BreadcrumbSeparator,
89
} from "@/components/ui/breadcrumb";
10+
import { Button } from "@/components/ui/button";
911
import {
1012
Pagination,
1113
PaginationContent,
@@ -56,22 +58,27 @@ export default async function Borrows({
5658
const prevURL = `/borrows?skip=${prevSkip}&limit=${limit}`;
5759

5860
return (
59-
<div>
61+
<div className="space-y-4">
6062
<h1 className="text-2xl font-semibold">Borrows</h1>
61-
<Breadcrumb>
62-
<BreadcrumbList>
63-
<BreadcrumbItem>
64-
<Link href="/" passHref legacyBehavior>
65-
<BreadcrumbLink>Home</BreadcrumbLink>
66-
</Link>
67-
</BreadcrumbItem>
68-
<BreadcrumbSeparator />
63+
<div className="flex justify-between items-center">
64+
<Breadcrumb>
65+
<BreadcrumbList>
66+
<BreadcrumbItem>
67+
<Link href="/" passHref legacyBehavior>
68+
<BreadcrumbLink>Home</BreadcrumbLink>
69+
</Link>
70+
</BreadcrumbItem>
71+
<BreadcrumbSeparator />
6972

70-
<BreadcrumbItem>
71-
<BreadcrumbPage>Borrows</BreadcrumbPage>
72-
</BreadcrumbItem>
73-
</BreadcrumbList>
74-
</Breadcrumb>
73+
<BreadcrumbItem>
74+
<BreadcrumbPage>Borrows</BreadcrumbPage>
75+
</BreadcrumbItem>
76+
</BreadcrumbList>
77+
</Breadcrumb>
78+
<Button asChild>
79+
<Link href="/borrows/new">New Borrow</Link>
80+
</Button>
81+
</div>
7582

7683
<Table>
7784
{/* <TableCaption>List of books available in the library.</TableCaption> */}
@@ -82,6 +89,7 @@ export default async function Borrows({
8289
<TableHead>User</TableHead>
8390
<TableHead>Due</TableHead>
8491
<TableHead>Title</TableHead>
92+
<TableHead>Library</TableHead>
8593
<TableHead>Returned Date</TableHead>
8694
</TableRow>
8795
</TableHeader>
@@ -93,7 +101,10 @@ export default async function Borrows({
93101
<TableCell>{b.subscription.user.name}</TableCell>
94102
<TableCell>{b.due_at}</TableCell>
95103
<TableCell>{b.book.title}</TableCell>
96-
<TableCell>{b.returned_at}</TableCell>
104+
<TableCell>{b.subscription.membership.library.name}</TableCell>
105+
<TableCell>
106+
{b.returned_at ?? <Badge variant="outline">Active</Badge>}
107+
</TableCell>
97108
</TableRow>
98109
))}
99110
</TableBody>

app/layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Metadata } from "next";
22
import { Geist, Geist_Mono } from "next/font/google";
33
import "./globals.css";
4+
import { Toaster } from "@/components/ui/toaster";
45

56
const geistSans = Geist({
67
variable: "--font-geist-sans",
@@ -28,6 +29,7 @@ export default function RootLayout({
2829
className={`${geistSans.variable} ${geistMono.variable} antialiased container mx-auto px-4`}
2930
>
3031
{children}
32+
<Toaster />
3133
</body>
3234
</html>
3335
);

components/hooks/use-toast.ts

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
"use client"
2+
3+
// Inspired by react-hot-toast library
4+
import * as React from "react"
5+
6+
import type {
7+
ToastActionElement,
8+
ToastProps,
9+
} from "@/components/ui/toast"
10+
11+
const TOAST_LIMIT = 1
12+
const TOAST_REMOVE_DELAY = 1000000
13+
14+
type ToasterToast = ToastProps & {
15+
id: string
16+
title?: React.ReactNode
17+
description?: React.ReactNode
18+
action?: ToastActionElement
19+
}
20+
21+
const actionTypes = {
22+
ADD_TOAST: "ADD_TOAST",
23+
UPDATE_TOAST: "UPDATE_TOAST",
24+
DISMISS_TOAST: "DISMISS_TOAST",
25+
REMOVE_TOAST: "REMOVE_TOAST",
26+
} as const
27+
28+
let count = 0
29+
30+
function genId() {
31+
count = (count + 1) % Number.MAX_SAFE_INTEGER
32+
return count.toString()
33+
}
34+
35+
type ActionType = typeof actionTypes
36+
37+
type Action =
38+
| {
39+
type: ActionType["ADD_TOAST"]
40+
toast: ToasterToast
41+
}
42+
| {
43+
type: ActionType["UPDATE_TOAST"]
44+
toast: Partial<ToasterToast>
45+
}
46+
| {
47+
type: ActionType["DISMISS_TOAST"]
48+
toastId?: ToasterToast["id"]
49+
}
50+
| {
51+
type: ActionType["REMOVE_TOAST"]
52+
toastId?: ToasterToast["id"]
53+
}
54+
55+
interface State {
56+
toasts: ToasterToast[]
57+
}
58+
59+
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
60+
61+
const addToRemoveQueue = (toastId: string) => {
62+
if (toastTimeouts.has(toastId)) {
63+
return
64+
}
65+
66+
const timeout = setTimeout(() => {
67+
toastTimeouts.delete(toastId)
68+
dispatch({
69+
type: "REMOVE_TOAST",
70+
toastId: toastId,
71+
})
72+
}, TOAST_REMOVE_DELAY)
73+
74+
toastTimeouts.set(toastId, timeout)
75+
}
76+
77+
export const reducer = (state: State, action: Action): State => {
78+
switch (action.type) {
79+
case "ADD_TOAST":
80+
return {
81+
...state,
82+
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
83+
}
84+
85+
case "UPDATE_TOAST":
86+
return {
87+
...state,
88+
toasts: state.toasts.map((t) =>
89+
t.id === action.toast.id ? { ...t, ...action.toast } : t
90+
),
91+
}
92+
93+
case "DISMISS_TOAST": {
94+
const { toastId } = action
95+
96+
// ! Side effects ! - This could be extracted into a dismissToast() action,
97+
// but I'll keep it here for simplicity
98+
if (toastId) {
99+
addToRemoveQueue(toastId)
100+
} else {
101+
state.toasts.forEach((toast) => {
102+
addToRemoveQueue(toast.id)
103+
})
104+
}
105+
106+
return {
107+
...state,
108+
toasts: state.toasts.map((t) =>
109+
t.id === toastId || toastId === undefined
110+
? {
111+
...t,
112+
open: false,
113+
}
114+
: t
115+
),
116+
}
117+
}
118+
case "REMOVE_TOAST":
119+
if (action.toastId === undefined) {
120+
return {
121+
...state,
122+
toasts: [],
123+
}
124+
}
125+
return {
126+
...state,
127+
toasts: state.toasts.filter((t) => t.id !== action.toastId),
128+
}
129+
}
130+
}
131+
132+
const listeners: Array<(state: State) => void> = []
133+
134+
let memoryState: State = { toasts: [] }
135+
136+
function dispatch(action: Action) {
137+
memoryState = reducer(memoryState, action)
138+
listeners.forEach((listener) => {
139+
listener(memoryState)
140+
})
141+
}
142+
143+
type Toast = Omit<ToasterToast, "id">
144+
145+
function toast({ ...props }: Toast) {
146+
const id = genId()
147+
148+
const update = (props: ToasterToast) =>
149+
dispatch({
150+
type: "UPDATE_TOAST",
151+
toast: { ...props, id },
152+
})
153+
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
154+
155+
dispatch({
156+
type: "ADD_TOAST",
157+
toast: {
158+
...props,
159+
id,
160+
open: true,
161+
onOpenChange: (open) => {
162+
if (!open) dismiss()
163+
},
164+
},
165+
})
166+
167+
return {
168+
id: id,
169+
dismiss,
170+
update,
171+
}
172+
}
173+
174+
function useToast() {
175+
const [state, setState] = React.useState<State>(memoryState)
176+
177+
React.useEffect(() => {
178+
listeners.push(setState)
179+
return () => {
180+
const index = listeners.indexOf(setState)
181+
if (index > -1) {
182+
listeners.splice(index, 1)
183+
}
184+
}
185+
}, [state])
186+
187+
return {
188+
...state,
189+
toast,
190+
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
191+
}
192+
}
193+
194+
export { useToast, toast }

components/ui/badge.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import * as React from "react"
2+
import { cva, type VariantProps } from "class-variance-authority"
3+
4+
import { cn } from "@/lib/utils"
5+
6+
const badgeVariants = cva(
7+
"inline-flex items-center rounded-full 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",
8+
{
9+
variants: {
10+
variant: {
11+
default:
12+
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
13+
secondary:
14+
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
15+
destructive:
16+
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
17+
outline: "text-foreground",
18+
},
19+
},
20+
defaultVariants: {
21+
variant: "default",
22+
},
23+
}
24+
)
25+
26+
export interface BadgeProps
27+
extends React.HTMLAttributes<HTMLDivElement>,
28+
VariantProps<typeof badgeVariants> {}
29+
30+
function Badge({ className, variant, ...props }: BadgeProps) {
31+
return (
32+
<div className={cn(badgeVariants({ variant }), className)} {...props} />
33+
)
34+
}
35+
36+
export { Badge, badgeVariants }

0 commit comments

Comments
 (0)