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

View File

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

View File

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