links
This commit is contained in:
53
app/api/shortlinks/route.ts
Normal file
53
app/api/shortlinks/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
384
app/links/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user