475 lines
17 KiB
TypeScript
475 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;
|
|
}
|
|
|
|
// Define Link type based on the database schema
|
|
interface ShortLink {
|
|
id: string;
|
|
external_id: string | null;
|
|
attributes: LinkAttributes | null;
|
|
type: string | null;
|
|
creator_id: string | null;
|
|
created_at: string | null;
|
|
updated_at: string | null;
|
|
deleted_at: string | null;
|
|
schema_version: number | null;
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Convert ClickHouse data to ShortLink format
|
|
function convertClickHouseToShortLink(chData: ClickHouseShortUrl): ShortLink {
|
|
// Parse JSON strings
|
|
const attributesJson = JSON.parse(chData.attributes || '{}');
|
|
const teamsJson = JSON.parse(chData.teams || '[]');
|
|
const tagsJson = JSON.parse(chData.tags || '[]');
|
|
|
|
// Extract team info from the first team if available
|
|
const teamInfo = teamsJson.length > 0 ? {
|
|
team_id: teamsJson[0].team_id,
|
|
team_name: teamsJson[0].team_name
|
|
} : {};
|
|
|
|
// Extract tag names
|
|
const tagNames = tagsJson.map((tag: { tag_name: string }) => tag.tag_name);
|
|
|
|
// Parse link_attributes to get domain if available
|
|
let domain = 'shorturl.example.com';
|
|
try {
|
|
if (chData.link_attributes) {
|
|
const linkAttrObj = JSON.parse(chData.link_attributes);
|
|
if (linkAttrObj.domain) {
|
|
domain = linkAttrObj.domain;
|
|
}
|
|
}
|
|
|
|
// Extract domain from shortUrl in attributes if available
|
|
const attributesObj = JSON.parse(chData.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 link_attributes:', e);
|
|
}
|
|
|
|
return {
|
|
id: chData.id,
|
|
external_id: chData.external_id,
|
|
type: chData.type,
|
|
creator_id: chData.creator_id,
|
|
created_at: chData.created_at,
|
|
updated_at: chData.updated_at,
|
|
deleted_at: chData.deleted_at,
|
|
schema_version: chData.schema_version,
|
|
attributes: {
|
|
...attributesJson,
|
|
title: chData.title || attributesJson.title || '',
|
|
slug: chData.slug,
|
|
original_url: chData.original_url,
|
|
click_count: chData.click_count,
|
|
visits: chData.click_count,
|
|
unique_visitors: chData.unique_visitors,
|
|
domain: attributesJson.domain || domain,
|
|
...teamInfo,
|
|
tags: tagNames
|
|
}
|
|
};
|
|
}
|
|
|
|
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);
|
|
|
|
// Extract link metadata from attributes
|
|
const getLinkMetadata = (link: ShortLink) => {
|
|
const attrs = link.attributes || {};
|
|
return {
|
|
title: attrs.title || attrs.name || 'Untitled Link',
|
|
slug: attrs.slug || 'no-slug',
|
|
originalUrl: attrs.original_url || attrs.originalUrl || '#',
|
|
visits: attrs.visits || attrs.click_count || 0,
|
|
teamId: attrs.team_id || '',
|
|
teamName: attrs.team_name || 'Personal',
|
|
createdAt: new Date(link.created_at || Date.now()).toLocaleDateString(),
|
|
tags: attrs.tags || [],
|
|
domain: attrs.domain || 'shorturl.example.com',
|
|
};
|
|
};
|
|
|
|
// Filter links by search query
|
|
const filteredLinks = links.length > 0 ?
|
|
links.filter(link => {
|
|
if (!searchQuery && !teamFilter) return true;
|
|
|
|
const metadata = getLinkMetadata(link);
|
|
const matchesSearch = searchQuery ?
|
|
(metadata.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
metadata.slug.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
metadata.originalUrl.toLowerCase().includes(searchQuery.toLowerCase())) :
|
|
true;
|
|
|
|
const matchesTeam = teamFilter ?
|
|
metadata.teamId === teamFilter :
|
|
true;
|
|
|
|
return matchesSearch && matchesTeam;
|
|
}) : [];
|
|
|
|
// Get unique teams for filtering
|
|
const teams = links.length > 0 ?
|
|
Array.from(
|
|
links.reduce((teams, link) => {
|
|
const metadata = getLinkMetadata(link);
|
|
if (metadata.teamId) {
|
|
teams.set(metadata.teamId, metadata.teamName);
|
|
}
|
|
return teams;
|
|
}, new Map<string, string>())
|
|
).map(([id, name]) => ({ id, name })) : [];
|
|
|
|
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);
|
|
}
|
|
}
|
|
};
|
|
|
|
const supabase = getSupabaseClient();
|
|
|
|
const { data: { subscription } } = supabase.auth.onAuthStateChange((event: AuthChangeEvent) => {
|
|
if (event === 'SIGNED_IN') {
|
|
fetchLinks();
|
|
} else if (event === 'SIGNED_OUT') {
|
|
setLinks([]);
|
|
setError(null);
|
|
}
|
|
});
|
|
|
|
supabase.auth.getSession().then(() => {
|
|
fetchLinks();
|
|
});
|
|
|
|
return () => {
|
|
isMounted = false;
|
|
subscription.unsubscribe();
|
|
};
|
|
}, [currentPage, pageSize, searchQuery, teamFilter]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex w-full items-center justify-center p-12">
|
|
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="flex w-full items-center justify-center p-8 text-red-500">
|
|
<p>Error: {error}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (links.length === 0) {
|
|
return (
|
|
<div className="flex w-full flex-col items-center justify-center p-12 text-gray-500">
|
|
<p className="mb-4 text-xl">No short links found</p>
|
|
<p>Create your first short URL to get started</p>
|
|
</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={(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"
|
|
/>
|
|
</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">
|
|
{filteredLinks.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="whitespace-nowrap px-6 py-4">
|
|
<div className="flex flex-col">
|
|
<span className="font-medium text-blue-600">{metadata.title}</span>
|
|
<span className="text-xs text-gray-500">{shortUrl}</span>
|
|
</div>
|
|
</td>
|
|
<td className="max-w-xs truncate 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">
|
|
{metadata.originalUrl}
|
|
<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>
|
|
)}
|
|
|
|
{filteredLinks.length === 0 && 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>
|
|
);
|
|
}
|