diff --git a/src/api/artistSearch/mergeCandidateSelection.test.ts b/src/api/artistSearch/mergeCandidateSelection.test.ts new file mode 100644 index 000000000..fd66680dd --- /dev/null +++ b/src/api/artistSearch/mergeCandidateSelection.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "vitest"; +import { mergeCandidateSelection } from "./mergeCandidateSelection"; +import type { Candidate } from "./types"; + +describe("mergeCandidateSelection", () => { + it("sets providerUrl.spotify when url is selected for spotify", () => { + const candidate = makeCandidate(); + + const update = mergeCandidateSelection(candidate, "spotify", ["url"]); + + expect(update.providerUrl).toEqual({ spotify: candidate.url }); + }); + + it("sets providerUrl.soundcloud when url is selected for soundcloud", () => { + const candidate = makeCandidate({ + url: "https://soundcloud.com/artist", + }); + + const update = mergeCandidateSelection(candidate, "soundcloud", ["url"]); + + expect(update.providerUrl).toEqual({ soundcloud: candidate.url }); + }); + + it("does not set providerUrl when url is not selected", () => { + const candidate = makeCandidate(); + + const update = mergeCandidateSelection(candidate, "spotify", ["image"]); + + expect(update.providerUrl).toBeUndefined(); + }); + + it("sets image_url when image is selected", () => { + const candidate = makeCandidate({ + imageUrl: "https://example.com/img.jpg", + }); + + const update = mergeCandidateSelection(candidate, "spotify", ["image"]); + + expect(update.image_url).toBe(candidate.imageUrl); + }); + + it("does not set image_url when the candidate has none", () => { + const candidate = makeCandidate({ imageUrl: null }); + + const update = mergeCandidateSelection(candidate, "spotify", ["image"]); + + expect(update.image_url).toBeUndefined(); + }); + + it("sets description when description is selected", () => { + const candidate = makeCandidate({ description: "A great artist." }); + + const update = mergeCandidateSelection(candidate, "spotify", [ + "description", + ]); + + expect(update.description).toBe(candidate.description); + }); + + it("does not set description when the candidate has none", () => { + const candidate = makeCandidate({ description: null }); + + const update = mergeCandidateSelection(candidate, "spotify", [ + "description", + ]); + + expect(update.description).toBeUndefined(); + }); + + it("sets all requested fields when multiple are selected", () => { + const candidate = makeCandidate({ + imageUrl: "https://example.com/img.jpg", + description: "A great artist.", + }); + + const update = mergeCandidateSelection(candidate, "spotify", [ + "url", + "image", + "description", + ]); + + expect(update.providerUrl).toEqual({ spotify: candidate.url }); + expect(update.image_url).toBe(candidate.imageUrl); + expect(update.description).toBe(candidate.description); + }); + + it("never writes genres to updates", () => { + const candidate = makeCandidate({ genres: ["rock", "pop"] }); + + const update = mergeCandidateSelection(candidate, "spotify", [ + "url", + "image", + "description", + ]); + + expect(update).not.toHaveProperty("genres"); + }); + + it("does not overwrite an already-staged image_url", () => { + const candidate = makeCandidate({ + imageUrl: "https://example.com/other-image.jpg", + }); + + const update = mergeCandidateSelection(candidate, "spotify", ["image"], { + image_url: "https://example.com/first-image.jpg", + }); + + expect(update.image_url).toBeUndefined(); + }); + + it("does not overwrite an already-staged description", () => { + const candidate = makeCandidate({ description: "Another bio." }); + + const update = mergeCandidateSelection( + candidate, + "spotify", + ["description"], + { description: "First staged bio." }, + ); + + expect(update.description).toBeUndefined(); + }); + + it("still sets providerUrl even when other fields are already staged", () => { + const candidate = makeCandidate(); + + const update = mergeCandidateSelection( + candidate, + "spotify", + ["url", "image", "description"], + { + image_url: "https://example.com/first-image.jpg", + description: "First staged bio.", + }, + ); + + expect(update.providerUrl).toEqual({ spotify: candidate.url }); + }); +}); + +function makeCandidate(overrides: Partial = {}): Candidate { + return { + name: "Test Artist", + url: "https://spotify.com/artist/123", + imageUrl: "https://example.com/image.jpg", + description: "A test artist bio.", + followers: 1000, + genres: ["rock", "pop"], + ...overrides, + }; +} diff --git a/src/api/artistSearch/mergeCandidateSelection.ts b/src/api/artistSearch/mergeCandidateSelection.ts new file mode 100644 index 000000000..96b41b32f --- /dev/null +++ b/src/api/artistSearch/mergeCandidateSelection.ts @@ -0,0 +1,41 @@ +import type { Candidate, Provider } from "./types"; + +export interface CandidateUpdate { + providerUrl?: Partial>; + image_url?: string | null; + description?: string | null; +} + +export type SelectableField = "url" | "image" | "description"; + +export interface StagedValues { + image_url?: string | null | undefined; + description?: string | null | undefined; +} + +export function mergeCandidateSelection( + candidate: Candidate, + provider: Provider, + fields: SelectableField[], + staged: StagedValues = {}, +): CandidateUpdate { + const updates: CandidateUpdate = {}; + + if (fields.includes("url")) { + updates.providerUrl = { [provider]: candidate.url }; + } + + if (fields.includes("image") && candidate.imageUrl && !staged.image_url) { + updates.image_url = candidate.imageUrl; + } + + if ( + fields.includes("description") && + candidate.description && + !staged.description + ) { + updates.description = candidate.description; + } + + return updates; +} diff --git a/src/api/artistSearch/types.ts b/src/api/artistSearch/types.ts new file mode 100644 index 000000000..cfc57ab5e --- /dev/null +++ b/src/api/artistSearch/types.ts @@ -0,0 +1,34 @@ +import { z } from "zod"; + +export const providerSchema = z.enum(["soundcloud", "spotify"]); +export type Provider = z.infer; + +export const candidateSchema = z.object({ + name: z.string(), + url: z.string(), + imageUrl: z.string().nullable(), + description: z.string().nullable(), + followers: z.number().nullable(), + genres: z.array(z.string()), +}); +export type Candidate = z.infer; + +export const searchResultSchema = z.object({ + artistName: z.string(), + provider: providerSchema, + candidates: z.array(candidateSchema), + error: z.string().optional(), +}); +export type SearchResult = z.infer; + +export const searchResponseSchema = z.object({ + results: z.array(searchResultSchema), +}); +export type SearchResponse = z.infer; + +export const artistSearchKeys = { + all: ["artistSearch"] as const, + searches: () => [...artistSearchKeys.all, "search"] as const, + search: (artistNames: string[], provider?: Provider) => + [...artistSearchKeys.searches(), { artistNames, provider }] as const, +}; diff --git a/src/api/artistSearch/usePrefetchNextBatchLinks.ts b/src/api/artistSearch/usePrefetchNextBatchLinks.ts new file mode 100644 index 000000000..a27fc8167 --- /dev/null +++ b/src/api/artistSearch/usePrefetchNextBatchLinks.ts @@ -0,0 +1,32 @@ +import { useEffect } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import type { Artist } from "@/api/artists/types"; +import { searchArtistLinksQuery } from "./useSearchArtistLinksQuery"; + +export function usePrefetchNextBatchLinks( + artists: Artist[], + currentArtistId: string | undefined, +) { + const queryClient = useQueryClient(); + + useEffect(() => { + if (!currentArtistId || artists.length === 0) return; + + const currentIdx = Math.max( + 0, + artists.findIndex((a) => a.id === currentArtistId), + ); + const positionInBatch = currentIdx % 10; + + if (positionInBatch < 8) return; + + const nextBatchStart = Math.floor(currentIdx / 10) * 10 + 10; + const nextBatchArtists = artists + .slice(nextBatchStart, nextBatchStart + 10) + .map((a) => a.name); + + if (nextBatchArtists.length > 0) { + queryClient.prefetchQuery(searchArtistLinksQuery(nextBatchArtists)); + } + }, [currentArtistId, artists, queryClient]); +} diff --git a/src/api/artistSearch/useSearchArtistLinksQuery.ts b/src/api/artistSearch/useSearchArtistLinksQuery.ts new file mode 100644 index 000000000..5056f6adc --- /dev/null +++ b/src/api/artistSearch/useSearchArtistLinksQuery.ts @@ -0,0 +1,55 @@ +import { queryOptions, useQuery } from "@tanstack/react-query"; +import { supabase } from "@/integrations/supabase/client"; +import { + artistSearchKeys, + searchResponseSchema, + type SearchResponse, + type Provider, +} from "./types"; + +async function fetchSearchArtistLinks( + artistNames: string[], + provider?: Provider, +): Promise { + if (artistNames.length === 0) { + return { results: [] }; + } + + const { data, error } = await supabase.functions.invoke( + "search-artist-links", + { + body: { + artistNames, + provider, + }, + }, + ); + + if (error) { + console.error("Error searching artist links:", error); + throw new Error("Failed to search artist links"); + } + + return searchResponseSchema.parse(data); +} + +export function searchArtistLinksQuery( + artistNames: string[], + provider?: Provider, +) { + return queryOptions({ + queryKey: artistSearchKeys.search(artistNames, provider), + queryFn: () => fetchSearchArtistLinks(artistNames, provider), + enabled: artistNames.length > 0, + staleTime: 1000 * 60 * 30, + }); +} + +export function useSearchArtistLinksQuery( + artistNames: string[], + provider?: Provider, +) { + return useQuery({ + ...searchArtistLinksQuery(artistNames, provider), + }); +} diff --git a/src/pages/admin/festivals/LinkWizard/CandidateCard.tsx b/src/pages/admin/festivals/LinkWizard/CandidateCard.tsx new file mode 100644 index 000000000..62741e4c1 --- /dev/null +++ b/src/pages/admin/festivals/LinkWizard/CandidateCard.tsx @@ -0,0 +1,103 @@ +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Users } from "lucide-react"; +import type { Candidate } from "@/api/artistSearch/types"; +import type { SelectableField } from "@/api/artistSearch/mergeCandidateSelection"; + +interface CandidateCardProps { + candidate: Candidate; + onSelect: (candidate: Candidate, fields: SelectableField[]) => void; +} + +export function CandidateCard({ candidate, onSelect }: CandidateCardProps) { + const availableFields: SelectableField[] = [ + "url", + ...(candidate.imageUrl ? (["image"] as const) : []), + ...(candidate.description ? (["description"] as const) : []), + ]; + + return ( + +
+ {candidate.imageUrl && ( + {candidate.name} + )} +
+

{candidate.name}

+ {candidate.followers !== null && ( +
+ + {formatFollowers(candidate.followers)} +
+ )} +
+ {candidate.genres.length > 0 && ( +
+ {candidate.genres.slice(0, 2).map((genre) => ( + + {genre} + + ))} + {candidate.genres.length > 2 && ( + + +{candidate.genres.length - 2} + + )} +
+ )} + +
+ + {candidate.imageUrl && ( + + )} + {candidate.description && ( + + )} +
+
+
+ ); +} + +function formatFollowers(followers: number): string { + return followers < 1000 + ? followers.toString() + : `${(followers / 1000).toFixed(1)}k`; +} diff --git a/src/pages/admin/festivals/LinkWizard/CandidateCards.tsx b/src/pages/admin/festivals/LinkWizard/CandidateCards.tsx new file mode 100644 index 000000000..4b887ce88 --- /dev/null +++ b/src/pages/admin/festivals/LinkWizard/CandidateCards.tsx @@ -0,0 +1,42 @@ +import { Skeleton } from "@/components/ui/skeleton"; +import type { Candidate } from "@/api/artistSearch/types"; +import type { SelectableField } from "@/api/artistSearch/mergeCandidateSelection"; +import { CandidateCard } from "./CandidateCard"; + +interface CandidateCardsProps { + candidates: Candidate[]; + isLoading: boolean; + onSelectCandidate: (candidate: Candidate, fields: SelectableField[]) => void; +} + +export function CandidateCards({ + candidates, + isLoading, + onSelectCandidate, +}: CandidateCardsProps) { + if (isLoading) { + return ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ); + } + + if (candidates.length === 0) { + return null; + } + + return ( +
+ {candidates.slice(0, 3).map((candidate) => ( + + ))} +
+ ); +} diff --git a/src/pages/admin/festivals/LinkWizard/LinkWizard.tsx b/src/pages/admin/festivals/LinkWizard/LinkWizard.tsx index 9e5f15852..73ec504f3 100644 --- a/src/pages/admin/festivals/LinkWizard/LinkWizard.tsx +++ b/src/pages/admin/festivals/LinkWizard/LinkWizard.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { Loader2, LinkIcon } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { useArtistsMissingLinksByEditionQuery } from "@/api/artists/useArtistsMissingLinksByEdition"; +import { usePrefetchNextBatchLinks } from "@/api/artistSearch/usePrefetchNextBatchLinks"; import type { AdminArtistsPageSize } from "@/pages/admin/ArtistsManagement/searchSchema"; import type { Artist } from "@/api/artists/types"; import { LinkWizardStep } from "./LinkWizardStep"; @@ -19,6 +20,8 @@ export function LinkWizard({ editionId }: LinkWizardProps) { const [page, setPage] = useState(0); const [pageSize, setPageSize] = useState(10); + usePrefetchNextBatchLinks(artistsQuery.data ?? [], currentArtistId); + if (artistsQuery.isLoading) { return ( @@ -71,6 +74,7 @@ export function LinkWizard({ editionId }: LinkWizardProps) { artist={currentArtist} position={currentIndex + 1} total={artists.length} + artists={artists} onPrev={() => goTo(currentIndex - 1)} onNext={() => goTo(currentIndex + 1)} /> diff --git a/src/pages/admin/festivals/LinkWizard/LinkWizardStep.tsx b/src/pages/admin/festivals/LinkWizard/LinkWizardStep.tsx index f88534d8f..ce4ef9209 100644 --- a/src/pages/admin/festivals/LinkWizard/LinkWizardStep.tsx +++ b/src/pages/admin/festivals/LinkWizard/LinkWizardStep.tsx @@ -2,38 +2,44 @@ import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form"; +import { Form } from "@/components/ui/form"; import { ChevronLeft, ChevronRight } from "lucide-react"; import type { Artist } from "@/api/artists/types"; -import { useUpdateArtistMutation } from "@/api/artists/useUpdateArtist"; +import { + useUpdateArtistMutation, + type UpdateArtistUpdates, +} from "@/api/artists/useUpdateArtist"; +import { + mergeCandidateSelection, + type SelectableField, +} from "@/api/artistSearch/mergeCandidateSelection"; +import type { Provider, Candidate } from "@/api/artistSearch/types"; +import { ProviderCandidatesPanel } from "./ProviderCandidatesPanel"; +import { StagedFieldsPreview } from "./StagedFieldsPreview"; +import { useArtistBatchQuery } from "./useArtistBatchQuery"; -function requiredUrlSchema(isRequired: boolean) { - return isRequired - ? z.string().url("Enter a valid URL") - : z.string().url().optional().or(z.literal("")); -} +const optionalUrlSchema = z + .string() + .url("Enter a valid URL") + .optional() + .or(z.literal("")); -function makeLinkStepSchema(artist: Artist) { - return z.object({ - spotifyUrl: requiredUrlSchema(!artist.spotify_url), - soundcloudUrl: requiredUrlSchema(!artist.soundcloud_url), - }); -} +const linkStepSchema = z.object({ + providerUrl: z.object({ + spotify: optionalUrlSchema, + soundcloud: optionalUrlSchema, + }), + image_url: z.string().nullable().optional(), + description: z.string().nullable().optional(), +}); -type LinkStepData = z.infer>; +export type LinkStepData = z.infer; interface LinkWizardStepProps { artist: Artist; position: number; total: number; + artists: Artist[]; onPrev: () => void; onNext: () => void; } @@ -42,36 +48,25 @@ export function LinkWizardStep({ artist, position, total, + artists, onPrev, onNext, }: LinkWizardStepProps) { const updateArtistMutation = useUpdateArtistMutation(); + const batchQueryResult = useArtistBatchQuery(artist, artists); const form = useForm({ - resolver: zodResolver(makeLinkStepSchema(artist)), + resolver: zodResolver(linkStepSchema), defaultValues: { - spotifyUrl: artist.spotify_url ?? "", - soundcloudUrl: artist.soundcloud_url ?? "", + providerUrl: { + spotify: artist.spotify_url ?? "", + soundcloud: artist.soundcloud_url ?? "", + }, + image_url: artist.image_url ?? undefined, + description: artist.description ?? undefined, }, }); - function onSubmit(data: LinkStepData) { - updateArtistMutation.mutate( - { - id: artist.id, - updates: { - ...(!artist.spotify_url && { - spotify_url: data.spotifyUrl || null, - }), - ...(!artist.soundcloud_url && { - soundcloud_url: data.soundcloudUrl || null, - }), - }, - }, - { onSuccess: onNext }, - ); - } - return (
@@ -83,45 +78,31 @@ export function LinkWizardStep({
{!artist.spotify_url && ( - ( - - Spotify URL - - - - - - )} + + handleCandidateSelect(candidate, "spotify", fields) + } /> )} {!artist.soundcloud_url && ( - ( - - SoundCloud URL - - - - - - )} + + handleCandidateSelect(candidate, "soundcloud", fields) + } /> )} + +
); + + function handleCandidateSelect( + candidate: Candidate, + provider: Provider, + fields: SelectableField[], + ) { + const update = mergeCandidateSelection(candidate, provider, fields, { + image_url: form.getValues("image_url"), + description: form.getValues("description"), + }); + + if (update.providerUrl) { + for (const [providerKey, url] of Object.entries(update.providerUrl)) { + form.setValue( + `providerUrl.${providerKey}` as + | "providerUrl.spotify" + | "providerUrl.soundcloud", + url, + { shouldDirty: true }, + ); + } + } + + if (update.image_url !== undefined) { + form.setValue("image_url", update.image_url, { shouldDirty: true }); + } + if (update.description !== undefined) { + form.setValue("description", update.description, { + shouldDirty: true, + }); + } + } + + function onSubmit(data: LinkStepData) { + if (!form.formState.isDirty) { + onNext(); + return; + } + + const updates: UpdateArtistUpdates = { + spotify_url: data.providerUrl.spotify || null, + soundcloud_url: data.providerUrl.soundcloud || null, + }; + + if (data.image_url) { + updates.image_url = data.image_url; + } + if (data.description !== undefined) { + updates.description = data.description; + } + + updateArtistMutation.mutate( + { id: artist.id, updates }, + { onSuccess: onNext }, + ); + } } diff --git a/src/pages/admin/festivals/LinkWizard/ProviderCandidatesPanel.tsx b/src/pages/admin/festivals/LinkWizard/ProviderCandidatesPanel.tsx new file mode 100644 index 000000000..4006f7cb1 --- /dev/null +++ b/src/pages/admin/festivals/LinkWizard/ProviderCandidatesPanel.tsx @@ -0,0 +1,108 @@ +import { useState } from "react"; +import type { UseQueryResult } from "@tanstack/react-query"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { AlertCircle, RotateCcw } from "lucide-react"; +import type { + Candidate, + Provider, + SearchResponse, +} from "@/api/artistSearch/types"; +import type { SelectableField } from "@/api/artistSearch/mergeCandidateSelection"; +import { CandidateCards } from "./CandidateCards"; +import { useProviderCandidates } from "./useProviderCandidates"; + +interface ProviderCandidatesPanelProps { + provider: Provider; + label: string; + artistName: string; + batchQueryResult: UseQueryResult; + onSelectCandidate: (candidate: Candidate, fields: SelectableField[]) => void; +} + +export function ProviderCandidatesPanel({ + provider, + label, + artistName, + batchQueryResult, + onSelectCandidate, +}: ProviderCandidatesPanelProps) { + const { candidates, error, isLoading, search } = useProviderCandidates( + provider, + artistName, + batchQueryResult, + ); + + const [showCustomSearch, setShowCustomSearch] = useState(false); + const [customSearchQuery, setCustomSearchQuery] = useState(""); + + function handleSearchClick() { + setShowCustomSearch(!showCustomSearch); + } + + function handleCustomSearch() { + if (customSearchQuery.trim()) { + search(customSearchQuery); + setCustomSearchQuery(""); + } + } + + return ( +
+
+

{label}

+ +
+ + {showCustomSearch && ( +
+ setCustomSearchQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleCustomSearch(); + } + }} + disabled={isLoading} + /> + +
+ )} + + {error && !isLoading && ( + + + {error} + + )} + + +
+ ); +} diff --git a/src/pages/admin/festivals/LinkWizard/StagedFieldsPreview.tsx b/src/pages/admin/festivals/LinkWizard/StagedFieldsPreview.tsx new file mode 100644 index 000000000..e925d1e37 --- /dev/null +++ b/src/pages/admin/festivals/LinkWizard/StagedFieldsPreview.tsx @@ -0,0 +1,104 @@ +import type { UseFormReturn } from "react-hook-form"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import type { LinkStepData } from "./LinkWizardStep"; + +const URL_FIELDS: { + fieldName: "providerUrl.spotify" | "providerUrl.soundcloud"; + label: string; + placeholder: string; +}[] = [ + { + fieldName: "providerUrl.spotify", + label: "Spotify URL", + placeholder: "https://open.spotify.com/artist/...", + }, + { + fieldName: "providerUrl.soundcloud", + label: "SoundCloud URL", + placeholder: "https://soundcloud.com/...", + }, +]; + +interface StagedFieldsPreviewProps { + form: UseFormReturn; +} + +export function StagedFieldsPreview({ form }: StagedFieldsPreviewProps) { + const imageUrl = form.watch("image_url"); + const description = form.watch("description"); + + return ( +
+

Staged

+ + {URL_FIELDS.map(({ fieldName, label, placeholder }) => ( + ( + + {label} + + + + + + )} + /> + ))} + + {(imageUrl || description !== undefined) && ( +
+ {imageUrl && ( + + )} +
+ {description !== undefined && ( + ( + + Description + +