diff --git a/app/api/shortlinks/route.ts b/app/api/shortlinks/route.ts index 198a845..fc83b20 100644 --- a/app/api/shortlinks/route.ts +++ b/app/api/shortlinks/route.ts @@ -1,9 +1,51 @@ import { NextResponse } from 'next/server'; import { executeQuery } from '@/lib/clickhouse'; +import { NextRequest } from 'next/server'; -export async function GET() { +export async function GET(request: NextRequest) { try { - // Query to fetch shorturl data from ClickHouse + // Get pagination and filter parameters from the URL + const searchParams = request.nextUrl.searchParams; + const page = parseInt(searchParams.get('page') || '1', 10); + const pageSize = parseInt(searchParams.get('page_size') || '10', 10); + const search = searchParams.get('search'); + const team = searchParams.get('team'); + + // Calculate OFFSET + const offset = (page - 1) * pageSize; + + // Build WHERE conditions + const whereConditions = ['deleted_at IS NULL']; + + if (search) { + whereConditions.push(`( + title ILIKE '%${search}%' OR + slug ILIKE '%${search}%' OR + original_url ILIKE '%${search}%' + )`); + } + + if (team) { + whereConditions.push(`hasToken(teams, 'team_id', '${team}')`); + } + + const whereClause = whereConditions.join(' AND '); + + // First query to get total count + const countQuery = ` + SELECT count(*) as total + FROM shorturl_analytics.shorturl + WHERE ${whereClause} + `; + + const countResult = await executeQuery(countQuery); + // Handle the result safely by using an explicit type check + const total = Array.isArray(countResult) && countResult.length > 0 && typeof countResult[0] === 'object' && countResult[0] !== null && 'total' in countResult[0] + ? Number(countResult[0].total) + : 0; + const totalPages = Math.ceil(total / pageSize); + + // Main query with pagination const query = ` SELECT id, @@ -31,17 +73,21 @@ export async function GET() { click_count, unique_visitors FROM shorturl_analytics.shorturl - WHERE deleted_at IS NULL + WHERE ${whereClause} ORDER BY created_at DESC + LIMIT ${pageSize} OFFSET ${offset} `; // Execute the query using the shared client const rows = await executeQuery(query); - // Return the data + // Return the data with pagination metadata return NextResponse.json({ links: rows, - total: rows.length + total: total, + total_pages: totalPages, + page: page, + page_size: pageSize }); } catch (error) { console.error('Error fetching shortlinks from ClickHouse:', error); diff --git a/app/links/page.tsx b/app/links/page.tsx index e0908c8..fd267f0 100644 --- a/app/links/page.tsx +++ b/app/links/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react'; import { getSupabaseClient } from '../utils/supabase'; import { AuthChangeEvent } from '@supabase/supabase-js'; -import { Loader2, ExternalLink, Copy, Search } from 'lucide-react'; +import { Loader2, ExternalLink, Search } from 'lucide-react'; // Define attribute type to avoid using 'any' interface LinkAttributes { @@ -132,14 +132,10 @@ export default function LinksPage() { const [links, setLinks] = useState([]); const [searchQuery, setSearchQuery] = useState(''); const [teamFilter, setTeamFilter] = useState(null); - - // Copy link to clipboard - const copyToClipboard = (text: string) => { - navigator.clipboard.writeText(text).then(() => { - // Could add a notification here - console.log('Copied to clipboard'); - }); - }; + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [totalLinks, setTotalLinks] = useState(0); + const [totalPages, setTotalPages] = useState(0); // Extract link metadata from attributes const getLinkMetadata = (link: ShortLink) => { @@ -197,8 +193,8 @@ export default function LinksPage() { setError(null); try { - // Fetch data from ClickHouse API instead of Supabase - const response = await fetch('/api/shortlinks'); + // Fetch data from ClickHouse API with pagination parameters + const response = await fetch(`/api/shortlinks?page=${currentPage}&page_size=${pageSize}${searchQuery ? `&search=${encodeURIComponent(searchQuery)}` : ''}${teamFilter ? `&team=${encodeURIComponent(teamFilter)}` : ''}`); if (!response.ok) { throw new Error(`Failed to fetch links: ${response.status} ${response.statusText}`); @@ -207,7 +203,11 @@ export default function LinksPage() { const data = await response.json(); if (!data || !data.links || data.links.length === 0) { - if (isMounted) setLinks([]); + if (isMounted) { + setLinks([]); + setTotalLinks(0); + setTotalPages(0); + } return; } @@ -216,6 +216,8 @@ export default function LinksPage() { if (isMounted) { setLinks(convertedLinks); + setTotalLinks(data.total || convertedLinks.length); + setTotalPages(data.total_pages || Math.ceil(data.total / pageSize) || 1); } } catch (err) { if (isMounted) { @@ -248,7 +250,7 @@ export default function LinksPage() { isMounted = false; subscription.unsubscribe(); }; - }, []); + }, [currentPage, pageSize, searchQuery, teamFilter]); if (loading) { return ( @@ -288,13 +290,22 @@ export default function LinksPage() { placeholder="Search links..." value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + setCurrentPage(1); // Reset to page 1 when searching + // Trigger search via useEffect dependency change + } + }} className="w-full rounded-md border border-gray-300 py-2 pl-10 pr-4 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" /> { + // Allow input to be cleared for typing + if (e.target.value === '') { + e.target.value = ''; + } + }} + onBlur={(e) => { + // Ensure a valid value on blur + const value = parseInt(e.target.value, 10); + if (isNaN(value) || value < 1) { + setCurrentPage(1); + } else if (value > totalPages) { + setCurrentPage(totalPages); + } else { + setCurrentPage(value); + } + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + const value = parseInt(e.currentTarget.value, 10); + if (!isNaN(value) && value >= 1 && value <= totalPages) { + setCurrentPage(value); + } else if (!isNaN(value) && value < 1) { + setCurrentPage(1); + } else if (!isNaN(value) && value > totalPages) { + setCurrentPage(totalPages); + } + } + }} + className="w-16 rounded-md border border-gray-300 px-2 py-1 text-sm text-center" + /> + of {totalPages} + + + + + + )} {filteredLinks.length === 0 && links.length > 0 && (