diff --git a/.oxlintrc.json b/.oxlintrc.json index 28baf54..5a904ff 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -13,7 +13,8 @@ { "allowArrowFunctions": false } - ] + ], + "typescript/no-explicit-any": "error" }, "env": { "es2024": true diff --git a/src/components/router/EditionRoutes.tsx b/src/components/router/EditionRoutes.tsx index b9951b3..e0dab06 100644 --- a/src/components/router/EditionRoutes.tsx +++ b/src/components/router/EditionRoutes.tsx @@ -13,7 +13,7 @@ import { ScheduleTab } from "@/pages/EditionView/tabs/ScheduleTab"; interface EditionRoutesProps { basePath: string; - WrapperComponent?: React.ComponentType; + WrapperComponent?: React.ComponentType<{ component: React.ComponentType }>; } export function createEditionRoutes({ diff --git a/src/components/router/GlobalRoutes.tsx b/src/components/router/GlobalRoutes.tsx index 57c8cfb..5497d25 100644 --- a/src/components/router/GlobalRoutes.tsx +++ b/src/components/router/GlobalRoutes.tsx @@ -14,7 +14,8 @@ import PrivacyPolicy from "@/pages/legal/PrivacyPolicy"; import TermsOfService from "@/pages/legal/TermsOfService"; import NotFound from "@/pages/NotFound"; import { AdminRolesTable } from "@/pages/admin/Roles/AdminRolesTable"; -import { ArtistsManagement } from "@/pages/admin/ArtistsManagement/ArtistsManagement"; +import { DuplicateArtistsPage } from "@/pages/admin/ArtistsManagement/DuplicateArtistsPage"; +import { ArtistBulkEditor } from "@/pages/admin/ArtistsManagement/ArtistBulkEditor"; export function GlobalRoutes() { return ( @@ -26,7 +27,8 @@ export function GlobalRoutes() { {/* Admin routes */} }> } /> - } /> + } /> + } /> } /> } /> }> diff --git a/src/hooks/queries/artists/useBulkArchiveArtists.ts b/src/hooks/queries/artists/useBulkArchiveArtists.ts new file mode 100644 index 0000000..581e625 --- /dev/null +++ b/src/hooks/queries/artists/useBulkArchiveArtists.ts @@ -0,0 +1,44 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useToast } from "@/hooks/use-toast"; +import { supabase } from "@/integrations/supabase/client"; +import { artistsKeys } from "./useArtists"; + +async function bulkArchiveArtists(artistIds: string[]) { + const { error } = await supabase + .from("artists") + .update({ archived: true }) + .in("id", artistIds); + + if (error) { + console.error("Error bulk archiving artists:", error); + throw new Error("Failed to archive artists"); + } + + return artistIds; +} + +export function useBulkArchiveArtistsMutation() { + const queryClient = useQueryClient(); + const { toast } = useToast(); + + return useMutation({ + mutationFn: bulkArchiveArtists, + onSuccess: (results) => { + queryClient.invalidateQueries({ + queryKey: artistsKeys.all, + }); + + toast({ + title: "Bulk Archive Complete", + description: `Successfully archived ${results.length} artist(s).`, + }); + }, + onError: (error) => { + toast({ + title: "Bulk Archive Error", + description: error?.message || "Failed to archive artists", + variant: "destructive", + }); + }, + }); +} diff --git a/src/hooks/queries/artists/useBulkMergeArtists.ts b/src/hooks/queries/artists/useBulkMergeArtists.ts new file mode 100644 index 0000000..79795f5 --- /dev/null +++ b/src/hooks/queries/artists/useBulkMergeArtists.ts @@ -0,0 +1,272 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { supabase } from "@/integrations/supabase/client"; +import { Artist, artistsKeys } from "./useArtists"; +import { + duplicateArtistsKeys, + type DuplicateGroup, +} from "./useDuplicateArtists"; + +export interface BulkMergeParams { + groups: DuplicateGroup[]; + strategy: "smart" | "first" | "newest" | "oldest"; +} + +interface MergeProgress { + total: number; + completed: number; + current?: string; + errors: Array<{ group: string; error: string }>; +} + +async function bulkMergeArtists( + params: BulkMergeParams, + onProgress?: (progress: MergeProgress) => void, +) { + const { groups, strategy } = params; + const progress: MergeProgress = { + total: groups.length, + completed: 0, + errors: [], + }; + + for (const group of groups) { + try { + progress.current = group.name; + onProgress?.(progress); + + const { primaryArtist, mergeData } = getSmartMergeData(group, strategy); + const duplicateIds = group.artists + .filter((a) => a.id !== primaryArtist.id) + .map((a) => a.id); + + await performSingleMerge(primaryArtist.id, duplicateIds, mergeData); + + progress.completed++; + onProgress?.(progress); + } catch (error) { + progress.errors.push({ + group: group.name, + error: error instanceof Error ? error.message : "Unknown error", + }); + progress.completed++; + onProgress?.(progress); + } + } + + return progress; +} + +function getSmartMergeData( + group: DuplicateGroup, + strategy: BulkMergeParams["strategy"], +) { + let primaryArtist = group.artists[0]; + + // Choose primary artist based on strategy + switch (strategy) { + case "newest": + primaryArtist = group.artists.reduce((newest, current) => + new Date(current.created_at) > new Date(newest.created_at) + ? current + : newest, + ); + break; + case "oldest": + primaryArtist = group.artists.reduce((oldest, current) => + new Date(current.created_at) < new Date(oldest.created_at) + ? current + : oldest, + ); + break; + case "smart": + // Choose the one with most complete data as the base + primaryArtist = group.artists.reduce((best, current) => { + const currentScore = getCompletenessScore(current); + const bestScore = getCompletenessScore(best); + return currentScore > bestScore ? current : best; + }); + break; + case "first": + default: + primaryArtist = group.artists[0]; + break; + } + + // Smart merge: start with primary artist's data, then enhance with missing data from others + const mergeData = { + name: group.name, + description: smartMergeDescription(primaryArtist, group.artists), + spotify_url: smartMergeUrl(primaryArtist, group.artists, "spotify_url"), + soundcloud_url: smartMergeUrl( + primaryArtist, + group.artists, + "soundcloud_url", + ), + genreIds: getAllGenres(group.artists), + }; + + return { primaryArtist, mergeData }; +} + +function getCompletenessScore(artist: Artist): number { + let score = 0; + if (artist.description) score += 3; + if (artist.spotify_url) score += 2; + if (artist.soundcloud_url) score += 2; + if (artist.artist_music_genres?.length) score += 1; + return score; +} + +function smartMergeDescription( + primaryArtist: Artist, + allArtists: Artist[], +): string | null { + // If primary artist has description, use it (it was chosen for having most complete data) + if (primaryArtist.description) { + return primaryArtist.description; + } + + // Otherwise, find the best description from any duplicate + return ( + allArtists + .map((a) => a.description) + .filter((a): a is string => Boolean(a)) + .sort((a, b) => b.length - a.length)[0] || null + ); +} + +function smartMergeUrl( + primaryArtist: Artist, + allArtists: Artist[], + field: keyof Pick, +): string | null { + // If primary artist has this URL, use it + if (primaryArtist[field]) { + return primaryArtist[field]; + } + + // Otherwise, find this URL from any duplicate + return allArtists.map((a) => a[field]).filter(Boolean)[0] || null; +} + +function getAllGenres(artists: Artist[]): string[] { + const allGenres = new Set(); + artists.forEach((artist) => { + if (artist.artist_music_genres) { + artist.artist_music_genres.forEach((genre) => { + allGenres.add(genre.music_genre_id); + }); + } + }); + return Array.from(allGenres); +} + +async function performSingleMerge( + primaryArtistId: string, + duplicateArtistIds: string[], + mergeData: { + name: string; + description: string | null; + spotify_url: string | null; + soundcloud_url: string | null; + genreIds: string[]; + }, +) { + // Update the primary artist with merged data + const { error: updateError } = await supabase + .from("artists") + .update({ + name: mergeData.name, + description: mergeData.description, + spotify_url: mergeData.spotify_url, + soundcloud_url: mergeData.soundcloud_url, + updated_at: new Date().toISOString(), + }) + .eq("id", primaryArtistId); + + if (updateError) { + throw new Error(`Failed to update primary artist: ${updateError.message}`); + } + + // Update genres + if (mergeData.genreIds.length > 0) { + const { error: deleteGenresError } = await supabase + .from("artist_music_genres") + .delete() + .eq("artist_id", primaryArtistId); + + if (deleteGenresError) { + throw new Error( + `Failed to remove existing genres: ${deleteGenresError.message}`, + ); + } + + const genreInserts = mergeData.genreIds.map((genreId: string) => ({ + artist_id: primaryArtistId, + music_genre_id: genreId, + })); + + const { error: insertGenresError } = await supabase + .from("artist_music_genres") + .insert(genreInserts); + + if (insertGenresError) { + throw new Error(`Failed to add new genres: ${insertGenresError.message}`); + } + } + + // Transfer data for each duplicate + for (const duplicateId of duplicateArtistIds) { + // Transfer set associations + const { error: updateSetArtistsError } = await supabase + .from("set_artists") + .update({ artist_id: primaryArtistId }) + .eq("artist_id", duplicateId); + + if (updateSetArtistsError && updateSetArtistsError.code === "23505") { + // Conflict: delete duplicate associations + await supabase.from("set_artists").delete().eq("artist_id", duplicateId); + } else if (updateSetArtistsError) { + throw new Error( + `Failed to transfer sets: ${updateSetArtistsError.message}`, + ); + } + + // Transfer notes (ignore if table doesn't exist) + await supabase + .from("artist_notes") + .update({ + artist_id: primaryArtistId, + updated_at: new Date().toISOString(), + }) + .eq("artist_id", duplicateId); + + // Delete duplicate artist + const { error: deleteError } = await supabase + .from("artists") + .delete() + .eq("id", duplicateId); + + if (deleteError) { + throw new Error(`Failed to delete duplicate: ${deleteError.message}`); + } + } +} + +export function useBulkMergeArtistsMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + params, + onProgress, + }: { + params: BulkMergeParams; + onProgress?: (progress: MergeProgress) => void; + }) => bulkMergeArtists(params, onProgress), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: artistsKeys.all }); + queryClient.invalidateQueries({ queryKey: duplicateArtistsKeys.all }); + }, + }); +} diff --git a/src/hooks/queries/artists/useDuplicateArtists.ts b/src/hooks/queries/artists/useDuplicateArtists.ts new file mode 100644 index 0000000..a64b57b --- /dev/null +++ b/src/hooks/queries/artists/useDuplicateArtists.ts @@ -0,0 +1,81 @@ +import { useQuery } from "@tanstack/react-query"; +import { supabase } from "@/integrations/supabase/client"; +import type { Artist } from "./useArtists"; + +export type DuplicateGroup = { + name: string; + count: number; + artists: Artist[]; +}; + +export const duplicateArtistsKeys = { + all: ["duplicateArtists"] as const, + groups: () => [...duplicateArtistsKeys.all, "groups"] as const, +}; + +async function fetchDuplicateArtists(): Promise { + const { data: duplicateNames, error: duplicateError } = await supabase + .from("artists") + .select("name") + .eq("archived", false) + .order("name"); + + if (duplicateError) { + console.error("Error fetching duplicate names:", duplicateError); + throw new Error("Failed to fetch duplicate artists"); + } + + const nameCounts = duplicateNames.reduce( + (acc, artist) => { + acc[artist.name] = (acc[artist.name] || 0) + 1; + return acc; + }, + {} as Record, + ); + + const duplicateNamesList = Object.entries(nameCounts) + .filter(([_, count]) => count > 1) + .map(([name, count]) => ({ name, count })); + + if (duplicateNamesList.length === 0) { + return []; + } + + const duplicateGroups: DuplicateGroup[] = []; + + for (const { name, count } of duplicateNamesList) { + const { data: artists, error } = await supabase + .from("artists") + .select( + ` + *, + artist_music_genres (music_genre_id) + `, + ) + .eq("name", name) + .eq("archived", false) + .order("created_at", { ascending: false }); + + if (error) { + console.error(`Error fetching artists for ${name}:`, error); + continue; + } + + if (artists && artists.length > 1) { + duplicateGroups.push({ + name, + count, + artists: artists as Artist[], + }); + } + } + + return duplicateGroups.sort((a, b) => b.count - a.count); +} + +export function useDuplicateArtistsQuery() { + return useQuery({ + queryKey: duplicateArtistsKeys.groups(), + queryFn: fetchDuplicateArtists, + }); +} diff --git a/src/hooks/queries/artists/useMergeArtists.ts b/src/hooks/queries/artists/useMergeArtists.ts new file mode 100644 index 0000000..2d6c2fd --- /dev/null +++ b/src/hooks/queries/artists/useMergeArtists.ts @@ -0,0 +1,155 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { supabase } from "@/integrations/supabase/client"; +import { artistsKeys } from "./useArtists"; +import { duplicateArtistsKeys } from "./useDuplicateArtists"; + +export interface MergeArtistsParams { + primaryArtistId: string; + duplicateArtistIds: string[]; + mergeData: { + name: string; + description: string | null; + spotify_url: string | null; + soundcloud_url: string | null; + genreIds: string[]; + }; +} + +async function mergeArtists(params: MergeArtistsParams) { + const { primaryArtistId, duplicateArtistIds, mergeData } = params; + + // Start a transaction by using the Supabase client + try { + // 1. Update the primary artist with merged data + const { error: updateError } = await supabase + .from("artists") + .update({ + name: mergeData.name, + description: mergeData.description, + spotify_url: mergeData.spotify_url, + soundcloud_url: mergeData.soundcloud_url, + updated_at: new Date().toISOString(), + }) + .eq("id", primaryArtistId); + + if (updateError) { + throw new Error( + `Failed to update primary artist: ${updateError.message}`, + ); + } + + // 2. Update artist genres for primary artist + if (mergeData.genreIds.length > 0) { + // Remove existing genre associations + const { error: deleteGenresError } = await supabase + .from("artist_music_genres") + .delete() + .eq("artist_id", primaryArtistId); + + if (deleteGenresError) { + throw new Error( + `Failed to remove existing genres: ${deleteGenresError.message}`, + ); + } + + // Add new genre associations + const genreInserts = mergeData.genreIds.map((genreId) => ({ + artist_id: primaryArtistId, + music_genre_id: genreId, + })); + + const { error: insertGenresError } = await supabase + .from("artist_music_genres") + .insert(genreInserts); + + if (insertGenresError) { + throw new Error( + `Failed to add new genres: ${insertGenresError.message}`, + ); + } + } + + // 3. For each duplicate artist, transfer data and delete + for (const duplicateId of duplicateArtistIds) { + // Update set_artists junction table to point duplicate artist's sets to primary artist + const { error: updateSetArtistsError } = await supabase + .from("set_artists") + .update({ + artist_id: primaryArtistId, + }) + .eq("artist_id", duplicateId); + + if (updateSetArtistsError) { + // If there's a conflict (primary artist already linked to same set), delete the duplicate entry + if (updateSetArtistsError.code === "23505") { + // unique constraint violation + const { error: deleteConflictError } = await supabase + .from("set_artists") + .delete() + .eq("artist_id", duplicateId); + + if (deleteConflictError) { + throw new Error( + `Failed to resolve set conflicts: ${deleteConflictError.message}`, + ); + } + } else { + throw new Error( + `Failed to transfer set associations: ${updateSetArtistsError.message}`, + ); + } + } + + // Transfer artist notes (if this table exists) + const { error: updateNotesError } = await supabase + .from("artist_notes") + .update({ + artist_id: primaryArtistId, + updated_at: new Date().toISOString(), + }) + .eq("artist_id", duplicateId); + + // Ignore error if artist_notes table doesn't exist + if ( + updateNotesError && + !updateNotesError.message.includes("does not exist") + ) { + throw new Error( + `Failed to transfer notes: ${updateNotesError.message}`, + ); + } + + // Delete the duplicate artist (this will cascade to related data) + const { error: deleteError } = await supabase + .from("artists") + .delete() + .eq("id", duplicateId); + + if (deleteError) { + throw new Error( + `Failed to delete duplicate artist: ${deleteError.message}`, + ); + } + } + + return { success: true }; + } catch (error) { + console.error("Error merging artists:", error); + throw error; + } +} + +export function useMergeArtistsMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: mergeArtists, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: artistsKeys.all }); + queryClient.invalidateQueries({ queryKey: duplicateArtistsKeys.all }); + }, + onError: (error) => { + console.error("Merge artists mutation error:", error); + }, + }); +} diff --git a/src/hooks/queries/artists/useUpdateArtist.ts b/src/hooks/queries/artists/useUpdateArtist.ts index 6eb4c44..61fea85 100644 --- a/src/hooks/queries/artists/useUpdateArtist.ts +++ b/src/hooks/queries/artists/useUpdateArtist.ts @@ -132,6 +132,7 @@ export function useUpdateArtistMutation() { queryClient.invalidateQueries({ queryKey: artistsKeys.detail(data.id), }); + toast({ title: "Success", description: "Artist updated successfully", diff --git a/src/hooks/queries/auth/useSignInWithOtpMutation.ts b/src/hooks/queries/auth/useSignInWithOtpMutation.ts index f33f359..4634624 100644 --- a/src/hooks/queries/auth/useSignInWithOtpMutation.ts +++ b/src/hooks/queries/auth/useSignInWithOtpMutation.ts @@ -38,7 +38,7 @@ export function useSignInWithOtpMutation() { "We've sent you a magic link and a 6-digit code. Click the link or enter the code below.", }); }, - onError: (error: any) => { + onError: (error) => { toast({ title: "Error", description: error.message, diff --git a/src/hooks/queries/auth/useVerifyOtpMutation.ts b/src/hooks/queries/auth/useVerifyOtpMutation.ts index 0eb96f0..f8525f4 100644 --- a/src/hooks/queries/auth/useVerifyOtpMutation.ts +++ b/src/hooks/queries/auth/useVerifyOtpMutation.ts @@ -30,7 +30,7 @@ export function useVerifyOtpMutation() { description: "Welcome! You're now signed in.", }); }, - onError: (error: any) => { + onError: (error) => { toast({ title: "Error", description: error.message, diff --git a/src/hooks/queries/genres/useCreateGenreMutation.ts b/src/hooks/queries/genres/useCreateGenreMutation.ts index 049dbe3..8d23870 100644 --- a/src/hooks/queries/genres/useCreateGenreMutation.ts +++ b/src/hooks/queries/genres/useCreateGenreMutation.ts @@ -37,8 +37,8 @@ export function useCreateGenreMutation() { description: "Music genre added successfully!", }); }, - onError: (error: any) => { - if (error.code === "23505") { + onError: (error) => { + if ("code" in error && error.code === "23505") { toast({ title: "Error", description: "This genre already exists", diff --git a/src/hooks/queries/groups/useUserGroups.ts b/src/hooks/queries/groups/useUserGroups.ts index bde1b8b..a39f348 100644 --- a/src/hooks/queries/groups/useUserGroups.ts +++ b/src/hooks/queries/groups/useUserGroups.ts @@ -42,7 +42,7 @@ async function getUserGroupIds(userId: string): Promise { // Helper function to add member counts to groups async function addMemberCounts( - groups: any[], + groups: Group[], userId: string, userGroupIds: string[], isUserGroupsOnly: boolean = false, @@ -68,7 +68,7 @@ async function addMemberCounts( async function fetchGroupsFromDb( shouldFetchAll: boolean, userGroupIds: string[], -): Promise { +) { let groupsQuery = supabase .from("groups") .select("*") diff --git a/src/pages/EditionView/tabs/ArtistsTab/useSetFiltering.ts b/src/pages/EditionView/tabs/ArtistsTab/useSetFiltering.ts index 3c5a73d..52ee461 100644 --- a/src/pages/EditionView/tabs/ArtistsTab/useSetFiltering.ts +++ b/src/pages/EditionView/tabs/ArtistsTab/useSetFiltering.ts @@ -7,7 +7,6 @@ export function useSetFiltering( sets: FestivalSet[], filterSortState: FilterSortState, ) { - // todo - refactor to useGroupMembersQuery const groupMembersQuery = useGroupMembersQuery( filterSortState?.groupId || "", ); diff --git a/src/pages/admin/ArtistsManagement/ArtistBulkEditor.tsx b/src/pages/admin/ArtistsManagement/ArtistBulkEditor.tsx new file mode 100644 index 0000000..911873d --- /dev/null +++ b/src/pages/admin/ArtistsManagement/ArtistBulkEditor.tsx @@ -0,0 +1,87 @@ +import { useState, useMemo } from "react"; +import { Card, CardContent } from "@/components/ui/card"; +import { useArtistsQuery } from "@/hooks/queries/artists/useArtists"; +import { AddArtistDialog } from "./AddArtistDialog"; +import { BulkEditorHeader } from "./components/BulkEditorHeader"; +import { BulkEditorSearchAndActions } from "./components/BulkEditorSearchAndActions"; +import { BulkEditorTable } from "./components/BulkEditorTable"; +import { BulkEditorFooter } from "./components/BulkEditorFooter"; +import { BulkEditorLoadingState } from "./components/BulkEditorLoadingState"; +import { useArtistSorting } from "./hooks/useArtistSorting"; +import { useArtistFiltering } from "./hooks/useArtistFiltering"; +import { useArtistSelection } from "./hooks/useArtistSelection"; + +// Re-export types from hooks for external use +export type { SortConfig } from "./hooks/useArtistSorting"; + +export function ArtistBulkEditor() { + const [addArtistOpen, setAddArtistOpen] = useState(false); + + const artistsQuery = useArtistsQuery(); + const artists = useMemo(() => artistsQuery.data || [], [artistsQuery.data]); + + // Custom hooks for managing state and logic + const { sortConfig, handleSort, sortArtists } = useArtistSorting(); + const { searchTerm, setSearchTerm, filterArtists } = useArtistFiltering(); + const { selectedIds, handleSelectAll, handleSelectArtist, clearSelection } = + useArtistSelection(); + + // Apply filtering and sorting + const filteredAndSortedArtists = useMemo(() => { + const filtered = filterArtists(artists); + return sortArtists(filtered); + }, [artists, filterArtists, sortArtists]); + + // Wrapper function for select all + function handleSelectAllWrapper() { + handleSelectAll(filteredAndSortedArtists.map((a) => a.id)); + } + + if (artistsQuery.isLoading) { + return ; + } + + return ( +
+ + setAddArtistOpen(true)} /> + + + + + + + + + + + { + // Artist list will refresh automatically via React Query + }} + /> +
+ ); +} diff --git a/src/pages/admin/ArtistsManagement/ArtistComparisonModal.tsx b/src/pages/admin/ArtistsManagement/ArtistComparisonModal.tsx new file mode 100644 index 0000000..67a0863 --- /dev/null +++ b/src/pages/admin/ArtistsManagement/ArtistComparisonModal.tsx @@ -0,0 +1,209 @@ +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { Merge, X } from "lucide-react"; +import type { Artist } from "@/hooks/queries/artists/useArtists"; +import { useMergeArtistsMutation } from "@/hooks/queries/artists/useMergeArtists"; +import { useToast } from "@/components/ui/use-toast"; +import { ArtistComparisonCard } from "./components/ArtistComparisonCard"; +import { MergePreviewDialog } from "./components/MergePreviewDialog"; +import { FieldSelector, GenreSelector } from "./components/FieldSelector"; + +interface ArtistComparisonModalProps { + artists: Artist[]; + onClose: () => void; +} + +type MergeChoices = { + name: string; + description: string | null; + spotify_url: string | null; + soundcloud_url: string | null; + genres: string[]; +}; + +export function ArtistComparisonModal({ + artists, + onClose, +}: ArtistComparisonModalProps) { + const [mergeChoices, setMergeChoices] = useState({ + name: artists[0]?.name || "", + description: null, + spotify_url: null, + soundcloud_url: null, + genres: [], + }); + + const [showMergePreview, setShowMergePreview] = useState(false); + const mergeMutation = useMergeArtistsMutation(); + const { toast } = useToast(); + + function handleFieldChange(field: keyof MergeChoices, value: unknown) { + setMergeChoices((prev) => ({ + ...prev, + [field]: value, + })); + } + + function getAllGenres() { + const allGenres = new Set(); + artists.forEach((artist) => { + if (artist.artist_music_genres) { + artist.artist_music_genres.forEach((genre) => { + allGenres.add(genre.music_genre_id); + }); + } + }); + return Array.from(allGenres); + } + + function handleGenreToggle(genreId: string) { + setMergeChoices((prev) => ({ + ...prev, + genres: prev.genres.includes(genreId) + ? prev.genres.filter((g) => g !== genreId) + : [...prev.genres, genreId], + })); + } + + function handleMergePreview() { + const allGenres = getAllGenres(); + setMergeChoices((prev) => ({ + ...prev, + genres: prev.genres.length === 0 ? allGenres : prev.genres, + })); + setShowMergePreview(true); + } + + function handleConfirmMerge() { + const primaryArtist = artists[0]; + const duplicateIds = artists.slice(1).map((artist) => artist.id); + + mergeMutation.mutate( + { + primaryArtistId: primaryArtist.id, + duplicateArtistIds: duplicateIds, + mergeData: { + name: mergeChoices.name, + description: mergeChoices.description, + spotify_url: mergeChoices.spotify_url, + soundcloud_url: mergeChoices.soundcloud_url, + genreIds: mergeChoices.genres, + }, + }, + { + onSuccess: () => { + toast({ + title: "Artists Merged Successfully", + description: `Successfully merged ${duplicateIds.length} duplicate artist(s) into "${mergeChoices.name}".`, + }); + onClose(); + }, + onError: (error) => { + toast({ + title: "Merge Failed", + description: `Failed to merge artists: ${error.message}`, + variant: "destructive", + }); + }, + }, + ); + } + + if (showMergePreview) { + return ( + setShowMergePreview(false)} + onConfirm={handleConfirmMerge} + /> + ); + } + + return ( + + + + + + Compare & Merge: {artists[0]?.name} + + + Choose which data to keep for each field. All votes and notes will + be preserved. + + + +
+
+ {artists.map((artist, index) => ( + + ))} +
+ + + +
+

Choose Data to Keep

+ + handleFieldChange("description", value)} + /> + + handleFieldChange("spotify_url", value)} + /> + + + handleFieldChange("soundcloud_url", value) + } + /> + + +
+
+ +
+ + +
+
+
+ ); +} diff --git a/src/pages/admin/ArtistsManagement/ArtistsManagement.tsx b/src/pages/admin/ArtistsManagement/ArtistsManagement.tsx deleted file mode 100644 index a4c345d..0000000 --- a/src/pages/admin/ArtistsManagement/ArtistsManagement.tsx +++ /dev/null @@ -1,193 +0,0 @@ -import { useState, useMemo } from "react"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { Input } from "@/components/ui/input"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { Edit2, Archive, Search, Music, Plus } from "lucide-react"; -import { Artist, useArtistsQuery } from "@/hooks/queries/artists/useArtists"; -import { useArchiveArtistMutation } from "@/hooks/queries/artists/useArchiveArtist"; -import { AddArtistDialog } from "./AddArtistDialog"; -import { EditArtistDialog } from "./EditArtistDialog"; -import { GenreBadge } from "@/components/GenreBadge"; - -export function ArtistsManagement() { - const [addArtistOpen, setAddArtistOpen] = useState(false); - const [edittedArtist, setEdittedArtist] = useState(); - - const archiveMutation = useArchiveArtistMutation(); - - const [searchTerm, setSearchTerm] = useState(""); - - const artistsQuery = useArtistsQuery(); - - const artists = artistsQuery.data; - - const filteredArtists = useMemo( - () => - (artists || []).filter( - (artist) => - artist.name.toLowerCase().includes(searchTerm.toLowerCase()) || - artist.description?.toLowerCase().includes(searchTerm.toLowerCase()), - ), - [artists, searchTerm], - ); - - async function handleArchive(artist: Artist) { - if ( - !confirm( - `Are you sure you want to archive "${artist.name}"? This will hide the artist from the main interface but preserve all data.`, - ) - ) { - return; - } - - archiveMutation.mutate(artist.id, {}); - } - - return ( - <> - - - - - - Artists Management - -
-
- - setSearchTerm(e.target.value)} - className="pl-10 md:w-64" - /> -
- - -
-
-
- -
- - - - Name - Description - Genres - Links - Actions - - - - {filteredArtists.map((artist) => ( - - {artist.name} - {artist.description} - -
- {artist.artist_music_genres ? ( - artist.artist_music_genres.map((genre) => ( - - )) - ) : ( - - No genres - - )} -
-
- -
- {artist.spotify_url && ( - - - Spotify - - - )} - {artist.soundcloud_url && ( - - - SoundCloud - - - )} -
-
- -
- - -
-
-
- ))} -
-
- - {filteredArtists.length === 0 && ( -
- {searchTerm - ? "No artists found matching your search." - : "No artists found. Create your first artist to get started."} -
- )} -
-
-
- - {}} - /> - - {!!edittedArtist && ( - setEdittedArtist(undefined)} - /> - )} - - ); -} diff --git a/src/pages/admin/ArtistsManagement/BulkEditor/BulkActionsToolbar.tsx b/src/pages/admin/ArtistsManagement/BulkEditor/BulkActionsToolbar.tsx new file mode 100644 index 0000000..605d8c3 --- /dev/null +++ b/src/pages/admin/ArtistsManagement/BulkEditor/BulkActionsToolbar.tsx @@ -0,0 +1,57 @@ +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { CheckSquare, Square } from "lucide-react"; +import { ArchiveButton } from "../components/ArchiveButton"; + +interface BulkActionsToolbarProps { + selectedCount: number; + totalCount: number; + selectedIds: Set; + onSelectAll: () => void; + onClearSelection: () => void; +} + +export function BulkActionsToolbar({ + selectedCount, + totalCount, + selectedIds, + onSelectAll, + onClearSelection, +}: BulkActionsToolbarProps) { + const allSelected = selectedCount === totalCount && totalCount > 0; + const someSelected = selectedCount > 0; + + return ( +
+ {someSelected && ( + <> + {selectedCount} selected + + + + + + )} + + +
+ ); +} diff --git a/src/pages/admin/ArtistsManagement/BulkEditor/GenresCell.tsx b/src/pages/admin/ArtistsManagement/BulkEditor/GenresCell.tsx new file mode 100644 index 0000000..88f817e --- /dev/null +++ b/src/pages/admin/ArtistsManagement/BulkEditor/GenresCell.tsx @@ -0,0 +1,104 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { GenreMultiSelect } from "../GenreMultiSelect"; +import { useGenresQuery } from "@/hooks/queries/genres/useGenres"; +import { Check, X } from "lucide-react"; + +interface GenresCellProps { + value: string[]; + onSave: (genreIds: string[]) => void; +} + +export function GenresCell({ value, onSave }: GenresCellProps) { + const [isEditing, setIsEditing] = useState(false); + const [genreIds, setGenreIds] = useState([]); + + const { data: genres = [] } = useGenresQuery(); + + function handleEdit() { + const currentGenreIds = value || []; + setGenreIds(currentGenreIds); + setIsEditing(true); + } + + async function handleSave() { + const currentGenreIds = value || []; + + // Only save if genres actually changed + if ( + JSON.stringify(currentGenreIds.sort()) === JSON.stringify(genreIds.sort()) + ) { + setIsEditing(false); + return; + } + + try { + await onSave(genreIds); + setIsEditing(false); + } catch (error) { + console.error("Failed to save genres:", error); + } + } + + function handleCancel() { + setIsEditing(false); + setGenreIds([]); + } + + if (isEditing) { + return ( +
+
+ +
+ + +
+
+
+ ); + } + + const genreNames = value + .map((id) => genres.find((g) => g.id === id)?.name) + .filter((i): i is NonNullable => !!i); + + return ( +
+ {genreNames.length > 0 ? ( +
+ {genreNames.map((name, idx) => ( + + {name} + + ))} +
+ ) : ( + + Click to add genres... + + )} +
+ ); +} diff --git a/src/pages/admin/ArtistsManagement/BulkEditor/TextCell.tsx b/src/pages/admin/ArtistsManagement/BulkEditor/TextCell.tsx new file mode 100644 index 0000000..2c909d9 --- /dev/null +++ b/src/pages/admin/ArtistsManagement/BulkEditor/TextCell.tsx @@ -0,0 +1,88 @@ +import { useState } from "react"; +import { Input } from "@/components/ui/input"; + +interface TextCellProps { + value: string | null; + placeholder?: string; + required?: boolean; + onSave: (value: string | null) => void; +} + +export function TextCell({ + value, + placeholder, + required, + onSave, +}: TextCellProps) { + const [isEditing, setIsEditing] = useState(false); + const [editValue, setEditValue] = useState(""); + + function handleEdit() { + setEditValue(value || ""); + setIsEditing(true); + } + + async function handleSave() { + if (required && !editValue.trim()) { + return; + } + + const newValue = editValue.trim() || null; + + // Only save if value actually changed + if (newValue === value) { + setIsEditing(false); + return; + } + + try { + await onSave(newValue); + setIsEditing(false); + } catch (error) { + console.error("Failed to save:", error); + } + } + + function handleCancel() { + setIsEditing(false); + setEditValue(""); + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === "Enter") { + handleSave(); + } else if (e.key === "Escape") { + handleCancel(); + } + } + + if (isEditing) { + return ( +
+ setEditValue(e.target.value)} + onBlur={handleSave} + onKeyDown={handleKeyDown} + placeholder={placeholder} + className="text-sm" + autoFocus + /> +
+ ); + } + + return ( +
+ {value || ( + + {placeholder || "Click to add..."} + + )} +
+ ); +} diff --git a/src/pages/admin/ArtistsManagement/BulkEditor/TextareaCell.tsx b/src/pages/admin/ArtistsManagement/BulkEditor/TextareaCell.tsx new file mode 100644 index 0000000..9d7b5e1 --- /dev/null +++ b/src/pages/admin/ArtistsManagement/BulkEditor/TextareaCell.tsx @@ -0,0 +1,80 @@ +import { useState } from "react"; +import { Textarea } from "@/components/ui/textarea"; + +interface TextareaCellProps { + value: string | null; + placeholder?: string; + onSave: (value: string | null) => void; +} + +export function TextareaCell({ + value, + placeholder, + onSave, +}: TextareaCellProps) { + const [isEditing, setIsEditing] = useState(false); + const [editValue, setEditValue] = useState(""); + + function handleEdit() { + setEditValue(value || ""); + setIsEditing(true); + } + + async function handleSave() { + const newValue = editValue.trim() || null; + + // Only save if value actually changed + if (newValue === value) { + setIsEditing(false); + return; + } + + try { + await onSave(newValue); + setIsEditing(false); + } catch (error) { + console.error("Failed to save:", error); + } + } + + function handleCancel() { + setIsEditing(false); + setEditValue(""); + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === "Escape") { + handleCancel(); + } + } + + if (isEditing) { + return ( +
+