This commit is contained in:
2025-04-07 21:54:05 +08:00
parent 33dbf62665
commit 523e99a001
2 changed files with 182 additions and 45 deletions

View File

@@ -1,9 +1,51 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { executeQuery } from '@/lib/clickhouse'; import { executeQuery } from '@/lib/clickhouse';
import { NextRequest } from 'next/server';
export async function GET() { export async function GET(request: NextRequest) {
try { 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 = ` const query = `
SELECT SELECT
id, id,
@@ -31,17 +73,21 @@ export async function GET() {
click_count, click_count,
unique_visitors unique_visitors
FROM shorturl_analytics.shorturl FROM shorturl_analytics.shorturl
WHERE deleted_at IS NULL WHERE ${whereClause}
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT ${pageSize} OFFSET ${offset}
`; `;
// Execute the query using the shared client // Execute the query using the shared client
const rows = await executeQuery(query); const rows = await executeQuery(query);
// Return the data // Return the data with pagination metadata
return NextResponse.json({ return NextResponse.json({
links: rows, links: rows,
total: rows.length total: total,
total_pages: totalPages,
page: page,
page_size: pageSize
}); });
} catch (error) { } catch (error) {
console.error('Error fetching shortlinks from ClickHouse:', error); console.error('Error fetching shortlinks from ClickHouse:', error);

View File

@@ -3,7 +3,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { getSupabaseClient } from '../utils/supabase'; import { getSupabaseClient } from '../utils/supabase';
import { AuthChangeEvent } from '@supabase/supabase-js'; 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' // Define attribute type to avoid using 'any'
interface LinkAttributes { interface LinkAttributes {
@@ -132,14 +132,10 @@ export default function LinksPage() {
const [links, setLinks] = useState<ShortLink[]>([]); const [links, setLinks] = useState<ShortLink[]>([]);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [teamFilter, setTeamFilter] = useState<string | null>(null); const [teamFilter, setTeamFilter] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1);
// Copy link to clipboard const [pageSize, setPageSize] = useState(10);
const copyToClipboard = (text: string) => { const [totalLinks, setTotalLinks] = useState(0);
navigator.clipboard.writeText(text).then(() => { const [totalPages, setTotalPages] = useState(0);
// Could add a notification here
console.log('Copied to clipboard');
});
};
// Extract link metadata from attributes // Extract link metadata from attributes
const getLinkMetadata = (link: ShortLink) => { const getLinkMetadata = (link: ShortLink) => {
@@ -197,8 +193,8 @@ export default function LinksPage() {
setError(null); setError(null);
try { try {
// Fetch data from ClickHouse API instead of Supabase // Fetch data from ClickHouse API with pagination parameters
const response = await fetch('/api/shortlinks'); const response = await fetch(`/api/shortlinks?page=${currentPage}&page_size=${pageSize}${searchQuery ? `&search=${encodeURIComponent(searchQuery)}` : ''}${teamFilter ? `&team=${encodeURIComponent(teamFilter)}` : ''}`);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch links: ${response.status} ${response.statusText}`); throw new Error(`Failed to fetch links: ${response.status} ${response.statusText}`);
@@ -207,7 +203,11 @@ export default function LinksPage() {
const data = await response.json(); const data = await response.json();
if (!data || !data.links || data.links.length === 0) { if (!data || !data.links || data.links.length === 0) {
if (isMounted) setLinks([]); if (isMounted) {
setLinks([]);
setTotalLinks(0);
setTotalPages(0);
}
return; return;
} }
@@ -216,6 +216,8 @@ export default function LinksPage() {
if (isMounted) { if (isMounted) {
setLinks(convertedLinks); setLinks(convertedLinks);
setTotalLinks(data.total || convertedLinks.length);
setTotalPages(data.total_pages || Math.ceil(data.total / pageSize) || 1);
} }
} catch (err) { } catch (err) {
if (isMounted) { if (isMounted) {
@@ -248,7 +250,7 @@ export default function LinksPage() {
isMounted = false; isMounted = false;
subscription.unsubscribe(); subscription.unsubscribe();
}; };
}, []); }, [currentPage, pageSize, searchQuery, teamFilter]);
if (loading) { if (loading) {
return ( return (
@@ -288,13 +290,22 @@ export default function LinksPage() {
placeholder="Search links..." placeholder="Search links..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} 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" 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"
/> />
</div> </div>
<select <select
value={teamFilter || ''} value={teamFilter || ''}
onChange={(e) => setTeamFilter(e.target.value || null)} onChange={(e) => {
setTeamFilter(e.target.value || null);
setCurrentPage(1); // Reset to page 1 when filtering
}}
className="rounded-md border border-gray-300 py-2 pl-3 pr-10 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" className="rounded-md border border-gray-300 py-2 pl-3 pr-10 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
> >
<option value="">All Teams</option> <option value="">All Teams</option>
@@ -313,8 +324,6 @@ export default function LinksPage() {
<th scope="col" className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">Original URL</th> <th scope="col" className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">Original URL</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">Team</th> <th scope="col" className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">Team</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">Created</th> <th scope="col" className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">Created</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">Visits</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-200 bg-white"> <tbody className="divide-y divide-gray-200 bg-white">
@@ -342,29 +351,6 @@ export default function LinksPage() {
<td className="px-6 py-4 text-sm text-gray-500"> <td className="px-6 py-4 text-sm text-gray-500">
{metadata.createdAt} {metadata.createdAt}
</td> </td>
<td className="px-6 py-4 text-sm text-gray-500">
{metadata.visits}
</td>
<td className="px-6 py-4 text-sm">
<div className="flex space-x-2">
<button
onClick={() => copyToClipboard(shortUrl)}
className="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-500"
title="Copy link"
>
<Copy className="h-5 w-5" />
</button>
<a
href={shortUrl}
target="_blank"
rel="noopener noreferrer"
className="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-500"
title="Open link"
>
<ExternalLink className="h-5 w-5" />
</a>
</div>
</td>
</tr> </tr>
); );
})} })}
@@ -372,7 +358,112 @@ export default function LinksPage() {
</table> </table>
</div> </div>
{/* Pagination or "Load More" could be added here */} {/* Pagination */}
{totalPages > 0 && (
<div className="mt-6 flex items-center justify-between">
<div className="text-sm text-gray-500">
Showing {((currentPage - 1) * pageSize) + 1} to {Math.min(currentPage * pageSize, totalLinks)} of {totalLinks} results
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
disabled={currentPage === 1}
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm disabled:opacity-50"
>
Previous
</button>
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
// Create a window of 5 pages around current page
let pageNumber;
if (totalPages <= 5) {
pageNumber = i + 1;
} else {
const start = Math.max(1, currentPage - 2);
const end = Math.min(totalPages, start + 4);
pageNumber = start + i;
if (pageNumber > end) return null;
}
return (
<button
key={pageNumber}
onClick={() => setCurrentPage(pageNumber)}
className={`h-8 w-8 rounded-md text-sm ${
currentPage === pageNumber
? 'bg-blue-500 text-white'
: 'border border-gray-300 text-gray-700 hover:bg-gray-50'
}`}
>
{pageNumber}
</button>
);
})}
<button
onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))}
disabled={currentPage === totalPages}
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm disabled:opacity-50"
>
Next
</button>
{/* Page input */}
<div className="ml-4 flex items-center space-x-1">
<span className="text-sm text-gray-500">Go to:</span>
<input
type="number"
min="1"
max={totalPages}
value={currentPage}
onChange={(e) => {
// 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"
/>
<span className="text-sm text-gray-500">of {totalPages}</span>
</div>
<select
value={pageSize}
onChange={(e) => {
setPageSize(Number(e.target.value));
setCurrentPage(1); // Reset to page 1 when changing page size
}}
className="ml-4 rounded-md border border-gray-300 py-1.5 pl-3 pr-8 text-sm"
>
<option value="10">10 per page</option>
<option value="25">25 per page</option>
<option value="50">50 per page</option>
<option value="100">100 per page</option>
</select>
</div>
</div>
)}
{filteredLinks.length === 0 && links.length > 0 && ( {filteredLinks.length === 0 && links.length > 0 && (
<div className="mt-6 rounded-md bg-gray-50 p-6 text-center text-gray-500"> <div className="mt-6 rounded-md bg-gray-50 p-6 text-center text-gray-500">