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) {
|
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">
|
||||||
<Input
|
<form action="/contacts" method="GET">
|
||||||
type="search"
|
<Input
|
||||||
placeholder="Search contacts..."
|
type="search"
|
||||||
className="pr-8"
|
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>
|
</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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
<Button variant="outline" className="w-full">
|
||||||
<Button asChild variant="outline" className="h-auto py-4">
|
Add New Contact
|
||||||
<Link href="/contacts/export">
|
</Button>
|
||||||
<div className="flex flex-col items-start">
|
<Button variant="outline" className="w-full">
|
||||||
<span className="font-medium">Export Data</span>
|
Import Contacts
|
||||||
<span className="text-sm text-muted-foreground">Download contact data</span>
|
</Button>
|
||||||
</div>
|
</CardContent>
|
||||||
</Link>
|
</Card>
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<footer className="w-full border-t py-6">
|
<footer className="w-full border-t py-6">
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue