This commit is contained in:
2025-04-07 21:48:24 +08:00
parent 1a9e28bd7e
commit 33dbf62665
7 changed files with 1100 additions and 235 deletions

View File

@@ -0,0 +1,53 @@
import { NextResponse } from 'next/server';
import { executeQuery } from '@/lib/clickhouse';
export async function GET() {
try {
// Query to fetch shorturl data from ClickHouse
const query = `
SELECT
id,
external_id,
type,
slug,
original_url,
title,
description,
attributes,
schema_version,
creator_id,
creator_email,
creator_name,
created_at,
updated_at,
deleted_at,
projects,
teams,
tags,
qr_codes AS qr_codes,
channels,
favorites,
expires_at,
click_count,
unique_visitors
FROM shorturl_analytics.shorturl
WHERE deleted_at IS NULL
ORDER BY created_at DESC
`;
// Execute the query using the shared client
const rows = await executeQuery(query);
// Return the data
return NextResponse.json({
links: rows,
total: rows.length
});
} catch (error) {
console.error('Error fetching shortlinks from ClickHouse:', error);
return NextResponse.json(
{ error: 'Failed to fetch shortlinks' },
{ status: 500 }
);
}
}

View File

@@ -30,6 +30,23 @@ export default function Header() {
</svg>
<span className="text-xl font-bold text-gray-900">ShortURL Analytics</span>
</Link>
{user && (
<nav className="ml-6">
<ul className="flex space-x-4">
<li>
<Link href="/" className="text-sm text-gray-700 hover:text-blue-500">
Dashboard
</Link>
</li>
<li>
<Link href="/links" className="text-sm text-gray-700 hover:text-blue-500">
Short Links
</Link>
</li>
</ul>
</nav>
)}
</div>
{user && (

View File

@@ -1,10 +0,0 @@
import IpLocationTest from '../components/ipLocationTest';
export default function IpTestPage() {
return (
<div className="container mx-auto p-4 max-w-4xl">
<h1 className="text-2xl font-bold mb-6">IP to Location Test</h1>
<IpLocationTest />
</div>
);
}

384
app/links/page.tsx Normal file
View File

@@ -0,0 +1,384 @@
"use client";
import { useEffect, useState } from 'react';
import { getSupabaseClient } from '../utils/supabase';
import { AuthChangeEvent } from '@supabase/supabase-js';
import { Loader2, ExternalLink, Copy, 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);
// Copy link to clipboard
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text).then(() => {
// Could add a notification here
console.log('Copied to clipboard');
});
};
// 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 instead of Supabase
const response = await fetch('/api/shortlinks');
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([]);
return;
}
// Convert ClickHouse data format to ShortLink format
const convertedLinks = data.links.map(convertClickHouseToShortLink);
if (isMounted) {
setLinks(convertedLinks);
}
} 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();
};
}, []);
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)}
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)}
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>
<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>
</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>
<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>
);
})}
</tbody>
</table>
</div>
{/* Pagination or "Load More" could be added here */}
{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>
);
}