diff --git a/app/layout.tsx b/app/layout.tsx index 989428f61..6ed4b2d3c 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -7,6 +7,7 @@ import Sidebar from "@/components/Sidebar"; import Header from "@/components/Header"; import { Suspense } from "react"; import ArtistSettingModal from "@/components/ArtistSettingModal"; +import AddArtistDialog from "@/components/Artists/AddArtistDialog"; import MobileDownloadModal from "@/components/ModalDownloadModal"; import ArtistsSidebar from "@/components/Artists/ArtistsSidebar"; import { ToastContainer } from "react-toastify"; @@ -85,6 +86,7 @@ export default function RootLayout({
+
diff --git a/components/Artists/AddArtistDialog.tsx b/components/Artists/AddArtistDialog.tsx new file mode 100644 index 000000000..8eac4e9dc --- /dev/null +++ b/components/Artists/AddArtistDialog.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useArtistProvider } from "@/providers/ArtistProvider"; +import { useAddSpotifyArtist } from "@/hooks/useAddSpotifyArtist"; +import SpotifyArtistSearch from "./SpotifyArtistSearch"; +import type { SpotifyArtistSearchResult } from "@/types/spotify"; + +/** + * Global "Add New Artist" dialog: search Spotify -> pick -> add to the roster. + * Opened by `toggleCreation` (Artists page, sidebar, header), replacing the old + * `/?q=create a new artist` redirect that dead-ended on the onboarding sequence. + */ +const AddArtistDialog = () => { + const { isCreationOpen, closeCreation } = useArtistProvider(); + const { add, isAdding } = useAddSpotifyArtist(); + + const handleSelect = async (artist: SpotifyArtistSearchResult) => { + const added = await add(artist); + if (added) closeCreation(); + }; + + return ( + { + if (!open) closeCreation(); + }} + > + + + Add a new artist + + Search Spotify and pick the artist to add them to your roster. + + + + + + ); +}; + +export default AddArtistDialog; diff --git a/components/Artists/SpotifyArtistSearch.tsx b/components/Artists/SpotifyArtistSearch.tsx new file mode 100644 index 000000000..b3ab0baa3 --- /dev/null +++ b/components/Artists/SpotifyArtistSearch.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useState } from "react"; +import { Loader, Search } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { useSpotifyArtistSearch } from "@/hooks/useSpotifyArtistSearch"; +import formatFollowerCount from "@/lib/utils/formatFollowerCount"; +import type { SpotifyArtistSearchResult } from "@/types/spotify"; + +interface SpotifyArtistSearchProps { + onSelect: (artist: SpotifyArtistSearchResult) => void; + /** Disables input + results while an add is in flight. */ + isBusy?: boolean; + autoFocus?: boolean; +} + +/** + * Shared Spotify artist typeahead: type -> see matches (avatar · name · + * followers) -> pick. Reused by the "Add New Artist" dialog and (next slice) + * social enrichment, so it only searches and reports the choice — the caller + * owns what happens on select. + */ +const SpotifyArtistSearch = ({ + onSelect, + isBusy, + autoFocus, +}: SpotifyArtistSearchProps) => { + const [query, setQuery] = useState(""); + const { results, isSearching } = useSpotifyArtistSearch(query); + const hasQuery = query.trim().length >= 2; + + return ( +
+
+ + setQuery(e.target.value)} + placeholder="Search Spotify for an artist" + className="pl-9" + aria-label="Search Spotify for an artist" + disabled={isBusy} + /> +
+ +
+ {isSearching && results.length === 0 && ( +

+ Searching... +

+ )} + {!isSearching && hasQuery && results.length === 0 && ( +

+ No artists found. +

+ )} + {results.map((artist) => ( + + ))} +
+
+ ); +}; + +export default SpotifyArtistSearch; diff --git a/hooks/useAddSpotifyArtist.ts b/hooks/useAddSpotifyArtist.ts new file mode 100644 index 000000000..23e270d66 --- /dev/null +++ b/hooks/useAddSpotifyArtist.ts @@ -0,0 +1,48 @@ +"use client"; + +import { useState } from "react"; +import { usePrivy } from "@privy-io/react-auth"; +import { toast } from "sonner"; +import { addSpotifyArtist } from "@/lib/artists/addSpotifyArtist"; +import { useArtistProvider } from "@/providers/ArtistProvider"; +import { useOrganization } from "@/providers/OrganizationProvider"; +import type { SpotifyArtistSearchResult } from "@/types/spotify"; + +/** + * Adds a Spotify-searched artist to the roster and refreshes + selects it. + * Wraps `addSpotifyArtist` with auth + the shared roster (ArtistProvider), + * mirroring `useAddRosterArtist`. + */ +export function useAddSpotifyArtist() { + const { getAccessToken } = usePrivy(); + const { getArtists } = useArtistProvider(); + const { selectedOrgId } = useOrganization(); + const [isAdding, setIsAdding] = useState(false); + + const add = async (artist: SpotifyArtistSearchResult): Promise => { + setIsAdding(true); + try { + const accessToken = await getAccessToken(); + if (!accessToken) { + throw new Error("Please sign in to add an artist"); + } + const created = await addSpotifyArtist( + accessToken, + artist, + selectedOrgId, + ); + await getArtists(created.account_id); + toast.success(`${artist.name} added to your roster`); + return true; + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to add artist", + ); + return false; + } finally { + setIsAdding(false); + } + }; + + return { add, isAdding }; +} diff --git a/hooks/useArtistMode.tsx b/hooks/useArtistMode.tsx index 292ac04ea..308c8832b 100644 --- a/hooks/useArtistMode.tsx +++ b/hooks/useArtistMode.tsx @@ -1,24 +1,28 @@ import { useUserProvider } from "@/providers/UserProvder"; import { ArtistRecord } from "@/types/Artist"; import { SETTING_MODE } from "@/types/Setting"; -import { useRouter } from "next/navigation"; import { Dispatch, SetStateAction, useState } from "react"; const useArtistMode = ( clearParams: () => void, - setEditableArtist: Dispatch> + setEditableArtist: Dispatch>, ) => { const [settingMode, setSettingMode] = useState(SETTING_MODE.UPDATE); const [isOpenSettingModal, setIsOpenSettingModal] = useState(false); + const [isCreationOpen, setIsCreationOpen] = useState(false); const { email } = useUserProvider(); - const { push } = useRouter(); + // Opens the shared Spotify-search dialog (AddArtistDialog). Previously this + // pushed `/?q=create a new artist`, which the onboarding router now + // intercepts for incomplete accounts — dead-ending in an /artists loop. const toggleCreation = () => { - clearParams(); if (!email) return; - push("/?q=create a new artist"); + clearParams(); + setIsCreationOpen(true); }; + const closeCreation = () => setIsCreationOpen(false); + const toggleUpdate = (artist: ArtistRecord) => { setSettingMode(SETTING_MODE.UPDATE); setEditableArtist(artist); @@ -32,6 +36,8 @@ const useArtistMode = ( toggleUpdate, toggleSettingModal, toggleCreation, + closeCreation, + isCreationOpen, settingMode, setSettingMode, isOpenSettingModal, diff --git a/hooks/useSpotifyArtistSearch.ts b/hooks/useSpotifyArtistSearch.ts new file mode 100644 index 000000000..225b0b3e5 --- /dev/null +++ b/hooks/useSpotifyArtistSearch.ts @@ -0,0 +1,60 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { getClientApiBaseUrl } from "@/lib/api/getClientApiBaseUrl"; +import type { + SpotifyArtistSearchResult, + SpotifySearchResponse, +} from "@/types/spotify"; + +interface UseSpotifyArtistSearchResult { + results: SpotifyArtistSearchResult[]; + isSearching: boolean; +} + +/** + * Debounced Spotify artist typeahead against the existing public + * `GET /api/spotify/search?type=artist` endpoint. Aborts the in-flight + * request when the query changes so results never arrive out of order. + */ +export function useSpotifyArtistSearch( + query: string, +): UseSpotifyArtistSearchResult { + const [results, setResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); + + useEffect(() => { + const trimmed = query.trim(); + if (trimmed.length < 2) { + setResults([]); + setIsSearching(false); + return; + } + + setIsSearching(true); + const controller = new AbortController(); + const timer = setTimeout(async () => { + try { + const res = await fetch( + `${getClientApiBaseUrl()}/api/spotify/search?q=${encodeURIComponent( + trimmed, + )}&type=artist`, + { signal: controller.signal }, + ); + const data: SpotifySearchResponse = await res.json(); + setResults(data.artists?.items ?? []); + } catch { + if (!controller.signal.aborted) setResults([]); + } finally { + if (!controller.signal.aborted) setIsSearching(false); + } + }, 300); + + return () => { + controller.abort(); + clearTimeout(timer); + }; + }, [query]); + + return { results, isSearching }; +} diff --git a/lib/artists/__tests__/addSpotifyArtist.test.ts b/lib/artists/__tests__/addSpotifyArtist.test.ts new file mode 100644 index 000000000..e0aa8057c --- /dev/null +++ b/lib/artists/__tests__/addSpotifyArtist.test.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { addSpotifyArtist } from "@/lib/artists/addSpotifyArtist"; +import { createRosterArtist } from "@/lib/artists/createRosterArtist"; +import saveArtist from "@/lib/saveArtist"; +import type { SpotifyArtistSearchResult } from "@/types/spotify"; + +vi.mock("@/lib/artists/createRosterArtist", () => ({ + createRosterArtist: vi.fn(), +})); +vi.mock("@/lib/saveArtist", () => ({ default: vi.fn() })); + +const spotifyArtist: SpotifyArtistSearchResult = { + id: "0xPoVNPnxIIUS1vrxAYV00", + name: "Del Water Gap", + type: "artist", + uri: "spotify:artist:0xPoVNPnxIIUS1vrxAYV00", + external_urls: { + spotify: "https://open.spotify.com/artist/0xPoVNPnxIIUS1vrxAYV00", + }, + images: [{ url: "https://i.scdn.co/image/big", height: 640, width: 640 }], + popularity: 60, + genres: [], + followers: { href: null, total: 317952 }, +}; + +describe("addSpotifyArtist", () => { + const token = "tok"; + const created = { id: "acc-1", account_id: "acc-1", name: "Del Water Gap" }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(createRosterArtist).mockResolvedValue(created as never); + vi.mocked(saveArtist).mockResolvedValue({ + artist: { ...created, image: "https://i.scdn.co/image/big" }, + } as never); + }); + + it("creates the artist by name then links its Spotify image + profile URL", async () => { + const result = await addSpotifyArtist(token, spotifyArtist, "org-1"); + + expect(createRosterArtist).toHaveBeenCalledWith( + token, + "Del Water Gap", + "org-1", + ); + expect(saveArtist).toHaveBeenCalledWith(token, "acc-1", { + image: "https://i.scdn.co/image/big", + profileUrls: { SPOTIFY: spotifyArtist.external_urls.spotify }, + }); + expect(result.account_id).toBe("acc-1"); + }); + + it("omits the image when the Spotify result has none", async () => { + await addSpotifyArtist(token, { ...spotifyArtist, images: [] }); + expect(saveArtist).toHaveBeenCalledWith(token, "acc-1", { + profileUrls: { SPOTIFY: spotifyArtist.external_urls.spotify }, + }); + }); +}); diff --git a/lib/artists/addSpotifyArtist.ts b/lib/artists/addSpotifyArtist.ts new file mode 100644 index 000000000..441d0995a --- /dev/null +++ b/lib/artists/addSpotifyArtist.ts @@ -0,0 +1,30 @@ +import type { ArtistRecord } from "@/types/Artist"; +import type { SpotifyArtistSearchResult } from "@/types/spotify"; +import { createRosterArtist } from "@/lib/artists/createRosterArtist"; +import saveArtist from "@/lib/saveArtist"; + +/** + * Adds a Spotify-searched artist to the roster with real data: creates the + * artist by name (`POST /api/artists`, which only accepts a name), then links + * its Spotify profile URL and avatar image (`PATCH /api/artists/{id}`) so the + * roster card and reports have the correct image + a Spotify social to enrich. + */ +export async function addSpotifyArtist( + accessToken: string, + artist: SpotifyArtistSearchResult, + orgId?: string | null, +): Promise { + const created = await createRosterArtist(accessToken, artist.name, orgId); + + const imageUrl = artist.images?.[0]?.url; + const { artist: updated } = await saveArtist( + accessToken, + created.account_id, + { + ...(imageUrl ? { image: imageUrl } : {}), + profileUrls: { SPOTIFY: artist.external_urls.spotify }, + }, + ); + + return updated ?? created; +}