Add search functionality and data export feature

This commit is contained in:
boilerrat 2025-03-18 22:03:47 -04:00
parent 3894be3a5f
commit 11f7a43d1d
4 changed files with 213 additions and 79 deletions

View File

@ -0,0 +1,109 @@
import { NextRequest, NextResponse } from "next/server";
import { getUser } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function GET(request: NextRequest) {
try {
// Check if user is authenticated
const user = await getUser();
if (!user) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
// Get search parameter
const searchParams = request.nextUrl.searchParams;
const search = searchParams.get("search") || "";
// Fetch contacts with search filter if provided
const contacts = await prisma.contact.findMany({
where: search ? {
OR: [
{ name: { contains: search, mode: 'insensitive' } },
{ ensName: { contains: search, mode: 'insensitive' } },
{ ethereumAddress: { contains: search, mode: 'insensitive' } },
{ email: { contains: search, mode: 'insensitive' } },
{ twitter: { contains: search, mode: 'insensitive' } },
{ discord: { contains: search, mode: 'insensitive' } },
],
} : undefined,
include: {
nftHoldings: true,
daoMemberships: true,
tokenHoldings: true,
tags: {
include: {
tag: true,
},
},
},
orderBy: {
createdAt: "desc",
},
});
// Convert to CSV format
const csvHeader = [
"Name",
"Ethereum Address",
"ENS Name",
"Email",
"Twitter",
"Discord",
"Telegram",
"Farcaster",
"NFT Holdings",
"DAO Memberships",
"Token Holdings",
"Tags",
"Created At"
].join(",");
const csvRows = contacts.map(contact => {
const nftHoldings = contact.nftHoldings.map(nft => `${nft.collectionName || 'Unknown'} #${nft.tokenId}`).join("; ");
const daoMemberships = contact.daoMemberships.map(dao => dao.daoName).join("; ");
const tokenHoldings = contact.tokenHoldings.map(token => `${token.tokenSymbol}: ${token.balance}`).join("; ");
const tags = contact.tags.map(tag => tag.tag.name).join("; ");
return [
contact.name ? `"${contact.name.replace(/"/g, '""')}"` : "",
`"${contact.ethereumAddress}"`,
contact.ensName ? `"${contact.ensName.replace(/"/g, '""')}"` : "",
contact.email ? `"${contact.email.replace(/"/g, '""')}"` : "",
contact.twitter ? `"${contact.twitter.replace(/"/g, '""')}"` : "",
contact.discord ? `"${contact.discord.replace(/"/g, '""')}"` : "",
contact.telegram ? `"${contact.telegram.replace(/"/g, '""')}"` : "",
contact.farcaster ? `"${contact.farcaster.replace(/"/g, '""')}"` : "",
nftHoldings ? `"${nftHoldings.replace(/"/g, '""')}"` : "",
daoMemberships ? `"${daoMemberships.replace(/"/g, '""')}"` : "",
tokenHoldings ? `"${tokenHoldings.replace(/"/g, '""')}"` : "",
tags ? `"${tags.replace(/"/g, '""')}"` : "",
`"${new Date(contact.createdAt).toISOString()}"`,
].join(",");
});
const csv = [csvHeader, ...csvRows].join("\n");
// Generate filename with date
const date = new Date().toISOString().split("T")[0];
const filename = search
? `stones-contacts-search-${search}-${date}.csv`
: `stones-contacts-${date}.csv`;
// Return CSV file
return new NextResponse(csv, {
headers: {
"Content-Type": "text/csv",
"Content-Disposition": `attachment; filename="${filename}"`,
},
});
} catch (error) {
console.error("Export error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@ -19,11 +19,22 @@ interface ContactsPageProps {
export default async function ContactsPage({ searchParams }: ContactsPageProps) { export default async function ContactsPage({ searchParams }: ContactsPageProps) {
const user = await getUser(); const user = await getUser();
const page = Number(searchParams.page) || 1; const page = Number(searchParams.page) || 1;
const search = typeof searchParams.search === 'string' ? searchParams.search : '';
const limit = 25; const limit = 25;
const skip = (page - 1) * limit; const skip = (page - 1) * limit;
// Get contacts with pagination // Get contacts with pagination and search
const contacts = await prisma.contact.findMany({ const contacts = await prisma.contact.findMany({
where: {
OR: [
{ name: { contains: search, mode: 'insensitive' } },
{ ensName: { contains: search, mode: 'insensitive' } },
{ ethereumAddress: { contains: search, mode: 'insensitive' } },
{ email: { contains: search, mode: 'insensitive' } },
{ twitter: { contains: search, mode: 'insensitive' } },
{ discord: { contains: search, mode: 'insensitive' } },
],
},
skip, skip,
take: limit, take: limit,
orderBy: { orderBy: {
@ -39,8 +50,19 @@ export default async function ContactsPage({ searchParams }: ContactsPageProps)
}, },
}); });
// Get total count for pagination // Get total count for pagination with search
const totalContacts = await prisma.contact.count(); const totalContacts = await prisma.contact.count({
where: {
OR: [
{ name: { contains: search, mode: 'insensitive' } },
{ ensName: { contains: search, mode: 'insensitive' } },
{ ethereumAddress: { contains: search, mode: 'insensitive' } },
{ email: { contains: search, mode: 'insensitive' } },
{ twitter: { contains: search, mode: 'insensitive' } },
{ discord: { contains: search, mode: 'insensitive' } },
],
},
});
const totalPages = Math.ceil(totalContacts / limit); const totalPages = Math.ceil(totalContacts / limit);
return ( return (
@ -75,15 +97,26 @@ export default async function ContactsPage({ searchParams }: ContactsPageProps)
<h1 className="text-3xl font-bold">Contacts</h1> <h1 className="text-3xl font-bold">Contacts</h1>
<div className="flex gap-4"> <div className="flex gap-4">
<div className="relative w-full md:w-60"> <div className="relative w-full md:w-60">
<form action="/contacts" method="GET">
<Input <Input
type="search" type="search"
name="search"
placeholder="Search contacts..." placeholder="Search contacts..."
className="pr-8" className="pr-8"
defaultValue={search}
/> />
{/* Preserve page parameter if it exists */}
{page > 1 && <input type="hidden" name="page" value={page} />}
</form>
</div> </div>
<Button> <Button>
Add Contact Add Contact
</Button> </Button>
<Link href={`/api/contacts/export?search=${encodeURIComponent(search)}`}>
<Button variant="outline">
Export Data
</Button>
</Link>
</div> </div>
</div> </div>
@ -91,6 +124,7 @@ export default async function ContactsPage({ searchParams }: ContactsPageProps)
contacts={contacts} contacts={contacts}
currentPage={page} currentPage={page}
totalPages={totalPages} totalPages={totalPages}
search={search}
/> />
</main> </main>
<footer className="w-full border-t py-6"> <footer className="w-full border-t py-6">

View File

@ -2,23 +2,23 @@ import { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { getUser } from "@/lib/auth"; import { getUser } from "@/lib/auth";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { prisma } from "@/lib/prisma";
import { LogoutButton } from "@/components/auth/logout-button"; import { LogoutButton } from "@/components/auth/logout-button";
import { prisma } from "@/lib/prisma";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Dashboard - Stones Database", title: "Dashboard - Stones Database",
description: "Dashboard for Stones Database", description: "Dashboard for the Stones Database",
}; };
export default async function DashboardPage() { export default async function DashboardPage() {
const user = await getUser(); const user = await getUser();
// Get counts from database // Get counts from database
const contactCount = await prisma.contact.count(); const contactsCount = await prisma.contact.count();
const nftHoldingCount = await prisma.nftHolding.count(); const nftHoldingsCount = await prisma.nftHolding.count();
const daoMembershipCount = await prisma.daoMembership.count(); const daoMembershipsCount = await prisma.daoMembership.count();
const tokenHoldingCount = await prisma.tokenHolding.count(); const tokenHoldingsCount = await prisma.tokenHolding.count();
return ( return (
<div className="flex min-h-screen flex-col"> <div className="flex min-h-screen flex-col">
@ -48,94 +48,80 @@ export default async function DashboardPage() {
</div> </div>
</header> </header>
<main className="flex-1 container py-6"> <main className="flex-1 container py-6">
<h1 className="text-3xl font-bold mb-6">Dashboard</h1> <div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold">Dashboard</h1>
<div className="flex gap-2">
<Link href="/api/contacts/export">
<Button variant="outline">
Export All Contacts
</Button>
</Link>
</div>
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Contacts</CardTitle> <CardTitle className="text-sm font-medium">
Total Contacts
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{contactCount}</div> <div className="text-2xl font-bold">{contactsCount}</div>
</CardContent> </CardContent>
<CardFooter>
<Button asChild variant="ghost" size="sm" className="w-full">
<Link href="/contacts">View All Contacts</Link>
</Button>
</CardFooter>
</Card> </Card>
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">NFT Holdings</CardTitle> <CardTitle className="text-sm font-medium">
NFT Holdings
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{nftHoldingCount}</div> <div className="text-2xl font-bold">{nftHoldingsCount}</div>
</CardContent> </CardContent>
<CardFooter>
<Button asChild variant="ghost" size="sm" className="w-full">
<Link href="/nft-holdings">View NFT Holdings</Link>
</Button>
</CardFooter>
</Card> </Card>
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">DAO Memberships</CardTitle> <CardTitle className="text-sm font-medium">
DAO Memberships
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{daoMembershipCount}</div> <div className="text-2xl font-bold">{daoMembershipsCount}</div>
</CardContent> </CardContent>
<CardFooter>
<Button asChild variant="ghost" size="sm" className="w-full">
<Link href="/dao-memberships">View DAO Memberships</Link>
</Button>
</CardFooter>
</Card> </Card>
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Token Holdings</CardTitle> <CardTitle className="text-sm font-medium">
Token Holdings
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{tokenHoldingCount}</div> <div className="text-2xl font-bold">{tokenHoldingsCount}</div>
</CardContent> </CardContent>
<CardFooter>
<Button asChild variant="ghost" size="sm" className="w-full">
<Link href="/token-holdings">View Token Holdings</Link>
</Button>
</CardFooter>
</Card> </Card>
</div> </div>
<div className="mt-8"> <div className="mt-8">
<h2 className="text-xl font-bold mb-4">Quick Actions</h2> <Card>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"> <CardHeader>
<Button asChild variant="outline" className="h-auto py-4"> <CardTitle>Quick Actions</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Link href="/contacts"> <Link href="/contacts">
<div className="flex flex-col items-start"> <Button className="w-full">Browse Contacts</Button>
<span className="font-medium">Browse Contacts</span>
<span className="text-sm text-muted-foreground">View and manage all contacts</span>
</div>
</Link> </Link>
</Button> <Link href="/contacts?page=1">
<Button asChild variant="outline" className="h-auto py-4"> <Button variant="outline" className="w-full">View Recent Contacts</Button>
<Link href="/contacts/search">
<div className="flex flex-col items-start">
<span className="font-medium">Search Contacts</span>
<span className="text-sm text-muted-foreground">Find specific contacts</span>
</div>
</Link> </Link>
<Button variant="outline" className="w-full">
Add New Contact
</Button> </Button>
<Button asChild variant="outline" className="h-auto py-4"> <Button variant="outline" className="w-full">
<Link href="/contacts/export"> Import Contacts
<div className="flex flex-col items-start">
<span className="font-medium">Export Data</span>
<span className="text-sm text-muted-foreground">Download contact data</span>
</div>
</Link>
</Button> </Button>
</div> </CardContent>
</Card>
</div> </div>
</main> </main>
<footer className="w-full border-t py-6"> <footer className="w-full border-t py-6">

View File

@ -18,13 +18,17 @@ interface ContactsListProps {
})[]; })[];
currentPage: number; currentPage: number;
totalPages: number; totalPages: number;
search?: string;
} }
export function ContactsList({ export function ContactsList({
contacts, contacts,
currentPage, currentPage,
totalPages, totalPages,
search = '',
}: ContactsListProps) { }: ContactsListProps) {
const searchParam = search ? `&search=${encodeURIComponent(search)}` : '';
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="rounded-md border"> <div className="rounded-md border">
@ -71,7 +75,7 @@ export function ContactsList({
{contacts.length === 0 && ( {contacts.length === 0 && (
<TableRow> <TableRow>
<TableCell colSpan={5} className="text-center h-24"> <TableCell colSpan={5} className="text-center h-24">
No contacts found. {search ? `No contacts found matching "${search}".` : "No contacts found."}
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
@ -93,10 +97,11 @@ export function ContactsList({
{(currentPage - 1) * 25 + contacts.length} {(currentPage - 1) * 25 + contacts.length}
</strong>{" "} </strong>{" "}
of <strong>{totalPages * 25}</strong> contacts of <strong>{totalPages * 25}</strong> contacts
{search && <span> matching "{search}"</span>}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Link <Link
href={`/contacts?page=${currentPage - 1}`} href={`/contacts?page=${currentPage - 1}${searchParam}`}
aria-disabled={currentPage <= 1} aria-disabled={currentPage <= 1}
> >
<Button <Button
@ -108,7 +113,7 @@ export function ContactsList({
</Button> </Button>
</Link> </Link>
<Link <Link
href={`/contacts?page=${currentPage + 1}`} href={`/contacts?page=${currentPage + 1}${searchParam}`}
aria-disabled={currentPage >= totalPages} aria-disabled={currentPage >= totalPages}
> >
<Button <Button