Skip to content

Commit 85bb77b

Browse files
committed
feat: book import page
1 parent 8bcaebe commit 85bb77b

File tree

11 files changed

+521
-41
lines changed

11 files changed

+521
-41
lines changed
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import { previewBookImport } from '@/lib/api/book'
2+
import { Verify } from '@/lib/firebase/firebase'
3+
import { cookies } from 'next/headers'
4+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
5+
import { Badge } from '@/components/ui/badge'
6+
import { AlertCircle, CheckCircle2, FileText } from 'lucide-react'
7+
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
8+
import { Button } from '@/components/ui/button'
9+
import Link from 'next/link'
10+
import type { Metadata } from 'next'
11+
import { SITE_NAME } from '@/lib/consts'
12+
import { BtnConfirmImport } from '@/components/books/BtnConfirmImport'
13+
14+
export const metadata: Metadata = {
15+
title: `Import Books Preview · ${SITE_NAME}`,
16+
}
17+
18+
export default async function ImportPreviewPage({
19+
params,
20+
}: {
21+
params: Promise<{ path: string }>
22+
}) {
23+
const { path } = await params
24+
const decodedPath = decodeURIComponent(path)
25+
26+
const headers = await Verify({
27+
from: `/admin/books/import/${path}`,
28+
})
29+
30+
const cookieStore = await cookies()
31+
const cookieName = process.env.LIBRARY_COOKIE_NAME as string
32+
const libraryId = cookieStore.get(cookieName)?.value
33+
34+
if (!libraryId) {
35+
return (
36+
<div className="space-y-4">
37+
<h1 className="text-2xl font-semibold">Import Books</h1>
38+
<Alert variant="destructive">
39+
<AlertCircle className="h-4 w-4" />
40+
<AlertTitle>Error</AlertTitle>
41+
<AlertDescription>No library selected</AlertDescription>
42+
</Alert>
43+
</div>
44+
)
45+
}
46+
47+
const res = await previewBookImport(
48+
{
49+
path: decodedPath,
50+
library_id: libraryId,
51+
},
52+
{ headers }
53+
)
54+
55+
if ('error' in res) {
56+
return (
57+
<div className="space-y-4">
58+
<Alert variant="destructive">
59+
<AlertCircle className="h-4 w-4" />
60+
<AlertTitle>Error</AlertTitle>
61+
<AlertDescription>{JSON.stringify(res.error)}</AlertDescription>
62+
</Alert>
63+
<Button asChild variant="outline">
64+
<Link href="/admin/books/import">Back to Import</Link>
65+
</Button>
66+
</div>
67+
)
68+
}
69+
70+
const preview = res.data
71+
const validBooks = preview.rows.filter((b) => b.status !== 'invalid')
72+
const invalidBooks = preview.rows.filter((b) => b.status === 'invalid')
73+
74+
return (
75+
<div className="space-y-4">
76+
{/* Summary */}
77+
<div className="grid gap-4 md:grid-cols-3">
78+
<Card>
79+
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
80+
<CardTitle className="text-sm font-medium">Total Books</CardTitle>
81+
<FileText className="h-4 w-4 text-muted-foreground" />
82+
</CardHeader>
83+
<CardContent>
84+
<div className="text-2xl font-bold">{preview.rows.length}</div>
85+
</CardContent>
86+
</Card>
87+
<Card>
88+
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
89+
<CardTitle className="text-sm font-medium">Valid</CardTitle>
90+
<CheckCircle2 className="h-4 w-4 text-primary" />
91+
</CardHeader>
92+
<CardContent>
93+
<div className="text-2xl font-bold text-primary">
94+
{validBooks.length}
95+
</div>
96+
</CardContent>
97+
</Card>
98+
<Card>
99+
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
100+
<CardTitle className="text-sm font-medium">Invalid</CardTitle>
101+
<AlertCircle className="h-4 w-4 text-destructive" />
102+
</CardHeader>
103+
<CardContent>
104+
<div className="text-2xl font-bold text-destructive">
105+
{invalidBooks.length}
106+
</div>
107+
</CardContent>
108+
</Card>
109+
</div>
110+
111+
{/* Book Preview List */}
112+
<Card>
113+
<CardHeader>
114+
<CardTitle>Books to Import</CardTitle>
115+
</CardHeader>
116+
<CardContent>
117+
<div className="space-y-2">
118+
{preview.rows.map((book, idx) => (
119+
<div
120+
key={idx}
121+
className={`p-4 border rounded-lg ${
122+
book.status === 'invalid'
123+
? 'bg-destructive/10 border-destructive/50'
124+
: book.status === 'update'
125+
? 'border-primary/50'
126+
: 'bg-primary/10 border-primary/30'
127+
}`}
128+
>
129+
<div className="flex items-start justify-between">
130+
<div className="flex-1">
131+
<div className="flex items-center gap-2 mb-1">
132+
<h3 className="font-semibold">{book.title}</h3>
133+
{book.status === 'invalid' ? (
134+
<Badge
135+
variant="outline"
136+
className="bg-destructive/20 text-destructive border-destructive"
137+
>
138+
Invalid
139+
</Badge>
140+
) : book.status === 'update' ? (
141+
<Badge
142+
variant="outline"
143+
className="bg-primary/20 text-primary border-primary"
144+
>
145+
Update
146+
</Badge>
147+
) : (
148+
<Badge
149+
variant="outline"
150+
className="bg-primary/10 text-primary border-primary"
151+
>
152+
New
153+
</Badge>
154+
)}
155+
</div>
156+
<div className="text-sm text-muted-foreground space-y-1">
157+
<p>Author: {book.author}</p>
158+
<p>Code: {book.code}</p>
159+
{book.id && (
160+
<pre className="font-mono text-xs">ID: {book.id}</pre>
161+
)}
162+
</div>
163+
{book.error && (
164+
<div className="mt-2 text-sm text-destructive">
165+
<p className="font-medium">Error:</p>
166+
<pre>{book.error}</pre>
167+
</div>
168+
)}
169+
</div>
170+
</div>
171+
</div>
172+
))}
173+
</div>
174+
</CardContent>
175+
</Card>
176+
177+
{/* Actions */}
178+
<div className="flex gap-2 justify-end">
179+
<Button asChild variant="outline">
180+
<Link href="/admin/books/import">Back</Link>
181+
</Button>
182+
{validBooks.length > 0 && (
183+
<BtnConfirmImport
184+
path={decodedPath}
185+
libraryId={libraryId}
186+
count={validBooks.length}
187+
/>
188+
)}
189+
</div>
190+
</div>
191+
)
192+
}

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

