Add search functionality and data export feature
This commit is contained in:
parent
3894be3a5f
commit
11f7a43d1d
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue