links
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user