Skip to content

Commit ec062f6

Browse files
committed
feat: book qr print and import
1 parent 29546e2 commit ec062f6

File tree

15 files changed

+858
-401
lines changed

15 files changed

+858
-401
lines changed

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ GOOGLE_APPLICATION_CREDENTIALS=/path/to/serviceAccountKey.json
1111

1212
CLIENT_ID=
1313

14+
# Redis Configuration
15+
REDIS_HOST=localhost
16+
REDIS_PORT=6379
17+
REDIS_PASSWORD=
18+
REDIS_DB=0
1419

1520
OTEL_RESOURCE_ATTRIBUTES=
1621
OTEL_EXPORTER_OTLP_ENDPOINT=

app/(protected)/admin/books/import/layout.tsx

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { BtnDownloadTemplate } from '@/components/books/BtnDownloadTemplate'
12
import { Alert, AlertDescription } from '@/components/ui/alert'
23
import {
34
Breadcrumb,
@@ -7,19 +8,13 @@ import {
78
BreadcrumbSeparator,
89
} from '@/components/ui/breadcrumb'
910
import { FileText } from 'lucide-react'
10-
import { DownloadTemplateButton } from '@/components/books/DownloadTemplateButton'
11-
import { cookies } from 'next/headers'
1211

1312
export default async function BorrowDetailsLayout({
1413
children,
1514
}: Readonly<{
1615
children: React.ReactNode
1716
params: Promise<{}>
1817
}>) {
19-
const cookieStore = await cookies()
20-
const cookieName = process.env.LIBRARY_COOKIE_NAME as string
21-
const libraryId = cookieStore.get(cookieName)?.value
22-
2318
return (
2419
<div className="space-y-4">
2520
<nav className="backdrop-blur-sm sticky top-0 z-10">
@@ -63,7 +58,7 @@ export default async function BorrowDetailsLayout({
6358
<strong>year</strong> - Year of publication (optional)
6459
</li>
6560
</ul>
66-
<DownloadTemplateButton libraryId={libraryId} />
61+
<BtnDownloadTemplate />
6762
</div>
6863
</AlertDescription>
6964
</Alert>

app/(protected)/admin/books/manage/page.tsx

Lines changed: 65 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,71 @@ import {
1515
} from '@/components/ui/pagination'
1616
import { getListBooks } from '@/lib/api/book'
1717
import type { Metadata } from 'next'
18-
import { SITE_NAME } from '@/lib/consts'
18+
import { REDIS_KEY_BOOK_PRINT_PREFIX, SITE_NAME } from '@/lib/consts'
1919
import { Badge } from '@/components/ui/badge'
2020
import { cookies } from 'next/headers'
2121
import { Verify } from '@/lib/firebase/firebase'
2222
import { BookSelectPanel } from '@/components/books/book-select-panel'
2323
import { SearchInput } from '@/components/common/SearchInput'
24-
import { Button } from '@/components/ui/button'
25-
import Link from 'next/link'
26-
import { QrCode } from 'lucide-react'
24+
import { cache } from '@/lib/redis-helpers'
25+
import { getBookImportTemplate } from '@/lib/book-utils'
26+
27+
async function onPrintAction(
28+
bookIDs: string[]
29+
): Promise<{ key: string } | { error: string }> {
30+
'use server'
31+
32+
// hash the key
33+
const hash = await crypto.subtle.digest(
34+
'SHA-256',
35+
new TextEncoder().encode(bookIDs.toSorted().join(','))
36+
)
37+
const key = Array.from(new Uint8Array(hash))
38+
.map((b) => b.toString(16).padStart(2, '0'))
39+
.join('')
40+
.slice(0, 16) // Shorten to 16 characters
41+
42+
const success = await cache.set(
43+
`${REDIS_KEY_BOOK_PRINT_PREFIX}:${key}`,
44+
bookIDs,
45+
300
46+
)
47+
if (!success) {
48+
return {
49+
error: 'Failed to store print data in cache',
50+
}
51+
}
52+
return { key }
53+
}
54+
55+
async function downloadImportTemplateAction(
56+
bookIDs: string[]
57+
): Promise<{ csv: string } | { error: string }> {
58+
'use server'
59+
60+
if (!bookIDs) {
61+
return { error: 'No book IDs provided' }
62+
}
63+
64+
const cookieStore = await cookies()
65+
const cookieName = process.env.LIBRARY_COOKIE_NAME as string
66+
const libID = cookieStore.get(cookieName)?.value
67+
68+
const res = await getListBooks(
69+
bookIDs ? { ids: bookIDs, library_id: libID } : { library_id: libID }
70+
)
71+
if ('error' in res) {
72+
return { error: res.error }
73+
}
74+
75+
const csv = getBookImportTemplate(res.data)
76+
if (!csv) {
77+
return {
78+
error: 'Failed to generate CSV',
79+
}
80+
}
81+
return { csv }
82+
}
2783

2884
export const metadata: Metadata = {
2985
title: `Books · ${SITE_NAME}`,
@@ -100,15 +156,13 @@ export default async function BooksSelectPage({
100156
placeholder="Search by title"
101157
name="title"
102158
/>
103-
<Button variant="outline" asChild>
104-
<Link href="/admin/books/print-qr">
105-
<QrCode className="mr-2 h-4 w-4" />
106-
Print QR Codes
107-
</Link>
108-
</Button>
109159
</div>
110160

111-
<BookSelectPanel books={res.data} />
161+
<BookSelectPanel
162+
books={res.data}
163+
onPrintAction={onPrintAction}
164+
downloadImportTemplateAction={downloadImportTemplateAction}
165+
/>
112166

113167
<Pagination>
114168
<PaginationContent>
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { getListBooks } from '@/lib/api/book'
2+
import { REDIS_KEY_BOOK_PRINT_PREFIX } from '@/lib/consts'
3+
import { cache } from '@/lib/redis-helpers'
4+
import qrcode from 'qrcode'
5+
import { cookies } from 'next/headers'
6+
7+
export default async function BooksPrintQRPage({
8+
params,
9+
}: {
10+
params: Promise<{ key: string }>
11+
}) {
12+
const { key } = await params
13+
const bookIDs = await cache.get<string[]>(
14+
`${REDIS_KEY_BOOK_PRINT_PREFIX}:${key}`
15+
)
16+
17+
if (!bookIDs) {
18+
return <div>No books found for the provided key.</div>
19+
}
20+
21+
const cookieStore = await cookies()
22+
const cookieName = process.env.LIBRARY_COOKIE_NAME as string
23+
const libID = cookieStore.get(cookieName)?.value
24+
25+
const res = await getListBooks(
26+
bookIDs ? { ids: bookIDs, library_id: libID } : { library_id: libID }
27+
)
28+
if ('error' in res) {
29+
return <div>Error loading books: {res.error}</div>
30+
}
31+
32+
const qrPromises = res.data.map(async (book) => {
33+
try {
34+
const svg = await qrcode.toString(book.id, {
35+
margin: 0,
36+
width: 100,
37+
errorCorrectionLevel: 'L',
38+
type: 'svg',
39+
})
40+
return { id: book.id, code: book.code, svg }
41+
} catch (err) {
42+
console.error(`Failed to generate QR code for ${book.id}:`, err)
43+
return { id: book.id, svg: '' }
44+
}
45+
})
46+
47+
const results = await Promise.all(qrPromises)
48+
49+
return (
50+
<div className="grid grid-cols-4 gap-2">
51+
{results
52+
.filter((b) => b.svg)
53+
.map((b) => {
54+
return (
55+
<figure
56+
key={b.id}
57+
className="flex flex-col items-center border border-dashed justify-center p-2 rounded-lg"
58+
>
59+
<span
60+
dangerouslySetInnerHTML={{ __html: b.svg }}
61+
className="aspect-square border-1 rounded-md p-2"
62+
/>
63+
<figcaption className="text-center">
64+
<p className="text-black dark:text-white font-mono font-medium text-xs">
65+
{b.code}
66+
</p>
67+
</figcaption>
68+
</figure>
69+
)
70+
})}
71+
</div>
72+
)
73+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
'use client'
2+
3+
import { Download } from 'lucide-react'
4+
import { Button } from '../ui/button'
5+
import { downloadCSV, getBookImportTemplate } from '@/lib/book-utils'
6+
import Link from 'next/link'
7+
8+
export const BtnDownloadTemplate: React.FC = () => {
9+
return (
10+
<div className="flex items-center gap-2">
11+
<Button
12+
variant="link"
13+
className="p-0 h-auto"
14+
onClick={() => downloadCSV(getBookImportTemplate([]))}
15+
>
16+
<Download className="h-3 w-3 mr-1" />
17+
Download Template
18+
</Button>
19+
<span>or</span>
20+
<Button variant="link">
21+
<Link href="/admin/books/manage">Get Edit Template</Link>
22+
</Button>
23+
</div>
24+
)
25+
}

components/books/DownloadTemplateButton.tsx

Lines changed: 0 additions & 13 deletions
This file was deleted.

0 commit comments

Comments
 (0)