"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(null); const [links, setLinks] = useState([]); const [searchQuery, setSearchQuery] = useState(''); const [teamFilter, setTeamFilter] = useState(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()) ).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 (
); } if (error) { return (

Error: {error}

); } if (links.length === 0) { return (

No short links found

Create your first short URL to get started

); } return (

Short URL Links

{/* Search and filters */}
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" />
{/* Links table */}
{filteredLinks.map(link => { const metadata = getLinkMetadata(link); const shortUrl = `https://${metadata.domain}/${metadata.slug}`; return ( ); })}
Link Original URL Team Created
{metadata.title} {shortUrl}
{metadata.originalUrl} {metadata.teamName} {metadata.createdAt}
{/* Pagination */} {totalPages > 0 && (
Showing {((currentPage - 1) * pageSize) + 1} to {Math.min(currentPage * pageSize, totalLinks)} of {totalLinks} results
{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 ( ); })} {/* Page input */}
Go to: { // 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" /> of {totalPages}
)} {filteredLinks.length === 0 && links.length > 0 && (
No links match your search criteria
)}
); }