671 lines
23 KiB
TypeScript
671 lines
23 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState } from 'react';
|
||
import { getSupabaseClient } from '../utils/supabase';
|
||
import { AuthChangeEvent } from '@supabase/supabase-js';
|
||
import { Loader2, ExternalLink, Search } from 'lucide-react';
|
||
import { TeamSelector } from '@/app/components/ui/TeamSelector';
|
||
import { useRouter } from 'next/navigation';
|
||
import { useShortUrlStore, ShortUrlData } from '@/app/utils/store';
|
||
import ClientRouteGuard from '@/app/components/ClientRouteGuard';
|
||
|
||
// Define attribute type to avoid using 'any'
|
||
interface LinkAttributes {
|
||
title?: string;
|
||
name?: string;
|
||
slug?: string;
|
||
original_url?: string;
|
||
originalUrl?: string;
|
||
visits?: number;
|
||
click_count?: number;
|
||
team_id?: string;
|
||
team_name?: string;
|
||
tags?: string[];
|
||
[key: string]: unknown;
|
||
}
|
||
|
||
// 更新 ShortLink 类型定义以匹配 ClickHouse 数据结构
|
||
interface ShortLink {
|
||
id: string;
|
||
external_id?: string;
|
||
type?: string;
|
||
slug?: string;
|
||
original_url?: string;
|
||
title?: string;
|
||
description?: string;
|
||
attributes: string | Record<string, unknown>;
|
||
schema_version?: number;
|
||
creator_id?: string;
|
||
creator_email?: string;
|
||
creator_name?: string;
|
||
created_at: string;
|
||
updated_at?: string;
|
||
deleted_at?: string | null;
|
||
projects?: string | Record<string, unknown>[];
|
||
teams?: string | Record<string, unknown>[];
|
||
tags?: string | Record<string, unknown>[];
|
||
qr_codes?: string | Record<string, unknown>[];
|
||
channels?: string | Record<string, unknown>[];
|
||
favorites?: string | Record<string, unknown>[];
|
||
expires_at?: string | null;
|
||
click_count?: number;
|
||
unique_visitors?: number;
|
||
domain?: string;
|
||
}
|
||
|
||
// Define ClickHouse shorturl type
|
||
interface ClickHouseShortUrl {
|
||
id: string;
|
||
external_id: string;
|
||
type: string;
|
||
slug: string;
|
||
original_url: string;
|
||
title: string;
|
||
description: string;
|
||
attributes: string; // JSON string
|
||
schema_version: number;
|
||
creator_id: string;
|
||
creator_email: string;
|
||
creator_name: string;
|
||
created_at: string;
|
||
updated_at: string;
|
||
deleted_at: string | null;
|
||
projects: string; // JSON string
|
||
teams: string; // JSON string
|
||
tags: string; // JSON string
|
||
qr_codes: string; // JSON string
|
||
channels: string; // JSON string
|
||
favorites: string; // JSON string
|
||
expires_at: string | null;
|
||
click_count: number;
|
||
unique_visitors: number;
|
||
domain?: string; // 添加domain字段
|
||
link_attributes?: string; // Optional JSON string containing link-specific attributes
|
||
}
|
||
|
||
// 示例团队数据 - 实际应用中应从API获取
|
||
const teams = [
|
||
{ id: 'marketing', name: 'Marketing' },
|
||
{ id: 'sales', name: 'Sales' },
|
||
{ id: 'product', name: 'Product' },
|
||
{ id: 'engineering', name: 'Engineering' }
|
||
];
|
||
|
||
// 将 ClickHouse 数据转换为 ShortLink 格式
|
||
const convertClickHouseToShortLink = (data: Record<string, unknown>): ShortLink => {
|
||
return {
|
||
...data as any, // 使用类型断言处理泛型记录转换
|
||
// 确保关键字段存在
|
||
id: data.id as string || '',
|
||
created_at: data.created_at as string || new Date().toISOString(),
|
||
attributes: data.attributes || '{}'
|
||
};
|
||
};
|
||
|
||
export default function LinksPage() {
|
||
return (
|
||
<ClientRouteGuard>
|
||
<LinksPageContent />
|
||
</ClientRouteGuard>
|
||
);
|
||
}
|
||
|
||
function LinksPageContent() {
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [links, setLinks] = useState<ShortLink[]>([]);
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [teamFilter, setTeamFilter] = useState<string | null>(null);
|
||
const [currentPage, setCurrentPage] = useState(1);
|
||
const [pageSize, setPageSize] = useState(10);
|
||
const [totalLinks, setTotalLinks] = useState(0);
|
||
const [totalPages, setTotalPages] = useState(0);
|
||
const [searchDebounce, setSearchDebounce] = useState<NodeJS.Timeout | null>(null);
|
||
const router = useRouter();
|
||
|
||
// 使用 Zustand store
|
||
const { setSelectedShortUrl } = useShortUrlStore();
|
||
|
||
// 处理点击链接行
|
||
const handleRowClick = (link: any) => {
|
||
// 解析 attributes 字符串为对象
|
||
let attributes: Record<string, any> = {};
|
||
try {
|
||
if (link.attributes && typeof link.attributes === 'string') {
|
||
attributes = JSON.parse(link.attributes || '{}');
|
||
}
|
||
} catch (e) {
|
||
console.error('Error parsing link attributes:', e);
|
||
}
|
||
|
||
// 解析 teams 字符串为数组
|
||
let teams: any[] = [];
|
||
try {
|
||
if (link.teams && typeof link.teams === 'string') {
|
||
teams = JSON.parse(link.teams || '[]');
|
||
}
|
||
} catch (e) {
|
||
console.error('Error parsing teams:', e);
|
||
}
|
||
|
||
// 解析 projects 字符串为数组
|
||
let projects: any[] = [];
|
||
try {
|
||
if (link.projects && typeof link.projects === 'string') {
|
||
projects = JSON.parse(link.projects || '[]');
|
||
}
|
||
} catch (e) {
|
||
console.error('Error parsing projects:', e);
|
||
}
|
||
|
||
// 解析 tags 字符串为数组
|
||
let tags: string[] = [];
|
||
try {
|
||
if (link.tags && typeof link.tags === 'string') {
|
||
const parsedTags = JSON.parse(link.tags);
|
||
if (Array.isArray(parsedTags)) {
|
||
tags = parsedTags.map((tag: { tag_name?: string }) => tag.tag_name || '');
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('Error parsing tags:', e);
|
||
}
|
||
|
||
// 确保 shortUrl 存在
|
||
const shortUrlValue = attributes.shortUrl || '';
|
||
|
||
// 提取用于显示的字段
|
||
const shortUrlData = {
|
||
id: link.id,
|
||
externalId: link.external_id, // 明确添加 externalId 字段
|
||
slug: link.slug,
|
||
originalUrl: link.original_url,
|
||
title: link.title,
|
||
shortUrl: shortUrlValue,
|
||
teams: teams,
|
||
projects: projects,
|
||
tags: tags,
|
||
createdAt: link.created_at,
|
||
domain: link.domain || (shortUrlValue ? new URL(shortUrlValue).hostname : '')
|
||
};
|
||
|
||
// 打印完整数据,确保 externalId 被包含
|
||
console.log('Setting shortURL data in store with externalId:', link.external_id);
|
||
|
||
// 将数据保存到 Zustand store
|
||
setSelectedShortUrl(shortUrlData);
|
||
|
||
// 导航到分析页面,并在 URL 中包含 shortUrl 参数
|
||
router.push(`/analytics?shorturl=${encodeURIComponent(shortUrlValue)}`);
|
||
};
|
||
|
||
// Extract link metadata from attributes
|
||
const getLinkMetadata = (link: ShortLink) => {
|
||
try {
|
||
// Parse attributes if it's a string
|
||
const attributes = typeof link.attributes === 'string'
|
||
? JSON.parse(link.attributes)
|
||
: link.attributes || {};
|
||
|
||
// Parse attributes to get domain if available
|
||
let domain = '';
|
||
try {
|
||
// 首先尝试使用link.domain字段
|
||
if (link.domain) {
|
||
domain = link.domain;
|
||
}
|
||
// 如果没有domain字段,从shortUrl中提取
|
||
else {
|
||
// Extract domain from shortUrl in attributes if available
|
||
const attributesObj = typeof link.attributes === 'string'
|
||
? JSON.parse(link.attributes)
|
||
: link.attributes || {};
|
||
|
||
if (attributesObj.shortUrl) {
|
||
try {
|
||
const urlObj = new URL(attributesObj.shortUrl);
|
||
domain = urlObj.hostname;
|
||
} catch (e) {
|
||
console.error('Error parsing shortUrl:', e);
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('Error parsing attributes:', e);
|
||
}
|
||
|
||
// Get team names
|
||
const teamNames: string[] = [];
|
||
try {
|
||
if (link.teams) {
|
||
const teams = typeof link.teams === 'string'
|
||
? JSON.parse(link.teams)
|
||
: link.teams || [];
|
||
|
||
if (Array.isArray(teams)) {
|
||
teams.forEach(team => {
|
||
if (team.team_name) {
|
||
teamNames.push(team.team_name);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('Error parsing teams:', e);
|
||
}
|
||
|
||
// Get project names
|
||
const projectNames: string[] = [];
|
||
try {
|
||
if (link.projects) {
|
||
const projects = typeof link.projects === 'string'
|
||
? JSON.parse(link.projects)
|
||
: link.projects || [];
|
||
|
||
if (Array.isArray(projects)) {
|
||
projects.forEach(project => {
|
||
if (project.project_name) {
|
||
projectNames.push(project.project_name);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('Error parsing projects:', e);
|
||
}
|
||
|
||
// Get tag names
|
||
const tagNames: string[] = [];
|
||
try {
|
||
if (link.tags) {
|
||
const tags = typeof link.tags === 'string'
|
||
? JSON.parse(link.tags)
|
||
: link.tags || [];
|
||
|
||
if (Array.isArray(tags)) {
|
||
tags.forEach(tag => {
|
||
if (tag.tag_name) {
|
||
tagNames.push(tag.tag_name);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('Error parsing tags:', e);
|
||
}
|
||
|
||
return {
|
||
title: link.title || attributes.title || 'Untitled',
|
||
slug: link.slug || attributes.slug || '',
|
||
domain: domain,
|
||
originalUrl: link.original_url || attributes.originalUrl || attributes.original_url || '',
|
||
teamNames: teamNames,
|
||
projectNames: projectNames,
|
||
tagNames: tagNames,
|
||
teamName: teamNames[0] || '', // Keep for backward compatibility
|
||
createdAt: new Date(link.created_at).toLocaleDateString(),
|
||
visits: link.click_count || 0
|
||
};
|
||
} catch (error) {
|
||
console.error('Error parsing link metadata:', error);
|
||
return {
|
||
title: 'Error parsing data',
|
||
slug: '',
|
||
domain: 'shorturl.example.com',
|
||
originalUrl: '',
|
||
teamNames: [],
|
||
projectNames: [],
|
||
tagNames: [],
|
||
teamName: '',
|
||
createdAt: '',
|
||
visits: 0
|
||
};
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
let isMounted = true;
|
||
|
||
const fetchLinks = async () => {
|
||
if (!isMounted) return;
|
||
setLoading(true);
|
||
setError(null);
|
||
|
||
try {
|
||
// 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}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
|
||
if (!data || !data.links || data.links.length === 0) {
|
||
if (isMounted) {
|
||
setLinks([]);
|
||
setTotalLinks(0);
|
||
setTotalPages(0);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Convert ClickHouse data format to ShortLink format
|
||
const convertedLinks = data.links.map(convertClickHouseToShortLink);
|
||
|
||
if (isMounted) {
|
||
setLinks(convertedLinks);
|
||
setTotalLinks(data.total || convertedLinks.length);
|
||
setTotalPages(data.total_pages || Math.ceil(data.total / pageSize) || 1);
|
||
}
|
||
} catch (err) {
|
||
if (isMounted) {
|
||
setError(err instanceof Error ? err.message : 'Failed to load short URLs');
|
||
console.error("Error fetching links:", err);
|
||
}
|
||
} finally {
|
||
if (isMounted) {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
};
|
||
|
||
// Subscribe to user auth state
|
||
const supabase = getSupabaseClient();
|
||
const { data: { subscription } } = supabase.auth.onAuthStateChange(
|
||
(event: AuthChangeEvent) => {
|
||
if (event === 'SIGNED_IN' || event === 'USER_UPDATED') {
|
||
fetchLinks();
|
||
}
|
||
if (event === 'SIGNED_OUT') {
|
||
setLinks([]);
|
||
}
|
||
}
|
||
);
|
||
|
||
fetchLinks();
|
||
|
||
return () => {
|
||
isMounted = false;
|
||
subscription.unsubscribe();
|
||
};
|
||
}, [currentPage, pageSize, searchQuery, teamFilter]);
|
||
|
||
// Handle search input with debounce
|
||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const value = e.target.value;
|
||
|
||
// Clear any existing timeout
|
||
if (searchDebounce) {
|
||
clearTimeout(searchDebounce);
|
||
}
|
||
|
||
// Set the input value immediately for UI feedback
|
||
setSearchQuery(value);
|
||
|
||
// Set a timeout to actually perform the search
|
||
setSearchDebounce(setTimeout(() => {
|
||
setCurrentPage(1); // Reset to page 1 when searching
|
||
}, 500)); // 500ms debounce
|
||
};
|
||
|
||
if (loading && links.length === 0) {
|
||
return (
|
||
<div className="flex h-96 w-full items-center justify-center">
|
||
<Loader2 className="h-8 w-8 animate-spin text-gray-500" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="flex h-96 w-full flex-col items-center justify-center text-red-500">
|
||
<p>Error loading shortcuts: {error}</p>
|
||
<button
|
||
onClick={() => window.location.reload()}
|
||
className="mt-4 rounded-md bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
|
||
>
|
||
Retry
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="container mx-auto px-4 py-8">
|
||
<h1 className="mb-6 text-2xl font-bold text-gray-900">Short URL Links</h1>
|
||
|
||
{/* Search and filters */}
|
||
<div className="mb-6 flex flex-wrap items-center gap-4">
|
||
<div className="relative flex-grow">
|
||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||
<input
|
||
type="text"
|
||
placeholder="Search links..."
|
||
value={searchQuery}
|
||
onChange={handleSearchChange}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') {
|
||
setCurrentPage(1); // Reset to page 1 when searching
|
||
}
|
||
}}
|
||
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 className="flex items-center gap-2">
|
||
<TeamSelector
|
||
value={teamFilter || ''}
|
||
onChange={(value) => {
|
||
// 如果是多选模式,值将是数组。对于空数组,设置为 null
|
||
if (Array.isArray(value)) {
|
||
setTeamFilter(value.length > 0 ? value[0] : null);
|
||
} else {
|
||
setTeamFilter(value || null);
|
||
}
|
||
setCurrentPage(1); // Reset to page 1 when filtering
|
||
}}
|
||
className="w-64"
|
||
multiple={true}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Links table */}
|
||
<div className="overflow-hidden rounded-lg border border-gray-200 shadow">
|
||
<table className="min-w-full divide-y divide-gray-200">
|
||
<thead className="bg-gray-50">
|
||
<tr>
|
||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">Link</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">Created</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-200 bg-white">
|
||
{links.map(link => {
|
||
const metadata = getLinkMetadata(link);
|
||
const shortUrl = `https://${metadata.domain}/${metadata.slug}`;
|
||
|
||
return (
|
||
<tr key={link.id} className="hover:bg-gray-50 cursor-pointer" onClick={() => handleRowClick(link)}>
|
||
<td className="px-6 py-4">
|
||
<div className="flex flex-col space-y-1">
|
||
<span className="font-medium text-gray-900">{metadata.title}</span>
|
||
<span className="text-xs text-blue-500">{shortUrl}</span>
|
||
|
||
{/* Tags */}
|
||
{metadata.tagNames.length > 0 && (
|
||
<div className="flex flex-wrap gap-1.5 mt-1">
|
||
{metadata.tagNames.map((tag, index) => (
|
||
<span key={index} className="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-800">
|
||
{tag}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</td>
|
||
<td className="px-6 py-4 text-sm text-gray-500">
|
||
<a
|
||
href={metadata.originalUrl}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="flex items-center hover:text-blue-500"
|
||
>
|
||
<span className="max-w-xs truncate">{metadata.originalUrl}</span>
|
||
<ExternalLink className="ml-1 h-3 w-3" />
|
||
</a>
|
||
</td>
|
||
<td className="px-6 py-4 text-sm text-gray-500">
|
||
<div className="flex flex-col space-y-1">
|
||
{/* Teams */}
|
||
{metadata.teamNames.length > 0 ? (
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{metadata.teamNames.map((team, index) => (
|
||
<span key={index} className="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800">
|
||
{team}
|
||
</span>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<span>-</span>
|
||
)}
|
||
|
||
{/* Projects */}
|
||
{metadata.projectNames.length > 0 && (
|
||
<div className="flex flex-wrap gap-1.5 mt-1">
|
||
{metadata.projectNames.map((project, index) => (
|
||
<span key={index} className="inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800">
|
||
{project}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</td>
|
||
<td className="px-6 py-4 text-sm text-gray-500">
|
||
{metadata.createdAt}
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* 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>
|
||
)}
|
||
|
||
{links.length === 0 && (
|
||
<div className="mt-6 rounded-md bg-gray-50 p-6 text-center text-gray-500">
|
||
No links match your search criteria
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|