Files
shorturl-analytics/app/links/page.tsx
2025-04-07 22:08:12 +08:00

485 lines
17 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';
// 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;
}
// 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;
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() {
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);
// 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 = 'shorturl.example.com';
try {
// 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 name
let teamName = '';
try {
if (link.teams) {
const teams = typeof link.teams === 'string'
? JSON.parse(link.teams)
: link.teams || [];
if (Array.isArray(teams) && teams.length > 0 && teams[0].team_name) {
teamName = teams[0].team_name;
}
}
} catch (e) {
console.error('Error parsing teams:', e);
}
return {
title: link.title || attributes.title || 'Untitled',
slug: link.slug || attributes.slug || '',
domain: domain,
originalUrl: link.original_url || attributes.originalUrl || attributes.original_url || '',
teamName: teamName,
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: '',
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>
<select
value={teamFilter || ''}
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"
>
<option value="">All Teams</option>
{teams.map(team => (
<option key={team.id} value={team.id}>{team.name}</option>
))}
</select>
</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">
<td className="px-6 py-4">
<div className="flex flex-col">
<span className="font-medium text-gray-900">{metadata.title}</span>
<span className="text-xs text-blue-500">{shortUrl}</span>
</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">
{metadata.teamName}
</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>
);
}