From a8d364be1f9a7780226da12310271b27507f472b Mon Sep 17 00:00:00 2001 From: William Tso Date: Tue, 1 Apr 2025 20:09:49 +0800 Subject: [PATCH] tags selector --- app/(app)/analytics/page.tsx | 41 ++++- app/components/ui/TagSelector.tsx | 281 ++++++++++++++++++++++++++++++ 2 files changed, 318 insertions(+), 4 deletions(-) create mode 100644 app/components/ui/TagSelector.tsx diff --git a/app/(app)/analytics/page.tsx b/app/(app)/analytics/page.tsx index e19aebf..cfec1bc 100644 --- a/app/(app)/analytics/page.tsx +++ b/app/(app)/analytics/page.tsx @@ -5,6 +5,7 @@ import { subDays } from 'date-fns'; import { DateRangePicker } from '@/app/components/ui/DateRangePicker'; import { TeamSelector } from '@/app/components/ui/TeamSelector'; import { ProjectSelector } from '@/app/components/ui/ProjectSelector'; +import { TagSelector } from '@/app/components/ui/TagSelector'; export default function AnalyticsPage() { // 默认日期范围为最近7天 @@ -19,6 +20,14 @@ export default function AnalyticsPage() { // 添加项目选择状态 - 使用数组支持多选 const [selectedProjectIds, setSelectedProjectIds] = useState([]); + + // 添加标签选择状态 - 使用数组支持多选 + const [selectedTagIds, setSelectedTagIds] = useState([]); + + // 分析是否有任何选择 + const hasNoSelection = selectedTeamIds.length === 0 && + selectedProjectIds.length === 0 && + selectedTagIds.length === 0; return (
@@ -39,6 +48,13 @@ export default function AnalyticsPage() { multiple={true} teamId={selectedTeamIds.length === 1 ? selectedTeamIds[0] : undefined} /> + setSelectedTagIds(Array.isArray(value) ? value : [value])} + className="w-[250px]" + multiple={true} + teamId={selectedTeamIds.length === 1 ? selectedTeamIds[0] : undefined} + />
- {/* 如果没有选择团队或项目,显示提示信息 */} - {selectedTeamIds.length === 0 && selectedProjectIds.length === 0 && ( + {/* 如果没有选择任何项,显示提示信息 */} + {hasNoSelection && (

- Please select teams or projects to view analytics + Please select teams, projects, or tags to view analytics

)} @@ -74,7 +90,7 @@ export default function AnalyticsPage() { {/* 显示项目相关的分析数据 */} {selectedProjectIds.length > 0 && ( -
+

Project Analytics ({selectedProjectIds.length} selected)

@@ -88,6 +104,23 @@ export default function AnalyticsPage() {
)} + + {/* 显示标签相关的分析数据 */} + {selectedTagIds.length > 0 && ( +
+

+ Tag Analytics ({selectedTagIds.length} selected) +

+
+ {selectedTagIds.map((tagId) => ( +
+

Tag ID: {tagId}

+

Tag analytics will appear here

+
+ ))} +
+
+ )} ); } \ No newline at end of file diff --git a/app/components/ui/TagSelector.tsx b/app/components/ui/TagSelector.tsx new file mode 100644 index 0000000..bc95d3b --- /dev/null +++ b/app/components/ui/TagSelector.tsx @@ -0,0 +1,281 @@ +"use client"; + +import * as React from 'react'; +import { useEffect, useState, useRef } from 'react'; +import { getSupabaseClient } from '../../utils/supabase'; +import { AuthChangeEvent } from '@supabase/supabase-js'; +import { Loader2, X, Check, Tag } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +// Define Tag type based on the database schema +interface Tag { + id: string; + name: string; + type?: string | null; + attributes?: Record; + created_at?: string; + updated_at?: string; + deleted_at?: string | null; + parent_tag_id?: string | null; + team_id?: string | null; + is_shared?: boolean; + schema_version?: number | null; + is_system?: boolean; +} + +// TagSelector component with multi-select support +export function TagSelector({ + value, + onChange, + className, + multiple = false, + teamId, + tagType, +}: { + value?: string | string[]; + onChange?: (tagId: string | string[]) => void; + className?: string; + multiple?: boolean; + teamId?: string; // Optional team ID to filter tags by team + tagType?: string; // Optional tag type for filtering +}) { + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [tags, setTags] = useState([]); + const [selectedIds, setSelectedIds] = useState([]); + const [isOpen, setIsOpen] = useState(false); + const selectorRef = useRef(null); + + // Initialize selected tags based on value prop + useEffect(() => { + if (value) { + if (Array.isArray(value)) { + setSelectedIds(value); + } else { + setSelectedIds(value ? [value] : []); + } + } else { + setSelectedIds([]); + } + }, [value]); + + // Add click outside listener to close dropdown + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (selectorRef.current && !selectorRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + } + + // Only add the event listener if the dropdown is open + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + } + }, [isOpen]); + + useEffect(() => { + let isMounted = true; + + const fetchTags = async () => { + if (!isMounted) return; + setLoading(true); + setError(null); + + try { + const supabase = getSupabaseClient(); + + let query = supabase.from('tags').select('*').is('deleted_at', null); + + // Filter by team if teamId is provided + if (teamId) { + query = query.eq('team_id', teamId); + } + + // Filter by tag type if provided + if (tagType) { + query = query.eq('type', tagType); + } + + const { data: tagsData, error: tagsError } = await query; + + if (tagsError) throw tagsError; + + if (!tagsData || tagsData.length === 0) { + if (isMounted) setTags([]); + return; + } + + if (isMounted) { + setTags(tagsData as Tag[]); + } + } catch (err) { + if (isMounted) { + setError(err instanceof Error ? err.message : 'Failed to load tags'); + } + } finally { + if (isMounted) { + setLoading(false); + } + } + }; + + const supabase = getSupabaseClient(); + + const { data: { subscription } } = supabase.auth.onAuthStateChange((event: AuthChangeEvent) => { + if (event === 'SIGNED_IN') { + fetchTags(); + } else if (event === 'SIGNED_OUT') { + setTags([]); + setError(null); + } + }); + + supabase.auth.getSession().then(() => { + fetchTags(); + }); + + return () => { + isMounted = false; + subscription.unsubscribe(); + }; + }, [teamId, tagType]); + + const handleToggle = () => { + if (!loading && !error && tags.length > 0) { + setIsOpen(!isOpen); + } + }; + + const handleTagSelect = (tagId: string) => { + let newSelected: string[]; + + if (multiple) { + // For multi-select: toggle tag in/out of selection + if (selectedIds.includes(tagId)) { + newSelected = selectedIds.filter(id => id !== tagId); + } else { + newSelected = [...selectedIds, tagId]; + } + } else { + // For single-select: replace selection with the new tag + newSelected = [tagId]; + setIsOpen(false); + } + + setSelectedIds(newSelected); + + if (onChange) { + onChange(multiple ? newSelected : newSelected[0] || ''); + } + }; + + const removeTag = (e: React.MouseEvent, tagId: string) => { + e.stopPropagation(); + const newSelected = selectedIds.filter(id => id !== tagId); + setSelectedIds(newSelected); + if (onChange) { + onChange(multiple ? newSelected : newSelected[0] || ''); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (tags.length === 0) { + return ( +
+ No tags available +
+ ); + } + + const selectedTags = tags.filter(tag => selectedIds.includes(tag.id)); + + return ( +
+
+ {selectedTags.length > 0 ? ( +
+ {selectedTags.map(tag => ( +
+ {tag.name} + {multiple && ( + removeTag(e, tag.id)} + /> + )} +
+ ))} +
+ ) : ( +
Select tags
+ )} +
+ + {isOpen && ( +
+ {tags.map(tag => ( +
handleTagSelect(tag.id)} + > + + + {tag.name} + {tag.type && ( + + {tag.type} + + )} + + {selectedIds.includes(tag.id) && ( + + )} +
+ ))} +
+ )} +
+ ); +} \ No newline at end of file