links search
This commit is contained in:
@@ -18,10 +18,14 @@ export async function GET(request: NextRequest) {
|
|||||||
const whereConditions = ['deleted_at IS NULL'];
|
const whereConditions = ['deleted_at IS NULL'];
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
|
// Expand search to include more fields: slug, shortUrl in attributes, team name, tag name, original_url
|
||||||
whereConditions.push(`(
|
whereConditions.push(`(
|
||||||
title ILIKE '%${search}%' OR
|
|
||||||
slug ILIKE '%${search}%' OR
|
slug ILIKE '%${search}%' OR
|
||||||
original_url ILIKE '%${search}%'
|
original_url ILIKE '%${search}%' OR
|
||||||
|
title ILIKE '%${search}%' OR
|
||||||
|
JSONHas(attributes, 'shortUrl') AND JSONExtractString(attributes, 'shortUrl') ILIKE '%${search}%' OR
|
||||||
|
arrayExists(x -> JSONExtractString(x, 'team_name') ILIKE '%${search}%', JSONExtractArrayRaw(teams)) OR
|
||||||
|
arrayExists(x -> JSONExtractString(x, 'tag_name') ILIKE '%${search}%', JSONExtractArrayRaw(tags))
|
||||||
)`);
|
)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,17 +20,32 @@ interface LinkAttributes {
|
|||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Define Link type based on the database schema
|
// 更新 ShortLink 类型定义以匹配 ClickHouse 数据结构
|
||||||
interface ShortLink {
|
interface ShortLink {
|
||||||
id: string;
|
id: string;
|
||||||
external_id: string | null;
|
external_id?: string;
|
||||||
attributes: LinkAttributes | null;
|
type?: string;
|
||||||
type: string | null;
|
slug?: string;
|
||||||
creator_id: string | null;
|
original_url?: string;
|
||||||
created_at: string | null;
|
title?: string;
|
||||||
updated_at: string | null;
|
description?: string;
|
||||||
deleted_at: string | null;
|
attributes: string | Record<string, unknown>;
|
||||||
schema_version: number | null;
|
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
|
// Define ClickHouse shorturl type
|
||||||
@@ -62,69 +77,24 @@ interface ClickHouseShortUrl {
|
|||||||
link_attributes?: string; // Optional JSON string containing link-specific attributes
|
link_attributes?: string; // Optional JSON string containing link-specific attributes
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert ClickHouse data to ShortLink format
|
// 示例团队数据 - 实际应用中应从API获取
|
||||||
function convertClickHouseToShortLink(chData: ClickHouseShortUrl): ShortLink {
|
const teams = [
|
||||||
// Parse JSON strings
|
{ id: 'marketing', name: 'Marketing' },
|
||||||
const attributesJson = JSON.parse(chData.attributes || '{}');
|
{ id: 'sales', name: 'Sales' },
|
||||||
const teamsJson = JSON.parse(chData.teams || '[]');
|
{ id: 'product', name: 'Product' },
|
||||||
const tagsJson = JSON.parse(chData.tags || '[]');
|
{ id: 'engineering', name: 'Engineering' }
|
||||||
|
];
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// 将 ClickHouse 数据转换为 ShortLink 格式
|
||||||
|
const convertClickHouseToShortLink = (data: Record<string, unknown>): ShortLink => {
|
||||||
return {
|
return {
|
||||||
id: chData.id,
|
...data as any, // 使用类型断言处理泛型记录转换
|
||||||
external_id: chData.external_id,
|
// 确保关键字段存在
|
||||||
type: chData.type,
|
id: data.id as string || '',
|
||||||
creator_id: chData.creator_id,
|
created_at: data.created_at as string || new Date().toISOString(),
|
||||||
created_at: chData.created_at,
|
attributes: data.attributes || '{}'
|
||||||
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() {
|
export default function LinksPage() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -136,53 +106,74 @@ export default function LinksPage() {
|
|||||||
const [pageSize, setPageSize] = useState(10);
|
const [pageSize, setPageSize] = useState(10);
|
||||||
const [totalLinks, setTotalLinks] = useState(0);
|
const [totalLinks, setTotalLinks] = useState(0);
|
||||||
const [totalPages, setTotalPages] = useState(0);
|
const [totalPages, setTotalPages] = useState(0);
|
||||||
|
const [searchDebounce, setSearchDebounce] = useState<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
// Extract link metadata from attributes
|
// Extract link metadata from attributes
|
||||||
const getLinkMetadata = (link: ShortLink) => {
|
const getLinkMetadata = (link: ShortLink) => {
|
||||||
const attrs = link.attributes || {};
|
try {
|
||||||
return {
|
// Parse attributes if it's a string
|
||||||
title: attrs.title || attrs.name || 'Untitled Link',
|
const attributes = typeof link.attributes === 'string'
|
||||||
slug: attrs.slug || 'no-slug',
|
? JSON.parse(link.attributes)
|
||||||
originalUrl: attrs.original_url || attrs.originalUrl || '#',
|
: link.attributes || {};
|
||||||
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
|
// Parse attributes to get domain if available
|
||||||
const filteredLinks = links.length > 0 ?
|
let domain = 'shorturl.example.com';
|
||||||
links.filter(link => {
|
try {
|
||||||
if (!searchQuery && !teamFilter) return true;
|
// Extract domain from shortUrl in attributes if available
|
||||||
|
const attributesObj = typeof link.attributes === 'string'
|
||||||
|
? JSON.parse(link.attributes)
|
||||||
|
: link.attributes || {};
|
||||||
|
|
||||||
const metadata = getLinkMetadata(link);
|
if (attributesObj.shortUrl) {
|
||||||
const matchesSearch = searchQuery ?
|
try {
|
||||||
(metadata.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
const urlObj = new URL(attributesObj.shortUrl);
|
||||||
metadata.slug.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
domain = urlObj.hostname;
|
||||||
metadata.originalUrl.toLowerCase().includes(searchQuery.toLowerCase())) :
|
} catch (e) {
|
||||||
true;
|
console.error('Error parsing shortUrl:', e);
|
||||||
|
|
||||||
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>())
|
} catch (e) {
|
||||||
).map(([id, name]) => ({ id, name })) : [];
|
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(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
@@ -231,20 +222,20 @@ export default function LinksPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Subscribe to user auth state
|
||||||
const supabase = getSupabaseClient();
|
const supabase = getSupabaseClient();
|
||||||
|
const { data: { subscription } } = supabase.auth.onAuthStateChange(
|
||||||
const { data: { subscription } } = supabase.auth.onAuthStateChange((event: AuthChangeEvent) => {
|
(event: AuthChangeEvent) => {
|
||||||
if (event === 'SIGNED_IN') {
|
if (event === 'SIGNED_IN' || event === 'USER_UPDATED') {
|
||||||
fetchLinks();
|
fetchLinks();
|
||||||
} else if (event === 'SIGNED_OUT') {
|
|
||||||
setLinks([]);
|
|
||||||
setError(null);
|
|
||||||
}
|
}
|
||||||
});
|
if (event === 'SIGNED_OUT') {
|
||||||
|
setLinks([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
supabase.auth.getSession().then(() => {
|
|
||||||
fetchLinks();
|
fetchLinks();
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
isMounted = false;
|
isMounted = false;
|
||||||
@@ -252,27 +243,42 @@ export default function LinksPage() {
|
|||||||
};
|
};
|
||||||
}, [currentPage, pageSize, searchQuery, teamFilter]);
|
}, [currentPage, pageSize, searchQuery, teamFilter]);
|
||||||
|
|
||||||
if (loading) {
|
// 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 (
|
return (
|
||||||
<div className="flex w-full items-center justify-center p-12">
|
<div className="flex h-96 w-full items-center justify-center">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
<Loader2 className="h-8 w-8 animate-spin text-gray-500" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full items-center justify-center p-8 text-red-500">
|
<div className="flex h-96 w-full flex-col items-center justify-center text-red-500">
|
||||||
<p>Error: {error}</p>
|
<p>Error loading shortcuts: {error}</p>
|
||||||
</div>
|
<button
|
||||||
);
|
onClick={() => window.location.reload()}
|
||||||
}
|
className="mt-4 rounded-md bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
|
||||||
|
>
|
||||||
if (links.length === 0) {
|
Retry
|
||||||
return (
|
</button>
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -289,11 +295,10 @@ export default function LinksPage() {
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Search links..."
|
placeholder="Search links..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={handleSearchChange}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
setCurrentPage(1); // Reset to page 1 when searching
|
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"
|
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"
|
||||||
@@ -327,21 +332,26 @@ export default function LinksPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-200 bg-white">
|
<tbody className="divide-y divide-gray-200 bg-white">
|
||||||
{filteredLinks.map(link => {
|
{links.map(link => {
|
||||||
const metadata = getLinkMetadata(link);
|
const metadata = getLinkMetadata(link);
|
||||||
const shortUrl = `https://${metadata.domain}/${metadata.slug}`;
|
const shortUrl = `https://${metadata.domain}/${metadata.slug}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={link.id} className="hover:bg-gray-50">
|
<tr key={link.id} className="hover:bg-gray-50">
|
||||||
<td className="whitespace-nowrap px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-medium text-blue-600">{metadata.title}</span>
|
<span className="font-medium text-gray-900">{metadata.title}</span>
|
||||||
<span className="text-xs text-gray-500">{shortUrl}</span>
|
<span className="text-xs text-blue-500">{shortUrl}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="max-w-xs truncate px-6 py-4 text-sm text-gray-500">
|
<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">
|
<a
|
||||||
{metadata.originalUrl}
|
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" />
|
<ExternalLink className="ml-1 h-3 w-3" />
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
@@ -465,7 +475,7 @@ export default function LinksPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{filteredLinks.length === 0 && links.length > 0 && (
|
{links.length === 0 && (
|
||||||
<div className="mt-6 rounded-md bg-gray-50 p-6 text-center text-gray-500">
|
<div className="mt-6 rounded-md bg-gray-50 p-6 text-center text-gray-500">
|
||||||
No links match your search criteria
|
No links match your search criteria
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user