Lines changed: 2 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { FormUploadCSV } from '@/components/books/csv-upload-form'
12
import { Button } from '@/components/ui/button'
23
import {
34
Card,
@@ -18,40 +19,7 @@ export default function ImportBooksPage() {
1819
</CardDescription>
1920
</CardHeader>
2021
<CardContent className="space-y-4">
21-
<div className="flex items-center gap-4">
22-
<div className="flex-1">
23-
<input
24-
type="file"
25-
accept=".csv,text/csv"
26-
// onChange={console.log}
27-
className="block w-full text-sm text-foreground
28-
file:mr-4 file:py-2 file:px-4
29-
file:rounded-md file:border-0
30-
file:text-sm file:font-semibold
31-
file:bg-primary/10 file:text-primary
32-
hover:file:bg-primary/20
33-
cursor-pointer"
34-
/>
35-
</div>
36-
<Button disabled={false}>
37-
{true ? (
38-
<>
39-
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
40-
Processing...
41-
</>
42-
) : (
43-
<>
44-
<Upload className="h-4 w-4 mr-2" />
45-
Generate Preview
46-
</>
47-
)}
48-
</Button>
49-
</div>
50-
{/* {file && (
51-
<div className="text-sm text-muted-foreground">
52-
Selected file: <span className="font-medium">{file.name}</span> ({(file.size / 1024).toFixed(2)} KB)
53-
</div>
54-
)} */}
22+
<FormUploadCSV />
5523
</CardContent>
5624
</Card>
5725
)
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
'use client'
2+
3+
import { useState } from 'react'
4+
import { Button } from '@/components/ui/button'
5+
import { importBooks } from '@/lib/api/book'
6+
import { useRouter } from 'next/navigation'
7+
import { toast } from 'sonner'
8+
import { CheckCircle2, RefreshCw } from 'lucide-react'
9+
10+
interface BtnConfirmImportProps {
11+
path: string
12+
libraryId: string
13+
count: number
14+
}
15+
16+
export const BtnConfirmImport: React.FC<BtnConfirmImportProps> = ({
17+
path,
18+
libraryId,
19+
count,
20+
}) => {
21+
const [isImporting, setIsImporting] = useState(false)
22+
const router = useRouter()
23+
24+
const handleImport = async () => {
25+
setIsImporting(true)
26+
27+
try {
28+
const res = await importBooks({
29+
path,
30+
library_id: libraryId,
31+
})
32+
33+
if ('error' in res) {
34+
toast.error('Failed to import books')
35+
setIsImporting(false)
36+
return
37+
}
38+
39+
toast.success(`Import job created successfully!`, {
40+
richColors: true,
41+
action: {
42+
label: 'View Job',
43+
onClick: () => router.push(`/admin/jobs/${res.data.id}`),
44+
},
45+
})
46+
} catch (error) {
47+
console.error('Import error:', error)
48+
toast.error(
49+
error instanceof Error ? error.message : 'Failed to import books'
50+
)
51+
setIsImporting(false)
52+
}
53+
}
54+
55+
return (
56+
<Button onClick={handleImport} disabled={isImporting}>
57+
{isImporting ? (
58+
<>
59+
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
60+
Creating Import Job...
61+
</>
62+
) : (
63+
<>
64+
<CheckCircle2 className="h-4 w-4 mr-2" />
65+
Import {count} Book{count !== 1 ? 's' : ''}
66+
</>
67+
)}
68+
</Button>
69+
)
70+
}

0 commit comments

Comments
 (0)