From 0c4a67e76908d930ac822f569a20d31c4fd51c1a Mon Sep 17 00:00:00 2001 From: William Tso Date: Mon, 7 Apr 2025 22:08:12 +0800 Subject: [PATCH] links search --- app/api/shortlinks/route.ts | 8 +- app/links/page.tsx | 302 +++++++++++++++++++----------------- 2 files changed, 162 insertions(+), 148 deletions(-) diff --git a/app/api/shortlinks/route.ts b/app/api/shortlinks/route.ts index fc83b20..8f3e4d6 100644 --- a/app/api/shortlinks/route.ts +++ b/app/api/shortlinks/route.ts @@ -18,10 +18,14 @@ export async function GET(request: NextRequest) { const whereConditions = ['deleted_at IS NULL']; if (search) { + // Expand search to include more fields: slug, shortUrl in attributes, team name, tag name, original_url whereConditions.push(`( - title 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)) )`); } diff --git a/app/links/page.tsx b/app/links/page.tsx index fd267f0..8f13ef6 100644 --- a/app/links/page.tsx +++ b/app/links/page.tsx @@ -20,17 +20,32 @@ interface LinkAttributes { [key: string]: unknown; } -// Define Link type based on the database schema +// 更新 ShortLink 类型定义以匹配 ClickHouse 数据结构 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; + external_id?: string; + type?: string; + slug?: string; + original_url?: string; + title?: string; + description?: string; + attributes: string | Record; + 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[]; + teams?: string | Record[]; + tags?: string | Record[]; + qr_codes?: string | Record[]; + channels?: string | Record[]; + favorites?: string | Record[]; + expires_at?: string | null; + click_count?: number; + unique_visitors?: number; } // Define ClickHouse shorturl type @@ -62,69 +77,24 @@ interface ClickHouseShortUrl { 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); - } - +// 示例团队数据 - 实际应用中应从API获取 +const teams = [ + { id: 'marketing', name: 'Marketing' }, + { id: 'sales', name: 'Sales' }, + { id: 'product', name: 'Product' }, + { id: 'engineering', name: 'Engineering' } +]; + +// 将 ClickHouse 数据转换为 ShortLink 格式 +const convertClickHouseToShortLink = (data: Record): ShortLink => { 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 - } + ...data as any, // 使用类型断言处理泛型记录转换 + // 确保关键字段存在 + id: data.id as string || '', + created_at: data.created_at as string || new Date().toISOString(), + attributes: data.attributes || '{}' }; -} +}; export default function LinksPage() { const [loading, setLoading] = useState(true); @@ -136,53 +106,74 @@ export default function LinksPage() { const [pageSize, setPageSize] = useState(10); const [totalLinks, setTotalLinks] = useState(0); const [totalPages, setTotalPages] = useState(0); + const [searchDebounce, setSearchDebounce] = useState(null); // 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; + try { + // Parse attributes if it's a string + const attributes = typeof link.attributes === 'string' + ? JSON.parse(link.attributes) + : link.attributes || {}; - 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; + // Parse attributes to get domain if available + let domain = 'shorturl.example.com'; + try { + // Extract domain from shortUrl in attributes if available + const attributesObj = typeof link.attributes === 'string' + ? JSON.parse(link.attributes) + : link.attributes || {}; - 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); + if (attributesObj.shortUrl) { + try { + const urlObj = new URL(attributesObj.shortUrl); + domain = urlObj.hostname; + } catch (e) { + console.error('Error parsing shortUrl:', e); + } } - return teams; - }, new Map()) - ).map(([id, name]) => ({ id, name })) : []; + } catch (e) { + 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(() => { let isMounted = true; @@ -231,20 +222,20 @@ export default function LinksPage() { } }; + // Subscribe to user auth state const supabase = getSupabaseClient(); - - const { data: { subscription } } = supabase.auth.onAuthStateChange((event: AuthChangeEvent) => { - if (event === 'SIGNED_IN') { - fetchLinks(); - } else if (event === 'SIGNED_OUT') { - setLinks([]); - setError(null); + const { data: { subscription } } = supabase.auth.onAuthStateChange( + (event: AuthChangeEvent) => { + if (event === 'SIGNED_IN' || event === 'USER_UPDATED') { + fetchLinks(); + } + if (event === 'SIGNED_OUT') { + setLinks([]); + } } - }); + ); - supabase.auth.getSession().then(() => { - fetchLinks(); - }); + fetchLinks(); return () => { isMounted = false; @@ -252,27 +243,42 @@ export default function LinksPage() { }; }, [currentPage, pageSize, searchQuery, teamFilter]); - if (loading) { + // Handle search input with debounce + const handleSearchChange = (e: React.ChangeEvent) => { + 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 ( -
- +
+
); } if (error) { return ( -
-

Error: {error}

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

No short links found

-

Create your first short URL to get started

+
+

Error loading shortcuts: {error}

+
); } @@ -289,11 +295,10 @@ export default function LinksPage() { type="text" placeholder="Search links..." value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} + onChange={handleSearchChange} 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" @@ -327,21 +332,26 @@ export default function LinksPage() { - {filteredLinks.map(link => { + {links.map(link => { const metadata = getLinkMetadata(link); const shortUrl = `https://${metadata.domain}/${metadata.slug}`; return ( - +
- {metadata.title} - {shortUrl} + {metadata.title} + {shortUrl}
- - - {metadata.originalUrl} + + + {metadata.originalUrl} @@ -465,7 +475,7 @@ export default function LinksPage() {
)} - {filteredLinks.length === 0 && links.length > 0 && ( + {links.length === 0 && (
No links match your search criteria