-
Your Swaplists
-
- Jump back in, the queue's waiting
+ {/* Swaplists carousel / empty CTA */}
+
+ {sortedPlaylists.length > 0 ? (
+ <>
+
+
+
Your Swaplists
+
+ Jump back in, the queue's waiting
+
+
+
+ Show all
+
+
+
+
+ {sortedPlaylists.map((playlist, i) => (
+
+
+
+ ))}
+
+ >
+ ) : (
+
+
+
+
+ {(activeCircle?.memberCount ?? 0) <= 1
+ ? 'Create a Swaplist and invite friends'
+ : 'Start your first Swaplist'}
+
+
+ {(activeCircle?.memberCount ?? 0) <= 1
+ ? 'Set up a shared playlist, then invite your crew to start swapping tracks.'
+ : 'Your circle is ready \u2014 create a shared playlist or import one from Spotify.'}
+
+
+
+ Create
+
+
+
+ Import
+
+
-
- Show all
-
-
-
-
- {sortedPlaylists.map((playlist, i) => (
-
-
-
- ))}
-
- )}
+ )}
+
{/* Spotlight onboarding tour */}
{showTour && circles.length > 0 && setShowTour(false)} />}
diff --git a/src/app/dashboard/ReactionsSection.tsx b/src/app/dashboard/ReactionsSection.tsx
new file mode 100644
index 0000000..9c83b80
--- /dev/null
+++ b/src/app/dashboard/ReactionsSection.tsx
@@ -0,0 +1,99 @@
+'use client';
+
+import Link from 'next/link';
+import Image from 'next/image';
+import { m } from 'motion/react';
+import { springs } from '@/lib/motion';
+
+interface ReactionRecap {
+ reactorName: string;
+ reactorAvatar: string | null;
+ reaction: string;
+ trackName: string;
+ albumImageUrl: string | null;
+ playlistId: string;
+ createdAt: string;
+}
+
+interface ReactionsSectionProps {
+ reactionRecap: ReactionRecap[];
+}
+
+/** Maps reaction keys to their emoji, background color, and border color. */
+const REACTION_MAP: Record = {
+ fire: { emoji: '\u{1F525}', bg: 'rgba(251,146,60,0.1)', border: 'rgba(251,146,60,0.15)' },
+ heart: {
+ emoji: '\u{2764}\u{FE0F}',
+ bg: 'rgba(217,70,239,0.1)',
+ border: 'rgba(217,70,239,0.15)',
+ },
+ thumbs_up: { emoji: '\u{1F44D}', bg: 'rgba(196,244,65,0.08)', border: 'rgba(196,244,65,0.12)' },
+ thumbs_down: {
+ emoji: '\u{1F44E}',
+ bg: 'rgba(255,255,255,0.04)',
+ border: 'rgba(255,255,255,0.08)',
+ },
+};
+
+const DEFAULT_ACCENT = {
+ emoji: '',
+ bg: 'rgba(255,255,255,0.04)',
+ border: 'rgba(255,255,255,0.08)',
+};
+
+export default function ReactionsSection({ reactionRecap }: ReactionsSectionProps) {
+ if (reactionRecap.length === 0) return null;
+
+ return (
+
+
+
Hits Different
+
People are feeling your picks rn
+
+
+ {reactionRecap.map((recap, i) => {
+ const accent = REACTION_MAP[recap.reaction] ?? {
+ ...DEFAULT_ACCENT,
+ emoji: recap.reaction,
+ };
+ return (
+
+
+ {recap.albumImageUrl ? (
+
+ ) : (
+
+ )}
+
+ {accent.emoji}
+
+
+
+
+ {recap.reactorName}
+
+
{recap.trackName}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/app/globals.css b/src/app/globals.css
index 5004b24..e188494 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -32,11 +32,11 @@
--glass-border: rgba(255, 255, 255, 0.06);
--glass-bg-hover: rgba(255, 255, 255, 0.08);
- /* Text */
+ /* Text — brightened for readability on deep-dark backgrounds */
--text-primary: #f1f5f9;
- --text-secondary: #94a3b8;
- --text-tertiary: #64748b;
- --text-muted: #475569;
+ --text-secondary: #b0bfd0;
+ --text-tertiary: #8494a7;
+ --text-muted: #64748b;
--text-on-accent: #0a0a0a;
--bottom-nav-height: 5rem;
@@ -165,13 +165,14 @@ body {
letter-spacing: 0.01em;
}
-/* Content text uses the lighter body font; UI chrome stays in Gabarito */
+/* Content text uses the body font at medium weight; UI chrome stays in Gabarito */
p,
li,
dd,
blockquote,
.font-body {
font-family: var(--font-jakarta), sans-serif;
+ font-weight: 500;
}
/* Glassmorphism utility */
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 568bb9a..31f62ae 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -34,7 +34,7 @@ const montserrat = Montserrat({
const plusJakarta = Plus_Jakarta_Sans({
variable: '--font-jakarta',
subsets: ['latin'],
- weight: ['300', '400', '500'],
+ weight: ['400', '500', '600'],
});
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://swapify.312.dev';
diff --git a/src/app/playlist/[playlistId]/PlaylistDetailClient.tsx b/src/app/playlist/[playlistId]/PlaylistDetailClient.tsx
index 9b0c0ed..ebe5446 100644
--- a/src/app/playlist/[playlistId]/PlaylistDetailClient.tsx
+++ b/src/app/playlist/[playlistId]/PlaylistDetailClient.tsx
@@ -1,27 +1,22 @@
'use client';
-import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
-import { useRouter, useSearchParams } from 'next/navigation';
+import { useState } from 'react';
+import { useRouter } from 'next/navigation';
import { useAlbumColors } from '@/hooks/useAlbumColors';
import ShareSheet from '@/components/ShareSheet';
import EditDetailsModal from '@/components/EditDetailsModal';
import PlaylistTabs from '@/components/PlaylistTabs';
import LikedTracksView from '@/components/LikedTracksView';
import OutcastTracksView from '@/components/OutcastTracksView';
-import { toast } from 'sonner';
import ReauthOverlay from '@/components/ReauthOverlay';
-import PlaylistSortControl, { type ClientSortMode } from '@/components/PlaylistSortControl';
-import { useUnreadActivity } from '@/components/UnreadActivityProvider';
-import type {
- PlaylistDetailClientProps,
- TrackData,
- LikedTrack,
- OutcastTrack,
- PlaylistMember,
-} from './types';
+import PlaylistSortControl from '@/components/PlaylistSortControl';
+import type { PlaylistDetailClientProps } from './types';
import PlaylistHeader from './PlaylistHeader';
import InboxTabContent from './InboxTabContent';
import FollowGateBanner from './FollowGateBanner';
+import { useCurrentTrack } from './use-current-track';
+import { useSortedTracks } from './use-sorted-tracks';
+import { usePlaylistActions } from './use-playlist-actions';
export default function PlaylistDetailClient({
playlistId,
@@ -40,290 +35,73 @@ export default function PlaylistDetailClient({
circleMemberCount,
}: PlaylistDetailClientProps) {
const router = useRouter();
- const searchParams = useSearchParams();
- const [needsReauth, setNeedsReauth] = useState(false);
- const [tracks, setTracks] = useState([]);
- const [members, setMembers] = useState([]);
const [activeTab, setActiveTab] = useState<'inbox' | 'liked' | 'outcasts'>('inbox');
- const [likedTracks, setLikedTracks] = useState([]);
- const [outcastTracks, setOutcastTracks] = useState([]);
- const [likedPlaylistId, setLikedPlaylistId] = useState(null);
- const [showShare, setShowShare] = useState(false);
- const [showEditDetails, setShowEditDetails] = useState(false);
- const [currentName, setCurrentName] = useState(playlistName);
- const [currentDescription, setCurrentDescription] = useState(playlistDescription);
- const [currentImageUrl, setCurrentImageUrl] = useState(playlistImageUrl);
- const [currentVibeName, setCurrentVibeName] = useState(vibeName);
- const [syncing, setSyncing] = useState(false);
- const [initialLoading, setInitialLoading] = useState(true);
- const [isDragOver, setIsDragOver] = useState(false);
- const [isFollowing, setIsFollowing] = useState(null);
- const [followLoading, setFollowLoading] = useState(false);
- const [clientSort, setClientSort] = useState('default');
- const [energyScores, setEnergyScores] = useState | null>(null);
- const [loadingEnergy, setLoadingEnergy] = useState(false);
- const [playingTrackId, setPlayingTrackId] = useState(null);
- const dropRef = useRef(null);
- const albumColors = useAlbumColors(currentImageUrl);
- const { markRead } = useUnreadActivity();
-
- // Mark this playlist's activity as read on mount
- useEffect(() => {
- markRead({ playlistId });
- }, [markRead, playlistId]);
-
- // Poll current player state for play head indicator
- useEffect(() => {
- async function fetchCurrentTrack() {
- try {
- const res = await fetch('/api/player/current');
- if (!res.ok) return;
- const data = await res.json();
- setPlayingTrackId(data.trackId ?? null);
- } catch {
- // Keep existing state
- }
- }
- fetchCurrentTrack();
- const interval = setInterval(fetchCurrentTrack, 15_000);
- return () => clearInterval(interval);
- }, []);
-
- // Fetch energy scores lazily when user selects energy sort
- const fetchEnergyScores = useCallback(async () => {
- if (energyScores || loadingEnergy) return;
- setLoadingEnergy(true);
- try {
- const res = await fetch(`/api/playlists/${playlistId}/audio-features`);
- if (res.ok) {
- const data = await res.json();
- setEnergyScores(data.energyScores);
- }
- } catch {
- // Silently fail
- } finally {
- setLoadingEnergy(false);
- }
- }, [playlistId, energyScores, loadingEnergy]);
-
- function handleSortChange(mode: ClientSortMode) {
- if ((mode === 'energy_asc' || mode === 'energy_desc') && !energyScores) {
- fetchEnergyScores();
- }
- setClientSort(mode);
- }
-
- // Client-side sorted tracks (temporary, resets on refresh)
- const sortedTracks = useMemo(() => {
- if (clientSort === 'default') return tracks;
- return sortTracksByMode([...tracks], clientSort, energyScores);
- }, [tracks, clientSort, energyScores]);
-
- // Tracks added by others that the current user hasn't listened to
- const unheardTracks = useMemo(
- () =>
- tracks.filter((track) => {
- if (track.addedBy.id === currentUserId) return false;
- const myProgress = track.progress.find((p) => p.id === currentUserId);
- return !myProgress?.hasListened;
- }),
- [tracks, currentUserId]
- );
-
- const unheardIds = useMemo(() => new Set(unheardTracks.map((t) => t.id)), [unheardTracks]);
- const unplayedSorted = useMemo(
- () => sortedTracks.filter((t) => unheardIds.has(t.id)),
- [sortedTracks, unheardIds]
- );
- const playedSorted = useMemo(
- () => sortedTracks.filter((t) => !unheardIds.has(t.id)),
- [sortedTracks, unheardIds]
- );
-
- const fetchTracks = useCallback(async () => {
- try {
- const res = await fetch(`/api/playlists/${playlistId}/tracks`);
- if (!res.ok) {
- handleApiError(res, setNeedsReauth);
- return;
- }
- const data = await res.json();
- setTracks(data.tracks);
- setMembers(data.members);
- setLikedTracks(data.likedTracks ?? []);
- setOutcastTracks(data.outcastTracks ?? []);
- setLikedPlaylistId(data.likedPlaylistId ?? null);
- if (data.vibeName !== undefined) setCurrentVibeName(data.vibeName);
- } catch {
- // Silently fail on refresh
- } finally {
- setInitialLoading(false);
- }
- }, [playlistId]);
-
- // Check Spotify follow status on mount
- useEffect(() => {
- fetch(`/api/playlists/${playlistId}/follow-status`)
- .then((res) => (res.ok ? res.json() : { isFollowing: true }))
- .then((data) => setIsFollowing(data.isFollowing))
- .catch(() => setIsFollowing(true)); // Fail open
- }, [playlistId]);
-
- async function handleFollow() {
- setFollowLoading(true);
- try {
- const res = await fetch(`/api/playlists/${playlistId}/follow`, { method: 'POST' });
- if (!res.ok) throw new Error();
- setIsFollowing(true);
- toast.success('Playlist followed on Spotify!');
- } catch {
- toast.error('Failed to follow playlist');
- } finally {
- setFollowLoading(false);
- }
- }
-
- // Initial fetch + auto-refresh every 10s
- useEffect(() => {
- fetchTracks();
- const interval = setInterval(fetchTracks, 10000);
- return () => clearInterval(interval);
- }, [fetchTracks]);
-
- // Auto-open share sheet when arriving from playlist creation (host only, solo circle)
- const shareTriggered = useRef(false);
- useEffect(() => {
- if (isOwner && searchParams.get('share') === '1' && !shareTriggered.current) {
- shareTriggered.current = true;
- window.history.replaceState({}, '', `/playlist/${playlistId}`);
- if (circleMemberCount <= 1) {
- setShowShare(true);
- }
- }
- }, [searchParams, playlistId, isOwner, circleMemberCount]);
-
- async function syncFromSpotify() {
- setSyncing(true);
- try {
- const res = await fetch(`/api/playlists/${playlistId}/tracks/sync`, { method: 'POST' });
- if (!res.ok) {
- handleApiError(res, setNeedsReauth);
- return;
- }
- const data = await res.json();
- applySyncMetadata(data.metadata, setCurrentName, setCurrentDescription, setCurrentImageUrl);
- await fetchTracks();
- } catch {
- // Silently fail
- } finally {
- setSyncing(false);
- }
- }
-
- // Play a track on Spotify (optimistic playingTrackId update)
- const handlePlayTrack = useCallback(
- async (spotifyTrackUri: string, spotifyTrackId: string) => {
- const prev = playingTrackId;
- setPlayingTrackId(spotifyTrackId);
- try {
- const res = await fetch('/api/spotify/play', {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- trackUri: spotifyTrackUri,
- contextUri: `spotify:playlist:${spotifyPlaylistId}`,
- }),
- });
- if (!res.ok) {
- setPlayingTrackId(prev);
- handlePlaybackError(res);
- }
- } catch {
- setPlayingTrackId(prev);
- toast.error('Could not reach server');
- }
- },
- [playingTrackId, spotifyPlaylistId]
- );
- async function handleReaction(spotifyTrackId: string, reaction: string) {
- try {
- await fetch(`/api/playlists/${playlistId}/reactions`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ spotifyTrackId, reaction }),
- });
- fetchTracks();
- } catch {
- // Silently fail
- }
- }
+ // Player polling
+ const { playingTrackId, setPlayingTrackId } = useCurrentTrack();
+
+ // Playlist data, actions, and UI state
+ const {
+ tracks,
+ members,
+ likedTracks,
+ outcastTracks,
+ likedPlaylistId,
+ setLikedPlaylistId,
+ likedSyncMode,
+ setLikedSyncMode,
+ likedPlaylistName,
+ setLikedPlaylistName,
+ initialLoading,
+ syncing,
+ needsReauth,
+ isDragOver,
+ isFollowing,
+ followLoading,
+ showShare,
+ setShowShare,
+ showEditDetails,
+ setShowEditDetails,
+ currentName,
+ setCurrentName,
+ currentDescription,
+ setCurrentDescription,
+ currentImageUrl,
+ setCurrentImageUrl,
+ currentVibeName,
+ syncFromSpotify,
+ handlePlayTrack,
+ handleReaction,
+ handleFollow,
+ handleDragOver,
+ handleDragLeave,
+ handleDrop,
+ dropRef,
+ } = usePlaylistActions({
+ playlistId,
+ currentUserId,
+ spotifyPlaylistId,
+ isOwner,
+ circleMemberCount,
+ playlistName,
+ playlistDescription,
+ playlistImageUrl,
+ vibeName,
+ playingTrackId,
+ setPlayingTrackId,
+ });
+
+ // Track sorting and filtering
+ const {
+ unheardTracks,
+ unplayedSorted,
+ playedSorted,
+ clientSort,
+ handleSortChange,
+ loadingEnergy,
+ } = useSortedTracks(tracks, currentUserId, playlistId);
- // Drag and drop from Spotify
- function handleDragOver(e: React.DragEvent) {
- e.preventDefault();
- setIsDragOver(true);
- }
-
- function handleDragLeave(e: React.DragEvent) {
- e.preventDefault();
- setIsDragOver(false);
- }
-
- async function handleDrop(e: React.DragEvent) {
- e.preventDefault();
- setIsDragOver(false);
-
- const text = e.dataTransfer.getData('text/plain') || e.dataTransfer.getData('text/uri-list');
- if (!text) return;
-
- const match = text.match(/open\.spotify\.com\/track\/([a-zA-Z0-9]+)/);
- if (!match) {
- toast.info('Drop a Spotify track link to add it.');
- return;
- }
-
- await addDroppedTrack(match[1]!);
- }
-
- async function addDroppedTrack(trackId: string) {
- try {
- const searchRes = await fetch(`/api/spotify/search?q=track:${trackId}`);
- if (!searchRes.ok) {
- const searchErr = await searchRes.json().catch(() => null);
- throw new Error(searchErr?.error || 'Search failed');
- }
- const searchData = await searchRes.json();
- const track = searchData.tracks?.find((t: { id: string }) => t.id === trackId);
- if (!track) {
- toast.error('Could not find that track on Spotify.');
- return;
- }
-
- const res = await fetch(`/api/playlists/${playlistId}/tracks`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- spotifyTrackUri: track.uri,
- spotifyTrackId: track.id,
- trackName: track.name,
- artistName: track.artists.map((a: { name: string }) => a.name).join(', '),
- albumName: track.album.name,
- albumImageUrl: track.album.images[0]?.url || null,
- durationMs: track.duration_ms,
- }),
- });
-
- if (res.ok) {
- fetchTracks();
- } else {
- const data = await res.json();
- toast.error(data.error || 'Failed to add track');
- }
- } catch {
- toast.error('Failed to add the dropped track.');
- }
- }
+ const albumColors = useAlbumColors(currentImageUrl);
return (
setLikedPlaylistId(id)}
+ likedSyncMode={likedSyncMode}
+ likedPlaylistName={likedPlaylistName}
+ onSyncConfigured={(config) => {
+ setLikedPlaylistId(config.spotifyPlaylistId);
+ setLikedSyncMode(config.mode);
+ if (config.playlistName) setLikedPlaylistName(config.playlistName);
+ }}
+ onSyncDisconnected={() => {
+ setLikedPlaylistId(null);
+ setLikedSyncMode(null);
+ setLikedPlaylistName(null);
+ }}
playingTrackId={playingTrackId}
onPlay={handlePlayTrack}
/>
@@ -454,72 +243,3 @@ export default function PlaylistDetailClient({
);
}
-
-// -- Helper functions extracted to reduce cognitive complexity --
-
-function sortTracksByMode(
- sorted: TrackData[],
- mode: ClientSortMode,
- energyScores: Record | null
-): TrackData[] {
- switch (mode) {
- case 'date_asc':
- sorted.sort((a, b) => new Date(a.addedAt).getTime() - new Date(b.addedAt).getTime());
- break;
- case 'date_desc':
- sorted.sort((a, b) => new Date(b.addedAt).getTime() - new Date(a.addedAt).getTime());
- break;
- case 'energy_desc':
- if (energyScores) {
- sorted.sort(
- (a, b) => (energyScores[b.spotifyTrackId] ?? -1) - (energyScores[a.spotifyTrackId] ?? -1)
- );
- }
- break;
- case 'energy_asc':
- if (energyScores) {
- sorted.sort(
- (a, b) => (energyScores[a.spotifyTrackId] ?? 2) - (energyScores[b.spotifyTrackId] ?? 2)
- );
- }
- break;
- case 'creator_asc':
- sorted.sort((a, b) => a.addedBy.displayName.localeCompare(b.addedBy.displayName));
- break;
- case 'creator_desc':
- sorted.sort((a, b) => b.addedBy.displayName.localeCompare(a.addedBy.displayName));
- break;
- }
- return sorted;
-}
-
-async function handlePlaybackError(res: Response) {
- const data = await res.json().catch(() => null);
- if (res.status === 404) {
- toast.error('Open Spotify on a device first');
- } else if (res.status === 403) {
- toast.error('Spotify Premium required');
- } else {
- toast.error(data?.error || 'Playback failed');
- }
-}
-
-/** Parse error responses from our API and trigger reauth or toast as needed. */
-async function handleApiError(res: Response, setNeedsReauth: (v: boolean) => void) {
- const data = await res.json().catch(() => null);
- if (data?.needsReauth) setNeedsReauth(true);
- else if (data?.rateLimited) toast.error(data.error);
-}
-
-/** Apply sync metadata updates from the sync response. */
-function applySyncMetadata(
- metadata: Record | undefined,
- setName: (v: string) => void,
- setDescription: (v: string | null) => void,
- setImageUrl: (v: string | null) => void
-) {
- if (!metadata) return;
- if (metadata.name !== undefined) setName(metadata.name as string);
- if (metadata.description !== undefined) setDescription(metadata.description as string | null);
- if (metadata.imageUrl !== undefined) setImageUrl(metadata.imageUrl as string | null);
-}
diff --git a/src/app/playlist/[playlistId]/types.ts b/src/app/playlist/[playlistId]/types.ts
index 64a1cc3..7c62b4c 100644
--- a/src/app/playlist/[playlistId]/types.ts
+++ b/src/app/playlist/[playlistId]/types.ts
@@ -1,3 +1,5 @@
+export type LikedSyncMode = 'created' | 'funnel' | null;
+
export interface PlaylistDetailClientProps {
playlistId: string;
playlistName: string;
diff --git a/src/app/playlist/[playlistId]/use-current-track.ts b/src/app/playlist/[playlistId]/use-current-track.ts
new file mode 100644
index 0000000..8853181
--- /dev/null
+++ b/src/app/playlist/[playlistId]/use-current-track.ts
@@ -0,0 +1,35 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+
+/** Polling interval for fetching the current player track (ms). */
+export const PLAYER_POLL_INTERVAL_MS = 15_000;
+
+/**
+ * Polls the current Spotify player state at a fixed interval and returns
+ * the currently-playing track ID (or null).
+ */
+export function useCurrentTrack(): {
+ playingTrackId: string | null;
+ setPlayingTrackId: (id: string | null) => void;
+} {
+ const [playingTrackId, setPlayingTrackId] = useState(null);
+
+ useEffect(() => {
+ async function fetchCurrentTrack() {
+ try {
+ const res = await fetch('/api/player/current');
+ if (!res.ok) return;
+ const data = await res.json();
+ setPlayingTrackId(data.trackId ?? null);
+ } catch {
+ // Keep existing state
+ }
+ }
+ fetchCurrentTrack();
+ const interval = setInterval(fetchCurrentTrack, PLAYER_POLL_INTERVAL_MS);
+ return () => clearInterval(interval);
+ }, []);
+
+ return { playingTrackId, setPlayingTrackId };
+}
diff --git a/src/app/playlist/[playlistId]/use-playlist-actions.ts b/src/app/playlist/[playlistId]/use-playlist-actions.ts
new file mode 100644
index 0000000..9bd2286
--- /dev/null
+++ b/src/app/playlist/[playlistId]/use-playlist-actions.ts
@@ -0,0 +1,367 @@
+'use client';
+
+import { useState, useEffect, useCallback, useRef } from 'react';
+import { useSearchParams } from 'next/navigation';
+import { toast } from 'sonner';
+import { useUnreadActivity } from '@/components/UnreadActivityProvider';
+import type { TrackData, LikedTrack, OutcastTrack, PlaylistMember, LikedSyncMode } from './types';
+
+/** Interval for auto-refreshing the track list (ms). */
+export const TRACK_REFRESH_INTERVAL_MS = 10_000;
+
+export interface UsePlaylistActionsParams {
+ playlistId: string;
+ currentUserId: string;
+ spotifyPlaylistId: string;
+ isOwner: boolean;
+ circleMemberCount: number;
+ playlistName: string;
+ playlistDescription: string | null;
+ playlistImageUrl: string | null;
+ vibeName: string | null;
+ playingTrackId: string | null;
+ setPlayingTrackId: (id: string | null) => void;
+}
+
+export interface UsePlaylistActionsReturn {
+ tracks: TrackData[];
+ members: PlaylistMember[];
+ likedTracks: LikedTrack[];
+ outcastTracks: OutcastTrack[];
+ likedPlaylistId: string | null;
+ setLikedPlaylistId: (id: string | null) => void;
+ likedSyncMode: LikedSyncMode;
+ setLikedSyncMode: (mode: LikedSyncMode) => void;
+ likedPlaylistName: string | null;
+ setLikedPlaylistName: (name: string | null) => void;
+ initialLoading: boolean;
+ syncing: boolean;
+ needsReauth: boolean;
+ isDragOver: boolean;
+ isFollowing: boolean | null;
+ followLoading: boolean;
+ showShare: boolean;
+ setShowShare: (v: boolean) => void;
+ showEditDetails: boolean;
+ setShowEditDetails: (v: boolean) => void;
+ currentName: string;
+ setCurrentName: (v: string) => void;
+ currentDescription: string | null;
+ setCurrentDescription: (v: string | null) => void;
+ currentImageUrl: string | null;
+ setCurrentImageUrl: (v: string | null) => void;
+ currentVibeName: string | null;
+ syncFromSpotify: () => Promise;
+ handlePlayTrack: (spotifyTrackUri: string, spotifyTrackId: string) => Promise;
+ handleReaction: (spotifyTrackId: string, reaction: string) => Promise;
+ handleFollow: () => Promise;
+ handleDragOver: (e: React.DragEvent) => void;
+ handleDragLeave: (e: React.DragEvent) => void;
+ handleDrop: (e: React.DragEvent) => Promise;
+ dropRef: React.RefObject;
+}
+
+/**
+ * Encapsulates all playlist action handlers, data fetching, polling,
+ * and drag-and-drop logic for the playlist detail view.
+ */
+export function usePlaylistActions({
+ playlistId,
+ spotifyPlaylistId,
+ isOwner,
+ circleMemberCount,
+ playlistName,
+ playlistDescription,
+ playlistImageUrl,
+ vibeName,
+ playingTrackId,
+ setPlayingTrackId,
+}: UsePlaylistActionsParams): UsePlaylistActionsReturn {
+ const searchParams = useSearchParams();
+ const { markRead } = useUnreadActivity();
+
+ const [tracks, setTracks] = useState([]);
+ const [members, setMembers] = useState([]);
+ const [likedTracks, setLikedTracks] = useState([]);
+ const [outcastTracks, setOutcastTracks] = useState([]);
+ const [likedPlaylistId, setLikedPlaylistId] = useState(null);
+ const [likedSyncMode, setLikedSyncMode] = useState<'created' | 'funnel' | null>(null);
+ const [likedPlaylistName, setLikedPlaylistName] = useState(null);
+ const [needsReauth, setNeedsReauth] = useState(false);
+ const [showShare, setShowShare] = useState(false);
+ const [showEditDetails, setShowEditDetails] = useState(false);
+ const [currentName, setCurrentName] = useState(playlistName);
+ const [currentDescription, setCurrentDescription] = useState(playlistDescription);
+ const [currentImageUrl, setCurrentImageUrl] = useState(playlistImageUrl);
+ const [currentVibeName, setCurrentVibeName] = useState(vibeName);
+ const [syncing, setSyncing] = useState(false);
+ const [initialLoading, setInitialLoading] = useState(true);
+ const [isDragOver, setIsDragOver] = useState(false);
+ const [isFollowing, setIsFollowing] = useState(null);
+ const [followLoading, setFollowLoading] = useState(false);
+ const dropRef = useRef(null);
+
+ // Mark this playlist's activity as read on mount
+ useEffect(() => {
+ markRead({ playlistId });
+ }, [markRead, playlistId]);
+
+ // Fetch tracks
+ const fetchTracks = useCallback(async () => {
+ try {
+ const res = await fetch(`/api/playlists/${playlistId}/tracks`);
+ if (!res.ok) {
+ handleApiError(res, setNeedsReauth);
+ return;
+ }
+ const data = await res.json();
+ setTracks(data.tracks);
+ setMembers(data.members);
+ setLikedTracks(data.likedTracks ?? []);
+ setOutcastTracks(data.outcastTracks ?? []);
+ setLikedPlaylistId(data.likedPlaylistId ?? null);
+ setLikedSyncMode(data.likedSyncMode ?? null);
+ setLikedPlaylistName(data.likedPlaylistName ?? null);
+ if (data.vibeName !== undefined) setCurrentVibeName(data.vibeName);
+ } catch {
+ // Silently fail on refresh
+ } finally {
+ setInitialLoading(false);
+ }
+ }, [playlistId]);
+
+ // Initial fetch + auto-refresh
+ useEffect(() => {
+ fetchTracks();
+ const interval = setInterval(fetchTracks, TRACK_REFRESH_INTERVAL_MS);
+ return () => clearInterval(interval);
+ }, [fetchTracks]);
+
+ // Check Spotify follow status on mount
+ useEffect(() => {
+ fetch(`/api/playlists/${playlistId}/follow-status`)
+ .then((res) => (res.ok ? res.json() : { isFollowing: true }))
+ .then((data) => setIsFollowing(data.isFollowing))
+ .catch(() => setIsFollowing(true)); // Fail open
+ }, [playlistId]);
+
+ // Auto-open share sheet when arriving from playlist creation (host only, solo circle)
+ const shareTriggered = useRef(false);
+ useEffect(() => {
+ if (isOwner && searchParams.get('share') === '1' && !shareTriggered.current) {
+ shareTriggered.current = true;
+ window.history.replaceState({}, '', `/playlist/${playlistId}`);
+ if (circleMemberCount <= 1) {
+ setShowShare(true);
+ }
+ }
+ }, [searchParams, playlistId, isOwner, circleMemberCount]);
+
+ async function handleFollow() {
+ setFollowLoading(true);
+ try {
+ const res = await fetch(`/api/playlists/${playlistId}/follow`, { method: 'POST' });
+ if (!res.ok) throw new Error();
+ setIsFollowing(true);
+ toast.success('Playlist followed on Spotify!');
+ } catch {
+ toast.error('Failed to follow playlist');
+ } finally {
+ setFollowLoading(false);
+ }
+ }
+
+ async function syncFromSpotify() {
+ setSyncing(true);
+ try {
+ const res = await fetch(`/api/playlists/${playlistId}/tracks/sync`, { method: 'POST' });
+ if (!res.ok) {
+ handleApiError(res, setNeedsReauth);
+ return;
+ }
+ const data = await res.json();
+ applySyncMetadata(data.metadata, setCurrentName, setCurrentDescription, setCurrentImageUrl);
+ await fetchTracks();
+ } catch {
+ // Silently fail
+ } finally {
+ setSyncing(false);
+ }
+ }
+
+ // Play a track on Spotify (optimistic playingTrackId update)
+ const handlePlayTrack = useCallback(
+ async (spotifyTrackUri: string, spotifyTrackId: string) => {
+ const prev = playingTrackId;
+ setPlayingTrackId(spotifyTrackId);
+ try {
+ const res = await fetch('/api/spotify/play', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ trackUri: spotifyTrackUri,
+ contextUri: `spotify:playlist:${spotifyPlaylistId}`,
+ }),
+ });
+ if (!res.ok) {
+ setPlayingTrackId(prev);
+ handlePlaybackError(res);
+ }
+ } catch {
+ setPlayingTrackId(prev);
+ toast.error('Could not reach server');
+ }
+ },
+ [playingTrackId, spotifyPlaylistId, setPlayingTrackId]
+ );
+
+ async function handleReaction(spotifyTrackId: string, reaction: string) {
+ try {
+ await fetch(`/api/playlists/${playlistId}/reactions`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ spotifyTrackId, reaction }),
+ });
+ fetchTracks();
+ } catch {
+ // Silently fail
+ }
+ }
+
+ // Drag and drop from Spotify
+ function handleDragOver(e: React.DragEvent) {
+ e.preventDefault();
+ setIsDragOver(true);
+ }
+
+ function handleDragLeave(e: React.DragEvent) {
+ e.preventDefault();
+ setIsDragOver(false);
+ }
+
+ async function handleDrop(e: React.DragEvent) {
+ e.preventDefault();
+ setIsDragOver(false);
+
+ const text = e.dataTransfer.getData('text/plain') || e.dataTransfer.getData('text/uri-list');
+ if (!text) return;
+
+ const match = text.match(/open\.spotify\.com\/track\/([a-zA-Z0-9]+)/);
+ if (!match) {
+ toast.info('Drop a Spotify track link to add it.');
+ return;
+ }
+
+ await addDroppedTrack(match[1]!);
+ }
+
+ async function addDroppedTrack(trackId: string) {
+ try {
+ const searchRes = await fetch(`/api/spotify/search?q=track:${trackId}`);
+ if (!searchRes.ok) {
+ const searchErr = await searchRes.json().catch(() => null);
+ throw new Error(searchErr?.error || 'Search failed');
+ }
+ const searchData = await searchRes.json();
+ const track = searchData.tracks?.find((t: { id: string }) => t.id === trackId);
+ if (!track) {
+ toast.error('Could not find that track on Spotify.');
+ return;
+ }
+
+ const res = await fetch(`/api/playlists/${playlistId}/tracks`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ spotifyTrackUri: track.uri,
+ spotifyTrackId: track.id,
+ trackName: track.name,
+ artistName: track.artists.map((a: { name: string }) => a.name).join(', '),
+ albumName: track.album.name,
+ albumImageUrl: track.album.images[0]?.url || null,
+ durationMs: track.duration_ms,
+ }),
+ });
+
+ if (res.ok) {
+ fetchTracks();
+ } else {
+ const data = await res.json();
+ toast.error(data.error || 'Failed to add track');
+ }
+ } catch {
+ toast.error('Failed to add the dropped track.');
+ }
+ }
+
+ return {
+ tracks,
+ members,
+ likedTracks,
+ outcastTracks,
+ likedPlaylistId,
+ setLikedPlaylistId,
+ likedSyncMode,
+ setLikedSyncMode,
+ likedPlaylistName,
+ setLikedPlaylistName,
+ initialLoading,
+ syncing,
+ needsReauth,
+ isDragOver,
+ isFollowing,
+ followLoading,
+ showShare,
+ setShowShare,
+ showEditDetails,
+ setShowEditDetails,
+ currentName,
+ setCurrentName,
+ currentDescription,
+ setCurrentDescription,
+ currentImageUrl,
+ setCurrentImageUrl,
+ currentVibeName,
+ syncFromSpotify,
+ handlePlayTrack,
+ handleReaction,
+ handleFollow,
+ handleDragOver,
+ handleDragLeave,
+ handleDrop,
+ dropRef,
+ };
+}
+
+// -- Helper functions --
+
+async function handlePlaybackError(res: Response) {
+ const data = await res.json().catch(() => null);
+ if (res.status === 404) {
+ toast.error('Open Spotify on a device first');
+ } else if (res.status === 403) {
+ toast.error('Spotify Premium required');
+ } else {
+ toast.error(data?.error || 'Playback failed');
+ }
+}
+
+/** Parse error responses from our API and trigger reauth or toast as needed. */
+async function handleApiError(res: Response, setNeedsReauth: (v: boolean) => void) {
+ const data = await res.json().catch(() => null);
+ if (data?.needsReauth) setNeedsReauth(true);
+ else if (data?.rateLimited) toast.error(data.error);
+}
+
+/** Apply sync metadata updates from the sync response. */
+function applySyncMetadata(
+ metadata: Record | undefined,
+ setName: (v: string) => void,
+ setDescription: (v: string | null) => void,
+ setImageUrl: (v: string | null) => void
+) {
+ if (!metadata) return;
+ if (metadata.name !== undefined) setName(metadata.name as string);
+ if (metadata.description !== undefined) setDescription(metadata.description as string | null);
+ if (metadata.imageUrl !== undefined) setImageUrl(metadata.imageUrl as string | null);
+}
diff --git a/src/app/playlist/[playlistId]/use-sorted-tracks.ts b/src/app/playlist/[playlistId]/use-sorted-tracks.ts
new file mode 100644
index 0000000..a5497e7
--- /dev/null
+++ b/src/app/playlist/[playlistId]/use-sorted-tracks.ts
@@ -0,0 +1,135 @@
+'use client';
+
+import { useState, useMemo, useCallback } from 'react';
+import type { ClientSortMode } from '@/components/PlaylistSortControl';
+import type { TrackData } from './types';
+
+export interface UseSortedTracksReturn {
+ /** Tracks sorted by the current client sort mode. */
+ sortedTracks: TrackData[];
+ /** Tracks added by others that the current user hasn't listened to. */
+ unheardTracks: TrackData[];
+ /** Unheard tracks in sorted order. */
+ unplayedSorted: TrackData[];
+ /** Heard tracks in sorted order. */
+ playedSorted: TrackData[];
+ /** Current client-side sort mode. */
+ clientSort: ClientSortMode;
+ /** Change the sort mode (triggers lazy energy fetch if needed). */
+ handleSortChange: (mode: ClientSortMode) => void;
+ /** Whether energy scores are currently being fetched. */
+ loadingEnergy: boolean;
+}
+
+/**
+ * Manages client-side sorting and filtering of playlist tracks.
+ * Includes lazy-loading of energy scores when the user selects energy-based sorts.
+ */
+export function useSortedTracks(
+ tracks: TrackData[],
+ currentUserId: string,
+ playlistId: string
+): UseSortedTracksReturn {
+ const [clientSort, setClientSort] = useState('default');
+ const [energyScores, setEnergyScores] = useState | null>(null);
+ const [loadingEnergy, setLoadingEnergy] = useState(false);
+
+ // Fetch energy scores lazily when user selects energy sort
+ const fetchEnergyScores = useCallback(async () => {
+ if (energyScores || loadingEnergy) return;
+ setLoadingEnergy(true);
+ try {
+ const res = await fetch(`/api/playlists/${playlistId}/audio-features`);
+ if (res.ok) {
+ const data = await res.json();
+ setEnergyScores(data.energyScores);
+ }
+ } catch {
+ // Silently fail
+ } finally {
+ setLoadingEnergy(false);
+ }
+ }, [playlistId, energyScores, loadingEnergy]);
+
+ function handleSortChange(mode: ClientSortMode) {
+ if ((mode === 'energy_asc' || mode === 'energy_desc') && !energyScores) {
+ fetchEnergyScores();
+ }
+ setClientSort(mode);
+ }
+
+ // Client-side sorted tracks (temporary, resets on refresh)
+ const sortedTracks = useMemo(() => {
+ if (clientSort === 'default') return tracks;
+ return sortTracksByMode([...tracks], clientSort, energyScores);
+ }, [tracks, clientSort, energyScores]);
+
+ // Tracks added by others that the current user hasn't listened to
+ const unheardTracks = useMemo(
+ () =>
+ tracks.filter((track) => {
+ if (track.addedBy.id === currentUserId) return false;
+ const myProgress = track.progress.find((p) => p.id === currentUserId);
+ return !myProgress?.hasListened;
+ }),
+ [tracks, currentUserId]
+ );
+
+ const unheardIds = useMemo(() => new Set(unheardTracks.map((t) => t.id)), [unheardTracks]);
+ const unplayedSorted = useMemo(
+ () => sortedTracks.filter((t) => unheardIds.has(t.id)),
+ [sortedTracks, unheardIds]
+ );
+ const playedSorted = useMemo(
+ () => sortedTracks.filter((t) => !unheardIds.has(t.id)),
+ [sortedTracks, unheardIds]
+ );
+
+ return {
+ sortedTracks,
+ unheardTracks,
+ unplayedSorted,
+ playedSorted,
+ clientSort,
+ handleSortChange,
+ loadingEnergy,
+ };
+}
+
+// -- Pure sorting helper --
+
+function sortTracksByMode(
+ sorted: TrackData[],
+ mode: ClientSortMode,
+ energyScores: Record | null
+): TrackData[] {
+ switch (mode) {
+ case 'date_asc':
+ sorted.sort((a, b) => new Date(a.addedAt).getTime() - new Date(b.addedAt).getTime());
+ break;
+ case 'date_desc':
+ sorted.sort((a, b) => new Date(b.addedAt).getTime() - new Date(a.addedAt).getTime());
+ break;
+ case 'energy_desc':
+ if (energyScores) {
+ sorted.sort(
+ (a, b) => (energyScores[b.spotifyTrackId] ?? -1) - (energyScores[a.spotifyTrackId] ?? -1)
+ );
+ }
+ break;
+ case 'energy_asc':
+ if (energyScores) {
+ sorted.sort(
+ (a, b) => (energyScores[a.spotifyTrackId] ?? 2) - (energyScores[b.spotifyTrackId] ?? 2)
+ );
+ }
+ break;
+ case 'creator_asc':
+ sorted.sort((a, b) => a.addedBy.displayName.localeCompare(b.addedBy.displayName));
+ break;
+ case 'creator_desc':
+ sorted.sort((a, b) => b.addedBy.displayName.localeCompare(a.addedBy.displayName));
+ break;
+ }
+ return sorted;
+}
diff --git a/src/app/profile/ProfileClient.tsx b/src/app/profile/ProfileClient.tsx
index ce72ac1..9fefc80 100644
--- a/src/app/profile/ProfileClient.tsx
+++ b/src/app/profile/ProfileClient.tsx
@@ -6,7 +6,7 @@ import { m } from 'motion/react';
import { springs, STAGGER_DELAY } from '@/lib/motion';
import { useAlbumColors } from '@/hooks/useAlbumColors';
import { darken, rgbaCss } from '@/lib/color-extract';
-import { LogOut, Wand2 } from 'lucide-react';
+import { LogOut, Zap } from 'lucide-react';
import ProfileHero from './ProfileHero';
import NotificationSettings from './NotificationSettings';
import { SectionHeader, ToggleRow } from './ProfileToggle';
@@ -98,7 +98,7 @@ function ProfileContent({ user, stats }: ProfileClientProps) {
}
+ icon={ }
label="Auto-reactions"
description="Skip = thumbs down, save to library = thumbs up"
enabled={user.autoNegativeReactions}
diff --git a/src/app/swaplists/SwaplistsClient.tsx b/src/app/swaplists/SwaplistsClient.tsx
index 67cb59f..248837b 100644
--- a/src/app/swaplists/SwaplistsClient.tsx
+++ b/src/app/swaplists/SwaplistsClient.tsx
@@ -1,7 +1,7 @@
'use client';
import { useState, useEffect } from 'react';
-import { useRouter } from 'next/navigation';
+import { useRouter, useSearchParams } from 'next/navigation';
import { toast } from 'sonner';
import { Plus, Download, ArrowUpDown } from 'lucide-react';
import { m } from 'motion/react';
@@ -13,14 +13,17 @@ import DiscoverSection from './DiscoverSection';
import SwaplistsEmptyState from './SwaplistsEmptyState';
import CreateSwaplistDialog from './CreateSwaplistDialog';
import ImportDrawer from './ImportDrawer';
+import SwaplistsHelpBanner from './SwaplistsHelpBanner';
export default function SwaplistsClient({
myPlaylists,
otherPlaylists,
spotifyId,
activeCircle,
+ helpBannerDismissed,
}: SwaplistsClientProps) {
const router = useRouter();
+ const searchParams = useSearchParams();
// View mode -- grid or list, persisted in localStorage
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
@@ -37,6 +40,14 @@ export default function SwaplistsClient({
const [showCreate, setShowCreate] = useState(false);
const [showImport, setShowImport] = useState(false);
+ // Auto-open create/import from ?action= deep link
+ useEffect(() => {
+ const action = searchParams.get('action');
+ if (action === 'create') setShowCreate(true);
+ else if (action === 'import') setShowImport(true);
+ if (action) router.replace('/swaplists', { scroll: false });
+ }, [searchParams, router]);
+
// Reauth state
const [needsReauth, setNeedsReauth] = useState(false);
@@ -117,6 +128,8 @@ export default function SwaplistsClient({
+
+
{/* Sort + View controls */}
{});
+ }
+
+ return (
+
+ {!dismissed && (
+
+
+
+
+
+
+
What's a Swaplist?
+
+ Think of it as a shared music inbox. Drop tracks for your friends, listen to theirs,
+ and react. Once everyone's heard a track it clears out, making room for fresh
+ picks.
+
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/app/swaplists/page.tsx b/src/app/swaplists/page.tsx
index c90771b..e49c492 100644
--- a/src/app/swaplists/page.tsx
+++ b/src/app/swaplists/page.tsx
@@ -140,6 +140,7 @@ export default async function SwaplistsPage() {
myPlaylists={myPlaylists}
otherPlaylists={otherPlaylists}
spotifyId={user.spotifyId}
+ helpBannerDismissed={user.helpBannerDismissed}
activeCircle={
activeCircle
? {
diff --git a/src/app/swaplists/swaplists-types.ts b/src/app/swaplists/swaplists-types.ts
index 3e7b28f..6703988 100644
--- a/src/app/swaplists/swaplists-types.ts
+++ b/src/app/swaplists/swaplists-types.ts
@@ -33,6 +33,7 @@ export interface SwaplistsClientProps {
otherPlaylists: (PlaylistData & { isMember: false })[];
spotifyId: string;
activeCircle: ActiveCircle | null;
+ helpBannerDismissed: boolean;
}
export type SortField = 'modified' | 'created';
diff --git a/src/components/ActivityEventCard.tsx b/src/components/ActivityEventCard.tsx
index 075cce0..2ec109c 100644
--- a/src/components/ActivityEventCard.tsx
+++ b/src/components/ActivityEventCard.tsx
@@ -1,10 +1,10 @@
'use client';
import Link from 'next/link';
-import Image from 'next/image';
import { motion } from 'motion/react';
import { springs, STAGGER_DELAY } from '@/lib/motion';
import AlbumArt from '@/components/AlbumArt';
+import { UserAvatar } from '@/components/ui/user-avatar';
import {
formatTimeAgo,
getEventDescription,
@@ -135,19 +135,16 @@ export default function ActivityEventCard({
>
{/* Avatar with badge */}
- {event.user.avatarUrl ? (
-
- ) : (
-
- {event.user.displayName[0]}
-
- )}
+
{getEventBadge(event.type)}
@@ -201,19 +198,16 @@ export default function ActivityEventCard({
{/* Top row: avatar + name + timestamp */}
- {event.user.avatarUrl ? (
-
- ) : (
-
- {event.user.displayName[0]}
-
- )}
+
{getEventBadge(event.type)}
diff --git a/src/components/ActivityFeed.tsx b/src/components/ActivityFeed.tsx
index 5106546..d1c1fad 100644
--- a/src/components/ActivityFeed.tsx
+++ b/src/components/ActivityFeed.tsx
@@ -10,6 +10,7 @@ import { springs, STAGGER_DELAY } from '@/lib/motion';
import { useUnreadActivity } from '@/components/UnreadActivityProvider';
import AlbumArt from '@/components/AlbumArt';
import AllActivityModal from '@/components/AllActivityModal';
+import { SkeletonRows } from '@/components/ui/skeleton-row';
import {
formatTimeAgo,
getEventRingColor,
@@ -17,8 +18,8 @@ import {
getEventBgAccent,
getEventLabel,
getDetailDescription,
-} from '@/components/activity-feed-utils';
-import type { ActivityEvent } from '@/lib/activity-utils';
+ type ActivityEvent,
+} from '@/lib/activity-utils';
// --- Bubble (horizontal scroll item) ---
@@ -79,7 +80,7 @@ function EventBubble({
{firstName}
- {label.text} · {formatTimeAgo(new Date(event.timestamp))}
+ {label.text} · {formatTimeAgo(new Date(event.timestamp), { compact: true })}
@@ -148,7 +149,7 @@ function DetailCard({ event, onClose }: Readonly<{ event: ActivityEvent; onClose
{event.user.displayName}
- {formatTimeAgo(new Date(event.timestamp))} ago
+ {formatTimeAgo(new Date(event.timestamp))}
@@ -237,17 +238,13 @@ export default function ActivityFeed() {
return (
- {loading
- ? [0, 1, 2, 3].map((i) => (
-
- ))
- : events.map((event, i) => (
-
- ))}
+ {loading ? (
+
+ ) : (
+ events.map((event, i) => (
+
+ ))
+ )}
{/* "See all" button */}
{!loading && events.length > 0 && (
diff --git a/src/components/ActivitySnippet.tsx b/src/components/ActivitySnippet.tsx
index b56259c..9f345e5 100644
--- a/src/components/ActivitySnippet.tsx
+++ b/src/components/ActivitySnippet.tsx
@@ -2,11 +2,12 @@
import { useState, useEffect } from 'react';
import Link from 'next/link';
-import Image from 'next/image';
import { m } from 'motion/react';
import { ChevronRight } from 'lucide-react';
import { springs, STAGGER_DELAY } from '@/lib/motion';
import AlbumArt from '@/components/AlbumArt';
+import { SkeletonRows } from '@/components/ui/skeleton-row';
+import { UserAvatar } from '@/components/ui/user-avatar';
import { formatTimeAgo, getEventDescription, getEventSubtext } from '@/lib/activity-utils';
import type { ActivityEvent } from '@/lib/activity-utils';
@@ -48,15 +49,7 @@ export default function ActivitySnippet() {
{loading ? (
- {[0, 1, 2].map((i) => (
-
- ))}
+
) : (
@@ -78,19 +71,14 @@ export default function ActivitySnippet() {
className="flex items-center gap-3 p-2.5 rounded-xl hover:bg-white/[0.03] transition-colors"
>
{/* User avatar */}
- {event.user.avatarUrl ? (
-
- ) : (
-
- {event.user.displayName[0]}
-
- )}
+
{/* Event text */}
diff --git a/src/components/AllActivityModal.tsx b/src/components/AllActivityModal.tsx
index a50d1ba..5f90761 100644
--- a/src/components/AllActivityModal.tsx
+++ b/src/components/AllActivityModal.tsx
@@ -7,13 +7,14 @@ import { m, AnimatePresence } from 'motion/react';
import { X } from 'lucide-react';
import { springs } from '@/lib/motion';
import AlbumArt from '@/components/AlbumArt';
+import { SkeletonRows } from '@/components/ui/skeleton-row';
import {
getEventTextColor,
getEventLabel,
getListDescription,
formatTimeAgo,
-} from '@/components/activity-feed-utils';
-import type { ActivityEvent } from '@/lib/activity-utils';
+ type ActivityEvent,
+} from '@/lib/activity-utils';
export default function AllActivityModal({ onClose }: Readonly<{ onClose: () => void }>) {
const router = useRouter();
@@ -66,15 +67,7 @@ export default function AllActivityModal({ onClose }: Readonly<{ onClose: () =>
{loadingAll ? (
- {[0, 1, 2, 3, 4, 5].map((i) => (
-
- ))}
+
) : allEvents.length === 0 ? (
@@ -130,7 +123,7 @@ export default function AllActivityModal({ onClose }: Readonly<{ onClose: () =>
{event.user.displayName}
- {formatTimeAgo(new Date(event.timestamp))}
+ {formatTimeAgo(new Date(event.timestamp), { compact: true })}
diff --git a/src/components/CircleCard.tsx b/src/components/CircleCard.tsx
index 8c4677f..1abf1e4 100644
--- a/src/components/CircleCard.tsx
+++ b/src/components/CircleCard.tsx
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
import { m } from 'motion/react';
import { Check, Crown, Users, Settings } from 'lucide-react';
import { STAGGER_DELAY } from '@/lib/motion';
+import { UserAvatar } from '@/components/ui/user-avatar';
interface CircleMember {
id: string;
@@ -175,15 +176,14 @@ function MemberAvatarStack({ members }: Readonly<{ members: CircleMember[] }>) {
- {member.avatarUrl ? (
-
- ) : (
-
- {member.displayName[0]}
-
- )}
+
))}
{members.length > 5 && (
diff --git a/src/components/LikedTracksView.tsx b/src/components/LikedTracksView.tsx
index 19e3697..f61acbc 100644
--- a/src/components/LikedTracksView.tsx
+++ b/src/components/LikedTracksView.tsx
@@ -1,13 +1,15 @@
'use client';
-import { useState } from 'react';
+import { useState, useEffect, useRef } from 'react';
import Image from 'next/image';
import { motion, AnimatePresence } from 'motion/react';
import { springs, STAGGER_DELAY } from '@/lib/motion';
-import { Music, Play } from 'lucide-react';
-import { formatDuration, NowPlayingBars } from '@/components/UnplayedTrackRow';
+import { Search, ExternalLink, Unlink, ArrowRightLeft, Plus } from 'lucide-react';
+import { TrackListRow, TRACK_ROW_GRID_COLUMNS } from '@/components/ui/track-list-row';
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip';
import { toast } from 'sonner';
+import GlassDrawer from '@/components/ui/glass-drawer';
+import type { LikedSyncMode } from '@/app/playlist/[playlistId]/types';
interface LikedTrack {
spotifyTrackId: string;
@@ -29,36 +31,69 @@ interface LikedTrack {
}>;
}
+interface SpotifyPlaylistOption {
+ id: string;
+ name: string;
+ imageUrl: string | null;
+ trackCount: number;
+ ownerId: string;
+ alreadyImported: boolean;
+}
+
interface LikedTracksViewProps {
playlistId: string;
likedTracks: LikedTrack[];
likedPlaylistId: string | null;
- onPlaylistCreated: (spotifyPlaylistId: string) => void;
+ likedSyncMode: LikedSyncMode;
+ likedPlaylistName: string | null;
+ onSyncConfigured: (config: {
+ spotifyPlaylistId: string;
+ mode: LikedSyncMode;
+ playlistName?: string;
+ }) => void;
+ onSyncDisconnected: () => void;
playingTrackId: string | null;
onPlay: (spotifyTrackUri: string, spotifyTrackId: string) => void;
}
+// ─── Spotify icon SVG (reused) ─────────────────────────────────────────────
+
+function SpotifyIcon({ className }: { className?: string }) {
+ return (
+
+
+
+ );
+}
+
+// ─── Main Component ─────────────────────────────────────────────────────────
+
export default function LikedTracksView({
playlistId,
likedTracks,
likedPlaylistId,
- onPlaylistCreated,
+ likedSyncMode,
+ likedPlaylistName,
+ onSyncConfigured,
+ onSyncDisconnected,
playingTrackId,
onPlay,
}: LikedTracksViewProps) {
const [creating, setCreating] = useState(false);
+ const [disconnecting, setDisconnecting] = useState(false);
+ const [showFunnelPicker, setShowFunnelPicker] = useState(false);
const [hoveredTrackId, setHoveredTrackId] = useState(null);
- async function handleOpenInSpotify() {
- if (likedPlaylistId) {
- window.open(`https://open.spotify.com/playlist/${likedPlaylistId}`, '_blank');
- return;
- }
+ const hasSync = !!likedPlaylistId;
+ const effectiveMode = likedSyncMode ?? (hasSync ? 'created' : null);
+ async function handleSaveToSpotify() {
setCreating(true);
try {
const res = await fetch(`/api/playlists/${playlistId}/liked-playlist`, {
method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ mode: 'created' }),
});
if (!res.ok) {
const data = await res.json();
@@ -66,7 +101,11 @@ export default function LikedTracksView({
return;
}
const data = await res.json();
- onPlaylistCreated(data.spotifyPlaylistId);
+ onSyncConfigured({
+ spotifyPlaylistId: data.spotifyPlaylistId,
+ mode: 'created',
+ playlistName: data.playlistName,
+ });
window.open(data.spotifyPlaylistUrl, '_blank');
toast.success('Liked playlist created on Spotify!');
} catch {
@@ -76,6 +115,63 @@ export default function LikedTracksView({
}
}
+ async function handleFunnelSelected(playlist: SpotifyPlaylistOption) {
+ setShowFunnelPicker(false);
+ setCreating(true);
+ try {
+ const res = await fetch(`/api/playlists/${playlistId}/liked-playlist`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ mode: 'funnel',
+ funnelPlaylistId: playlist.id,
+ funnelPlaylistName: playlist.name,
+ }),
+ });
+ if (!res.ok) {
+ const data = await res.json();
+ toast.error(data.error || 'Failed to set up funnel');
+ return;
+ }
+ const data = await res.json();
+ onSyncConfigured({
+ spotifyPlaylistId: data.spotifyPlaylistId,
+ mode: 'funnel',
+ playlistName: data.playlistName,
+ });
+ toast.success(`Liked tracks will funnel to "${playlist.name}"`);
+ } catch {
+ toast.error('Failed to set up funnel');
+ } finally {
+ setCreating(false);
+ }
+ }
+
+ async function handleDisconnect() {
+ setDisconnecting(true);
+ try {
+ const res = await fetch(`/api/playlists/${playlistId}/liked-playlist`, {
+ method: 'DELETE',
+ });
+ if (!res.ok) {
+ toast.error('Failed to disconnect');
+ return;
+ }
+ onSyncDisconnected();
+ toast.success('Sync disconnected');
+ } catch {
+ toast.error('Failed to disconnect');
+ } finally {
+ setDisconnecting(false);
+ }
+ }
+
+ function handleOpenInSpotify() {
+ if (likedPlaylistId) {
+ window.open(`https://open.spotify.com/playlist/${likedPlaylistId}`, '_blank');
+ }
+ }
+
if (likedTracks.length === 0) {
return (
@@ -88,30 +184,41 @@ export default function LikedTracksView({
return (
- {/* Open in Spotify button */}
-
- {creating ? (
- 'Creating playlist...'
- ) : (
- <>
-
-
-
- {likedPlaylistId ? 'Open in Spotify' : 'Save to Spotify'}
- >
- )}
-
+ {/* Sync configuration area */}
+ {!hasSync && !creating && (
+
setShowFunnelPicker(true)}
+ />
+ )}
+
+ {creating && (
+
+ )}
- {likedPlaylistId && (
-
- Your liked playlist syncs automatically
-
+ {hasSync && !creating && (
+ {
+ handleDisconnect();
+ }}
+ onDisconnect={handleDisconnect}
+ />
)}
+ {/* Funnel playlist picker drawer */}
+ setShowFunnelPicker(false)}
+ onSelect={handleFunnelSelected}
+ />
+
{/* Track list */}
@@ -130,136 +237,297 @@ export default function LikedTracksView({
transition={{ ...springs.gentle, delay: Math.min(index, 10) * STAGGER_DELAY }}
className="relative grid items-center gap-3 py-2 px-3 rounded-xl hover:bg-white/4 transition-colors cursor-pointer group"
style={{
- gridTemplateColumns: '40px minmax(0, 2fr) minmax(0, 1fr) 20px auto auto',
+ gridTemplateColumns: TRACK_ROW_GRID_COLUMNS,
}}
onClick={() => onPlay(track.spotifyTrackUri, track.spotifyTrackId)}
onMouseEnter={() => setHoveredTrackId(track.spotifyTrackId)}
onMouseLeave={() => setHoveredTrackId(null)}
>
- {/* Album art */}
-
- {track.albumImageUrl ? (
-
- ) : (
-
-
-
- )}
+
0 ? (
+
+
+ e.stopPropagation()}>
+
+ {totalPlays}x
+
+
+
+ {track.memberListenCounts
+ .filter((m) => m.listenCount > 0)
+ .map((m) => `${m.displayName}: ${m.listenCount}x`)
+ .join(', ')}
+
+
+
+ ) : undefined
+ }
+ />
+
+ );
+ })}
+
+
+
+ );
+}
- {isHovered && (
-
- )}
+// ─── Sync Mode Selector (no sync configured) ───────────────────────────────
+
+function SyncModeSelector({
+ onSaveToSpotify,
+ onFunnel,
+}: {
+ onSaveToSpotify: () => void;
+ onFunnel: () => void;
+}) {
+ return (
+
+
+
+
+
Save to Spotify
+
Create a new playlist with your likes
+
+
+
+
+
+
+
Funnel to playlist
+
+ Send liked tracks to an existing Spotify playlist
+
+
+
+
+ );
+}
+
+// ─── Sync Status Banner (sync active) ──────────────────────────────────────
+
+function SyncStatusBanner({
+ mode,
+ playlistName,
+ disconnecting,
+ onOpenInSpotify,
+ onChange,
+ onDisconnect,
+}: {
+ mode: 'created' | 'funnel';
+ playlistName: string | null;
+ disconnecting: boolean;
+ onOpenInSpotify: () => void;
+ onChange: () => void;
+ onDisconnect: () => void;
+}) {
+ return (
+
+
+
+ {mode === 'funnel' ? (
+
+ ) : (
+
+ )}
+
+
+
+ {mode === 'funnel' ? playlistName || 'Funneling' : playlistName || 'Liked playlist'}
+
+
+ {mode === 'funnel' ? 'New likes funnel here automatically' : 'Syncs automatically'}
+
+
+
+
+
+
+
+
+ {mode === 'funnel' && (
+
+ Change destination
+
+ )}
+ {mode === 'funnel' && | }
+
+
+ Disconnect
+
+
+
+ );
+}
+
+// ─── Funnel Playlist Picker Drawer ─────────────────────────────────────────
+
+function FunnelPlaylistPicker({
+ open,
+ onClose,
+ onSelect,
+}: {
+ open: boolean;
+ onClose: () => void;
+ onSelect: (playlist: SpotifyPlaylistOption) => void;
+}) {
+ const [playlists, setPlaylists] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [filter, setFilter] = useState('');
+ const fetchingRef = useRef(false);
- {!isHovered && isPlaying && (
-
-
+ useEffect(() => {
+ if (open && playlists.length === 0 && !fetchingRef.current) {
+ fetchingRef.current = true;
+ queueMicrotask(() => setLoading(true));
+ fetch('/api/spotify/playlists')
+ .then(async (res) => {
+ if (!res.ok) {
+ const data = await res.json().catch(() => null);
+ if (data?.rateLimited) {
+ toast.error(data.error || 'Spotify is busy. Try again soon.');
+ onClose();
+ return;
+ }
+ toast.error(data?.error || 'Failed to load playlists');
+ return;
+ }
+ const data = await res.json();
+ setPlaylists(data.playlists ?? []);
+ })
+ .catch(() => toast.error('Could not reach the server.'))
+ .finally(() => {
+ fetchingRef.current = false;
+ setLoading(false);
+ });
+ }
+ }, [open]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ // Filter out Swaplists (marked as alreadyImported) and apply search
+ const available = playlists
+ .filter((p) => !p.alreadyImported)
+ .filter((p) => p.name.toLowerCase().includes(filter.toLowerCase()));
+
+ function handleClose() {
+ onClose();
+ setFilter('');
+ }
+
+ return (
+
+
+ {/* Search */}
+
+
+
+ setFilter(e.target.value)}
+ placeholder="Filter playlists..."
+ className="w-full input-glass backdrop-blur-xl"
+ style={{ paddingLeft: '2.5rem' }}
+ enterKeyHint="search"
+ autoComplete="off"
+ autoCorrect="off"
+ spellCheck={false}
+ />
+
+
+
+ {loading ? (
+
+ ) : available.length === 0 ? (
+
+
+ {filter ? 'No playlists match your search' : 'No eligible playlists found'}
+
+
+ ) : (
+
+ {available.map((playlist) => (
+
onSelect(playlist)}
+ className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-white/5 transition-colors text-left"
+ >
+
+ {playlist.imageUrl ? (
+
+ ) : (
+
)}
-
- {/* Track name + artist */}
-
-
- {track.trackName}
+
-
- {/* Album name */}
- {track.albumName ? (
-
e.stopPropagation()}
- >
- {track.albumName}
-
- ) : (
-
- )}
-
- {/* Contributor avatar */}
-
-
- e.stopPropagation()}>
-
- {track.addedBy.avatarUrl ? (
-
-
-
- ) : track.addedBy.displayName ? (
-
-
- {track.addedBy.displayName.charAt(0).toUpperCase()}
-
-
- ) : null}
-
-
- {track.addedBy.displayName && (
-
- Added by {track.addedBy.displayName}
-
- )}
-
-
-
- {/* Listen count badge */}
-
- {totalPlays > 0 && (
-
-
- e.stopPropagation()}>
-
- {totalPlays}x
-
-
-
- {track.memberListenCounts
- .filter((m) => m.listenCount > 0)
- .map((m) => `${m.displayName}: ${m.listenCount}x`)
- .join(', ')}
-
-
-
- )}
-
-
- {/* Duration */}
-
- {formatDuration(track.durationMs)}
-
-
- );
- })}
-
+
+ ))}
+
+ )}
+
+ );
+}
+
+function PickerSkeleton() {
+ return (
+
+ {Array.from({ length: 6 }).map((_, i) => (
+
+ ))}
);
}
diff --git a/src/components/OutcastTracksView.tsx b/src/components/OutcastTracksView.tsx
index 216f922..70d9eff 100644
--- a/src/components/OutcastTracksView.tsx
+++ b/src/components/OutcastTracksView.tsx
@@ -1,11 +1,10 @@
'use client';
import { useState, useEffect, useMemo } from 'react';
-import Image from 'next/image';
import { motion, AnimatePresence } from 'motion/react';
import { springs, STAGGER_DELAY } from '@/lib/motion';
-import { Music, Play, Plus, Minus } from 'lucide-react';
-import { formatDuration, NowPlayingBars } from '@/components/UnplayedTrackRow';
+import { Plus, Minus } from 'lucide-react';
+import { TrackListRow, TRACK_ROW_GRID_COLUMNS } from '@/components/ui/track-list-row';
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip';
// ---------------------------------------------------------------------------
@@ -130,144 +129,62 @@ export default function OutcastTracksView({
transition={{ ...springs.gentle, delay: Math.min(index, 10) * STAGGER_DELAY }}
className="grid items-center gap-3 py-2 px-3 rounded-xl hover:bg-white/4 transition-colors cursor-pointer group"
style={{
- gridTemplateColumns: '40px minmax(0, 2fr) minmax(0, 1fr) 20px auto auto',
+ gridTemplateColumns: TRACK_ROW_GRID_COLUMNS,
}}
onClick={() => onPlay(track.spotifyTrackUri, track.spotifyTrackId)}
onMouseEnter={() => setHoveredTrackId(track.spotifyTrackId)}
onMouseLeave={() => setHoveredTrackId(null)}
>
- {/* Album art — always grayscale for outcasts */}
-
- {track.albumImageUrl ? (
-
- ) : (
-
-
-
- )}
-
- {isHovered && (
-
- )}
-
- {!isHovered && isPlaying && (
-
-
-
- )}
-
-
- {/* Track name + artist */}
-
-
- {/* Album name */}
- {track.albumName ? (
-
e.stopPropagation()}
- >
- {track.albumName}
-
- ) : (
-
- )}
-
- {/* Contributor avatar */}
-
-
- e.stopPropagation()}>
-
- {track.addedBy.avatarUrl ? (
-
-
-
- ) : track.addedBy.displayName ? (
-
-
- {track.addedBy.displayName.charAt(0).toUpperCase()}
-
-
- ) : null}
-
-
- {track.addedBy.displayName && (
-
- Added by {track.addedBy.displayName}
-
- )}
-
-
-
- {/* Reaction emoji + save button */}
-
- {track.reaction === 'thumbs_down' && (
-
- 👎
-
- )}
-
-
-
- {
- e.stopPropagation();
- toggleSaved(track.spotifyTrackId);
- }}
- disabled={isSaving}
- className={`w-6 h-6 rounded-full flex items-center justify-center transition-all ${
- isSaving
- ? 'opacity-50'
- : isSaved
- ? 'bg-brand/20 text-brand hover:bg-brand/30'
- : 'bg-white/5 text-text-tertiary hover:bg-white/10 hover:text-text-secondary'
- }`}
- >
- {isSaved ? : }
-
-
-
- {isSaved ? 'Remove from library' : 'Save to library'}
-
-
-
-
-
- {/* Duration */}
-
- {formatDuration(track.durationMs)}
-
+
+ {track.reaction === 'thumbs_down' && (
+
+ 👎
+
+ )}
+
+
+
+ {
+ e.stopPropagation();
+ toggleSaved(track.spotifyTrackId);
+ }}
+ disabled={isSaving}
+ className={`w-6 h-6 rounded-full flex items-center justify-center transition-all ${
+ isSaving
+ ? 'opacity-50'
+ : isSaved
+ ? 'bg-brand/20 text-brand hover:bg-brand/30'
+ : 'bg-white/5 text-text-tertiary hover:bg-white/10 hover:text-text-secondary'
+ }`}
+ >
+ {isSaved ? (
+
+ ) : (
+
+ )}
+
+
+
+ {isSaved ? 'Remove from library' : 'Save to library'}
+
+
+
+ >
+ }
+ />
);
})}
diff --git a/src/components/PlaylistCard.tsx b/src/components/PlaylistCard.tsx
index 1a968d4..ac893d6 100644
--- a/src/components/PlaylistCard.tsx
+++ b/src/components/PlaylistCard.tsx
@@ -6,24 +6,7 @@ import { motion } from 'motion/react';
import { springs } from '@/lib/motion';
import { useCardTint } from '@/hooks/useAlbumColors';
import { cn } from '@/lib/utils';
-
-function timeAgo(dateStr: string): string {
- const now = Date.now();
- const then = new Date(dateStr).getTime();
- const diff = now - then;
- const mins = Math.floor(diff / 60000);
- if (mins < 1) return 'just now';
- if (mins < 60) return `${mins}m ago`;
- const hrs = Math.floor(mins / 60);
- if (hrs < 24) return `${hrs}h ago`;
- const days = Math.floor(hrs / 24);
- if (days < 7) return `${days}d ago`;
- const weeks = Math.floor(days / 7);
- if (weeks < 5) return `${weeks}w ago`;
- const months = Math.floor(days / 30);
- if (months < 12) return `${months}mo ago`;
- return `${Math.floor(days / 365)}y ago`;
-}
+import { formatTimeAgo } from '@/lib/activity-utils';
function MusicIcon({ className }: { className?: string }) {
return (
@@ -351,7 +334,9 @@ function CardContent({
· {playlist.unplayedCount} unheard
)}
- {playlist.lastUpdatedAt && · {timeAgo(playlist.lastUpdatedAt)} }
+ {playlist.lastUpdatedAt && (
+ · {formatTimeAgo(new Date(playlist.lastUpdatedAt))}
+ )}
diff --git a/src/components/SpotlightTour.tsx b/src/components/SpotlightTour.tsx
index 74068d0..089449a 100644
--- a/src/components/SpotlightTour.tsx
+++ b/src/components/SpotlightTour.tsx
@@ -76,6 +76,14 @@ export default function SpotlightTour({ onComplete }: { onComplete: () => void }
const [direction, setDirection] = useState(1);
const [targetRect, setTargetRect] = useState(null);
const completedRef = useRef(false);
+ // Track whether we've already mounted — React Strict Mode double-mounts in
+ // dev, which replays the initial→animate Motion transition a second time.
+ // Refs persist across the unmount/remount cycle, so the second mount skips
+ // the entrance animation by passing `initial={false}`.
+ const [hasMounted, setHasMounted] = useState(false);
+ useEffect(() => {
+ setHasMounted(true); // eslint-disable-line react-hooks/set-state-in-effect -- track mount for Strict Mode double-mount
+ }, []);
const currentStep = TOUR_STEPS[step]!;
@@ -186,8 +194,8 @@ export default function SpotlightTour({ onComplete }: { onComplete: () => void }
return (
void }
borderRadius: SPOTLIGHT_RADIUS,
boxShadow: '0 0 0 2px rgba(56, 189, 248, 0.5), 0 0 20px 4px rgba(56, 189, 248, 0.15)',
}}
- initial={{ opacity: 0, scale: 0.95 }}
+ initial={hasMounted ? false : { opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={springs.smooth}
key={`ring-${step}`}
@@ -245,11 +253,11 @@ export default function SpotlightTour({ onComplete }: { onComplete: () => void }
key={step}
custom={direction}
variants={slideVariants}
- initial="enter"
+ initial={hasMounted ? false : 'enter'}
animate="center"
exit="exit"
transition={springs.smooth}
- className="glass rounded-2xl p-5 border border-white/[0.08] max-w-sm mx-auto"
+ className="glass rounded-2xl p-5 border border-white/8 max-w-sm mx-auto"
style={{
background: 'linear-gradient(135deg, rgba(30, 30, 30, 0.95), rgba(20, 20, 20, 0.98))',
backdropFilter: 'blur(24px)',
diff --git a/src/components/TrackCard.tsx b/src/components/TrackCard.tsx
index 48aa019..05dd30b 100644
--- a/src/components/TrackCard.tsx
+++ b/src/components/TrackCard.tsx
@@ -1,10 +1,14 @@
'use client';
-import { useState } from 'react';
-import Image from 'next/image';
-import { MessageCircleHeart, Music, Play } from 'lucide-react';
-import { formatDuration, NowPlayingBars, REACTION_EMOJI } from '@/components/UnplayedTrackRow';
+import { MessageCircleHeart } from 'lucide-react';
+import { REACTION_EMOJI } from '@/lib/activity-utils';
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip';
+import { UserAvatar } from '@/components/ui/user-avatar';
+import {
+ TrackListRow,
+ TrackListRowContainer,
+ formatDuration,
+} from '@/components/ui/track-list-row';
import type { ActiveListener } from '@/components/NowPlayingIndicator';
interface TrackCardProps {
@@ -52,84 +56,18 @@ interface TrackCardProps {
onPlay?: () => void;
}
-function AlbumArtButton({
- albumImageUrl,
- trackName,
- isPlaying,
- onPlay,
-}: {
- albumImageUrl: string | null;
- trackName: string;
- isPlaying: boolean;
- onPlay?: () => void;
-}) {
- const [hovered, setHovered] = useState(false);
- const [imgError, setImgError] = useState(false);
-
- return (
- {
- if (e.key === 'Enter' || e.key === ' ') onPlay?.();
- }}
- className="relative w-10 h-10 rounded-lg shrink-0 overflow-hidden cursor-pointer"
- data-play-button
- onMouseEnter={() => setHovered(true)}
- onMouseLeave={() => setHovered(false)}
- >
- {albumImageUrl && !imgError ? (
-
setImgError(true)}
- />
- ) : (
-
-
-
- )}
-
- {hovered && (
-
- )}
-
- {!hovered && isPlaying && (
-
-
-
- )}
-
- );
-}
-
function ContributorAvatar({ addedBy }: { addedBy: TrackCardProps['track']['addedBy'] }) {
return (
e.stopPropagation()}>
- {addedBy.avatarUrl ? (
-
-
-
- ) : (
-
-
- {addedBy.displayName.charAt(0).toUpperCase()}
-
-
- )}
+
Added by {addedBy.displayName}
@@ -229,81 +167,38 @@ export default function TrackCard({
}, {});
return (
-
- {/* Album art with play overlay + NowPlayingBars */}
-
+
-
- {/* Track name + artist */}
-
-
- {/* Album name */}
- {track.albumName ? (
- e.stopPropagation()}
- >
- {track.albumName}
-
- ) : (
-
- )}
-
- {/* Contributor avatar */}
-
-
- {/* Reaction pills + progress */}
-
-
-
-
- {/* Duration */}
-
- {formatDuration(track.durationMs)}
-
-
+ ) : undefined
+ }
+ rightContent={
+
+ }
+ />
+
);
}
+
+// Re-export formatDuration for any remaining consumers
+export { formatDuration };
diff --git a/src/components/UnplayedTrackRow.tsx b/src/components/UnplayedTrackRow.tsx
index 91745e0..7725724 100644
--- a/src/components/UnplayedTrackRow.tsx
+++ b/src/components/UnplayedTrackRow.tsx
@@ -1,9 +1,8 @@
'use client';
import { useState, useEffect } from 'react';
-import NextImage from 'next/image';
-import { Play, Music } from 'lucide-react';
-import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip';
+import { REACTION_EMOJI } from '@/lib/activity-utils';
+import { TrackListRow, TrackListRowContainer } from '@/components/ui/track-list-row';
// ---------------------------------------------------------------------------
// Type
@@ -26,24 +25,6 @@ export interface UnplayedTrack {
reactions: Array<{ reaction: string; count: number }>;
}
-// ---------------------------------------------------------------------------
-// Helpers
-// ---------------------------------------------------------------------------
-
-export const REACTION_EMOJI: Record = {
- thumbs_up: '👍',
- thumbs_down: '👎',
- fire: '🔥',
- heart: '❤️',
-};
-
-export function formatDuration(ms: number | null): string {
- if (ms === null) return '—';
- const minutes = Math.floor(ms / 60000);
- const seconds = String(Math.floor((ms % 60000) / 1000)).padStart(2, '0');
- return `${minutes}:${seconds}`;
-}
-
// ---------------------------------------------------------------------------
// Dominant color extraction from album art
// ---------------------------------------------------------------------------
@@ -127,43 +108,6 @@ function useDominantColor(imageUrl: string | null): [number, number, number] | n
return color;
}
-// ---------------------------------------------------------------------------
-// NowPlayingBars
-// ---------------------------------------------------------------------------
-
-export function NowPlayingBars() {
- return (
- <>
-
-
- {(
- [
- [0, 60],
- [0.2, 100],
- [0.4, 40],
- ] as const
- ).map(([delay, height]) => (
-
- ))}
-
- >
- );
-}
-
// ---------------------------------------------------------------------------
// UnplayedTrackRow
// ---------------------------------------------------------------------------
@@ -186,7 +130,6 @@ export function UnplayedTrackRow({
onPlay,
}: UnplayedTrackRowProps) {
const [hovered, setHovered] = useState(false);
- const [imgError, setImgError] = useState(false);
const dominantColor = useDominantColor(track.albumImageUrl);
// Subtle solid tint from dominant album color
@@ -200,12 +143,11 @@ export function UnplayedTrackRow({
: { animation: 'unplayed-thump 3.5s ease-in-out infinite', animationDelay: `${index * 0.2}s` };
return (
- setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
- {/* Album art */}
-
- {track.albumImageUrl && !imgError ? (
-
setImgError(true)}
- />
- ) : (
-
-
-
- )}
-
- {hovered && (
-
- )}
-
- {!hovered && isPlaying && (
-
-
-
- )}
-
-
- {/* Track name + artist */}
-
-
- {/* Album name */}
- {track.albumName ? (
-
e.stopPropagation()}
- >
- {track.albumName}
-
- ) : (
-
- )}
-
- {/* Contributor avatar */}
-
-
- e.stopPropagation()}>
-
- {track.addedByAvatarUrl ? (
-
-
-
- ) : track.addedByName ? (
-
-
- {track.addedByName.charAt(0).toUpperCase()}
-
-
- ) : null}
-
-
- {track.addedByName && (
- Added by {track.addedByName}
- )}
-
-
-
- {/* Reactions or swaplist pill */}
-
-
- {/* Duration */}
-
- {formatDuration(track.durationMs)}
-
-
+ {track.playlistName}
+
+ )
+ }
+ />
+
);
}
+
+// Re-export shared utilities for backward compatibility
+export { formatDuration, NowPlayingBars } from '@/components/ui/track-list-row';
+export { REACTION_EMOJI } from '@/lib/activity-utils';
diff --git a/src/components/UnplayedTracksModal.tsx b/src/components/UnplayedTracksModal.tsx
index c2b20dd..78b579f 100644
--- a/src/components/UnplayedTracksModal.tsx
+++ b/src/components/UnplayedTracksModal.tsx
@@ -6,6 +6,7 @@ import { Play } from 'lucide-react';
import { springs } from '@/lib/motion';
import GlassDrawer from '@/components/ui/glass-drawer';
import { UnplayedTrack, UnplayedTrackRow } from '@/components/UnplayedTrackRow';
+import { SkeletonRows } from '@/components/ui/skeleton-row';
import { usePlayerState } from '@/hooks/usePlayerState';
const PAGE_SIZE = 20;
@@ -190,9 +191,7 @@ export default function UnplayedTracksModal({ isOpen, onClose }: UnplayedTracksM
{/* Initial loading skeleton */}
{loading && (
- {[0, 1, 2, 3, 4].map((i) => (
-
- ))}
+
)}
@@ -234,9 +233,7 @@ export default function UnplayedTracksModal({ isOpen, onClose }: UnplayedTracksM
{/* Loading more skeletons */}
{loadingMore && (
- {[0, 1, 2].map((i) => (
-
- ))}
+
)}
diff --git a/src/components/UnplayedTracksWidget.tsx b/src/components/UnplayedTracksWidget.tsx
index 22eb0ac..a5559dd 100644
--- a/src/components/UnplayedTracksWidget.tsx
+++ b/src/components/UnplayedTracksWidget.tsx
@@ -5,28 +5,9 @@ import { m, AnimatePresence } from 'motion/react';
import { Play, ChevronRight } from 'lucide-react';
import { springs } from '@/lib/motion';
import { UnplayedTrack, UnplayedTrackRow } from '@/components/UnplayedTrackRow';
+import { SkeletonRows } from '@/components/ui/skeleton-row';
import { usePlayerState } from '@/hooks/usePlayerState';
-// ---------------------------------------------------------------------------
-// Skeleton rows
-// ---------------------------------------------------------------------------
-
-function SkeletonRows({ count }: { count: number }) {
- return (
- <>
- {Array.from({ length: count }).map((_, i) => (
-
- ))}
- >
- );
-}
-
// ---------------------------------------------------------------------------
// UnplayedTracksWidget
// ---------------------------------------------------------------------------
diff --git a/src/components/ui/skeleton-row.tsx b/src/components/ui/skeleton-row.tsx
new file mode 100644
index 0000000..91165e6
--- /dev/null
+++ b/src/components/ui/skeleton-row.tsx
@@ -0,0 +1,75 @@
+'use client';
+
+import { cn } from '@/lib/utils';
+
+// ---------------------------------------------------------------------------
+// SkeletonRow
+// ---------------------------------------------------------------------------
+
+interface SkeletonRowProps {
+ /** 'track' — album art square + two text lines + duration (used in track lists)
+ * 'track-simple' — single rounded skeleton bar (used in modals)
+ * 'activity' — avatar circle + two text lines (used in activity lists)
+ * 'activity-bubble' — circle + small text bars (used in horizontal bubble scroll) */
+ variant?: 'track' | 'track-simple' | 'activity' | 'activity-bubble';
+ className?: string;
+}
+
+export function SkeletonRow({ variant = 'track', className }: SkeletonRowProps) {
+ if (variant === 'track-simple') {
+ return
;
+ }
+
+ if (variant === 'activity-bubble') {
+ return (
+
+ );
+ }
+
+ if (variant === 'activity') {
+ return (
+
+ );
+ }
+
+ // variant === 'track' (default)
+ return (
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// SkeletonRows — renders multiple SkeletonRow instances
+// ---------------------------------------------------------------------------
+
+interface SkeletonRowsProps {
+ count?: number;
+ variant?: SkeletonRowProps['variant'];
+ className?: string;
+}
+
+export function SkeletonRows({ count = 3, variant = 'track', className }: SkeletonRowsProps) {
+ return (
+ <>
+ {Array.from({ length: count }, (_, i) => (
+
+ ))}
+ >
+ );
+}
diff --git a/src/components/ui/spinner.tsx b/src/components/ui/spinner.tsx
new file mode 100644
index 0000000..8723600
--- /dev/null
+++ b/src/components/ui/spinner.tsx
@@ -0,0 +1,11 @@
+import { Loader2 } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+interface SpinnerProps {
+ size?: number;
+ className?: string;
+}
+
+export function Spinner({ size = 16, className }: SpinnerProps) {
+ return
;
+}
diff --git a/src/components/ui/track-list-row.tsx b/src/components/ui/track-list-row.tsx
new file mode 100644
index 0000000..abe5a59
--- /dev/null
+++ b/src/components/ui/track-list-row.tsx
@@ -0,0 +1,364 @@
+'use client';
+
+import { useState, type ReactNode } from 'react';
+import Image from 'next/image';
+import { Music, Play } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip';
+
+// ---------------------------------------------------------------------------
+// formatDuration — consolidated from UnplayedTrackRow
+// ---------------------------------------------------------------------------
+
+export function formatDuration(ms: number | null): string {
+ if (ms === null) return '\u2014';
+ const minutes = Math.floor(ms / 60000);
+ const seconds = String(Math.floor((ms % 60000) / 1000)).padStart(2, '0');
+ return `${minutes}:${seconds}`;
+}
+
+// ---------------------------------------------------------------------------
+// NowPlayingBars — relocated from UnplayedTrackRow
+// ---------------------------------------------------------------------------
+
+export function NowPlayingBars() {
+ return (
+ <>
+
+
+ {(
+ [
+ [0, 60],
+ [0.2, 100],
+ [0.4, 40],
+ ] as const
+ ).map(([delay, height]) => (
+
+ ))}
+
+ >
+ );
+}
+
+// ---------------------------------------------------------------------------
+// AlbumArt sub-component
+// ---------------------------------------------------------------------------
+
+function AlbumArtCell({
+ albumImageUrl,
+ trackName,
+ isPlaying,
+ isHovered: externalHovered,
+ selfManagedHover,
+ onPlayClick,
+ grayscale,
+ albumImageClassName,
+}: {
+ albumImageUrl: string | null;
+ trackName: string;
+ isPlaying: boolean;
+ isHovered: boolean;
+ /** When true, the album art cell manages its own hover state (for standalone play buttons) */
+ selfManagedHover?: boolean;
+ /** Click handler for when selfManagedHover is true */
+ onPlayClick?: () => void;
+ grayscale?: boolean;
+ albumImageClassName?: string;
+}) {
+ const [imgError, setImgError] = useState(false);
+ const [internalHovered, setInternalHovered] = useState(false);
+ const isHovered = selfManagedHover ? internalHovered : externalHovered;
+
+ const grayscaleClasses = grayscale ? 'grayscale opacity-60' : '';
+
+ const interactiveProps = selfManagedHover
+ ? {
+ role: 'button' as const,
+ tabIndex: 0,
+ 'data-play-button': true,
+ onClick: onPlayClick,
+ onKeyDown: (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' || e.key === ' ') onPlayClick?.();
+ },
+ onMouseEnter: () => setInternalHovered(true),
+ onMouseLeave: () => setInternalHovered(false),
+ }
+ : {};
+
+ return (
+
+ {albumImageUrl && !imgError ? (
+
setImgError(true)}
+ />
+ ) : (
+
+
+
+ )}
+
+ {isHovered && (
+
+ )}
+
+ {!isHovered && isPlaying && (
+
+
+
+ )}
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// ContributorAvatar sub-component
+// ---------------------------------------------------------------------------
+
+function ContributorAvatarCell({
+ displayName,
+ avatarUrl,
+}: {
+ displayName: string;
+ avatarUrl: string | null;
+}) {
+ return (
+
+
+ e.stopPropagation()}>
+
+ {avatarUrl ? (
+
+
+
+ ) : displayName ? (
+
+
+ {displayName.charAt(0).toUpperCase()}
+
+
+ ) : null}
+
+
+ {displayName && Added by {displayName} }
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// TrackListRow
+// ---------------------------------------------------------------------------
+
+export interface TrackListRowProps {
+ /** Track name */
+ trackName: string;
+ /** Artist name */
+ artistName: string;
+ /** Album name (nullable) */
+ albumName: string | null;
+ /** Album art URL (nullable) */
+ albumImageUrl: string | null;
+ /** Duration in ms (nullable) */
+ durationMs: number | null;
+
+ /** Whether this track is currently playing */
+ isPlaying?: boolean;
+ /** Whether this row is currently hovered (caller-managed state for rows inside motion.div) */
+ isHovered?: boolean;
+
+ /** Apply grayscale filter to album art */
+ grayscale?: boolean;
+ /** Extra className applied to the album art Image element */
+ albumImageClassName?: string;
+ /** When true, the album art cell manages its own hover for play overlay (e.g. TrackCard) */
+ selfManagedAlbumHover?: boolean;
+ /** Click handler for play button on album art (used with selfManagedAlbumHover) */
+ onPlayClick?: () => void;
+
+ /** Contributor display name for the avatar column */
+ contributorName?: string;
+ /** Contributor avatar URL for the avatar column */
+ contributorAvatarUrl?: string | null;
+ /** Override the contributor column with custom content */
+ contributorContent?: ReactNode;
+
+ /** Content to render in the right actions area (between contributor and duration) */
+ rightContent?: ReactNode;
+ /** Extra content to render inside the track name area (e.g. pending reaction icon) */
+ trackNameExtra?: ReactNode;
+}
+
+export function TrackListRow({
+ trackName,
+ artistName,
+ albumName,
+ albumImageUrl,
+ durationMs,
+ isPlaying = false,
+ isHovered = false,
+ grayscale,
+ albumImageClassName,
+ selfManagedAlbumHover,
+ onPlayClick,
+ contributorName,
+ contributorAvatarUrl,
+ contributorContent,
+ rightContent,
+ trackNameExtra,
+}: TrackListRowProps) {
+ return (
+ <>
+ {/* Album art */}
+
+
+ {/* Track name + artist */}
+
+
+ {/* Album name */}
+ {albumName ? (
+
e.stopPropagation()}
+ >
+ {albumName}
+
+ ) : (
+
+ )}
+
+ {/* Contributor avatar */}
+ {contributorContent ??
+ (contributorName ? (
+
+ ) : (
+
+ ))}
+
+ {/* Right content / actions */}
+
{rightContent}
+
+ {/* Duration */}
+
+ {formatDuration(durationMs)}
+
+ >
+ );
+}
+
+// ---------------------------------------------------------------------------
+// TrackListRowContainer — optional wrapper that provides the grid layout
+// ---------------------------------------------------------------------------
+
+export const TRACK_ROW_GRID_COLUMNS = '40px minmax(0, 2fr) minmax(0, 1fr) 20px auto auto';
+
+export interface TrackListRowContainerProps {
+ children: ReactNode;
+ className?: string;
+ style?: React.CSSProperties;
+ onClick?: (e: React.MouseEvent) => void;
+ onKeyDown?: (e: React.KeyboardEvent) => void;
+ onMouseEnter?: () => void;
+ onMouseLeave?: () => void;
+ role?: string;
+ tabIndex?: number;
+}
+
+export function TrackListRowContainer({
+ children,
+ className,
+ style,
+ onClick,
+ onKeyDown,
+ onMouseEnter,
+ onMouseLeave,
+ role,
+ tabIndex,
+}: TrackListRowContainerProps) {
+ return (
+ // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- role is set dynamically based on onClick
+
+ {children}
+
+ );
+}
diff --git a/src/components/ui/user-avatar.tsx b/src/components/ui/user-avatar.tsx
new file mode 100644
index 0000000..e91e8ad
--- /dev/null
+++ b/src/components/ui/user-avatar.tsx
@@ -0,0 +1,33 @@
+'use client';
+import Image from 'next/image';
+import { cn } from '@/lib/utils';
+
+interface UserAvatarProps {
+ src?: string | null;
+ name?: string | null;
+ size?: number; // pixel size, default 24
+ className?: string;
+}
+
+export function UserAvatar({ src, name, size = 24, className }: UserAvatarProps) {
+ const initial = name?.[0]?.toUpperCase() || '?';
+ return src ? (
+
+ ) : (
+
+ {initial}
+
+ );
+}
diff --git a/src/db/schema.ts b/src/db/schema.ts
index cebe8c1..dd4d109 100644
--- a/src/db/schema.ts
+++ b/src/db/schema.ts
@@ -26,6 +26,7 @@ export const users = pgTable('users', {
autoNegativeReactions: boolean('auto_negative_reactions').notNull().default(true),
recentEmojis: text('recent_emojis'), // JSON array of last 3 custom emojis used
hasCompletedTour: boolean('has_completed_tour').notNull().default(false),
+ helpBannerDismissed: boolean('help_banner_dismissed').notNull().default(false),
lastDisconnectEmailAt: timestamp('last_disconnect_email_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});
@@ -117,6 +118,8 @@ export const playlistMembers = pgTable(
.notNull()
.references(() => users.id),
likedPlaylistId: text('liked_playlist_id'),
+ likedSyncMode: text('liked_sync_mode'), // 'created' | 'funnel' | null
+ likedPlaylistName: text('liked_playlist_name'),
joinedAt: timestamp('joined_at', { withTimezone: true }).notNull().defaultNow(),
lastActivitySeenAt: timestamp('last_activity_seen_at', { withTimezone: true }),
},
diff --git a/src/lib/__tests__/polling.integration.test.ts b/src/lib/__tests__/polling.integration.test.ts
index ef72b4b..2dfa63f 100644
--- a/src/lib/__tests__/polling.integration.test.ts
+++ b/src/lib/__tests__/polling.integration.test.ts
@@ -43,7 +43,6 @@ vi.mock('@/lib/spotify-config', () => ({
likedSyncEveryNCycles: 999,
pollIntervalMs: 30000,
},
- isOverBudget: vi.fn().mockReturnValue(false),
}));
vi.mock('@/lib/notifications', () => ({
diff --git a/src/lib/__tests__/spotify-budget.test.ts b/src/lib/__tests__/spotify-budget.test.ts
index 22d163f..d806212 100644
--- a/src/lib/__tests__/spotify-budget.test.ts
+++ b/src/lib/__tests__/spotify-budget.test.ts
@@ -1,10 +1,15 @@
-import { describe, it, expect, vi } from 'vitest';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock logger (pino may not be available in tests)
vi.mock('@/lib/logger', () => ({
logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() },
}));
+beforeEach(async () => {
+ const { _resetAllCircleState } = await import('@/lib/spotify-budget');
+ _resetAllCircleState();
+});
+
// ─── Rate Limiting (per-circle) ─────────────────────────────────────────────
describe('isCircleRateLimited', () => {
diff --git a/src/lib/activity-utils.ts b/src/lib/activity-utils.ts
index 3bf6213..4b2bee9 100644
--- a/src/lib/activity-utils.ts
+++ b/src/lib/activity-utils.ts
@@ -38,16 +38,22 @@ export function reactionToEmoji(reaction: string): string {
return REACTION_EMOJI[reaction] ?? reaction;
}
-export function formatTimeAgo(date: Date): string {
+export function formatTimeAgo(date: Date, options?: { compact?: boolean }): string {
+ const compact = options?.compact ?? false;
+ const suffix = compact ? '' : ' ago';
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
- if (diffMins < 1) return 'just now';
- if (diffMins < 60) return `${diffMins}m ago`;
+ if (diffMins < 1) return compact ? 'now' : 'just now';
+ if (diffMins < 60) return `${diffMins}m${suffix}`;
const diffHours = Math.floor(diffMins / 60);
- if (diffHours < 24) return `${diffHours}h ago`;
+ if (diffHours < 24) return `${diffHours}h${suffix}`;
const diffDays = Math.floor(diffHours / 24);
- if (diffDays < 7) return `${diffDays}d ago`;
+ if (diffDays < 7) return `${diffDays}d${suffix}`;
+ const diffWeeks = Math.floor(diffDays / 7);
+ if (diffWeeks < 5) return `${diffWeeks}w${suffix}`;
+ const diffMonths = Math.floor(diffDays / 30);
+ if (diffMonths < 12) return `${diffMonths}mo${suffix}`;
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
@@ -160,3 +166,127 @@ export function getEventSubtext(event: ActivityEvent): string {
return '';
}
}
+
+// --- Event styling helpers (ring colors, text colors, bg accents) ---
+
+export function getEventRingColor(type: string): string {
+ switch (type) {
+ case 'track_added':
+ return 'ring-green-400/50';
+ case 'reaction':
+ return 'ring-amber-400/50';
+ case 'member_joined':
+ case 'circle_joined':
+ return 'ring-sky-400/50';
+ case 'track_removed':
+ return 'ring-red-400/30';
+ case 'swaplist_created':
+ case 'circle_created':
+ return 'ring-purple-400/50';
+ default:
+ return 'ring-white/10';
+ }
+}
+
+export function getEventTextColor(type: string): string {
+ switch (type) {
+ case 'track_added':
+ return 'text-green-400';
+ case 'reaction':
+ return 'text-amber-400';
+ case 'member_joined':
+ case 'circle_joined':
+ return 'text-sky-400';
+ case 'track_removed':
+ return 'text-red-400/70';
+ case 'swaplist_created':
+ case 'circle_created':
+ return 'text-purple-400';
+ default:
+ return 'text-text-tertiary';
+ }
+}
+
+export function getEventBgAccent(type: string): string {
+ switch (type) {
+ case 'track_added':
+ return 'bg-green-400/8';
+ case 'reaction':
+ return 'bg-amber-400/8';
+ case 'member_joined':
+ case 'circle_joined':
+ return 'bg-sky-400/8';
+ case 'track_removed':
+ return 'bg-red-400/5';
+ case 'swaplist_created':
+ case 'circle_created':
+ return 'bg-purple-400/8';
+ default:
+ return 'bg-white/5';
+ }
+}
+
+// --- Event label & description helpers ---
+
+export function getEventLabel(event: ActivityEvent): { emoji: string; text: string } {
+ switch (event.type) {
+ case 'track_added':
+ return { emoji: '\uD83C\uDFB5', text: 'added' };
+ case 'reaction':
+ return { emoji: reactionToEmoji(event.data.reaction ?? ''), text: 'reacted' };
+ case 'member_joined':
+ return { emoji: '\uD83D\uDC4B', text: 'joined' };
+ case 'track_removed':
+ return { emoji: '', text: 'removed' };
+ case 'swaplist_created':
+ return { emoji: '\u2728', text: 'new list' };
+ case 'circle_joined':
+ return { emoji: '\uD83D\uDC4B', text: 'joined' };
+ case 'circle_created':
+ return { emoji: '\u2728', text: 'new circle' };
+ default:
+ return { emoji: '', text: '' };
+ }
+}
+
+export function getDetailDescription(event: ActivityEvent): string {
+ switch (event.type) {
+ case 'track_added':
+ return `Added a track to ${event.data.playlistName ?? 'a swaplist'}`;
+ case 'reaction':
+ return `Reacted ${reactionToEmoji(event.data.reaction ?? '')} to \u201c${event.data.trackName}\u201d`;
+ case 'member_joined':
+ return `Joined ${event.data.playlistName ?? 'a swaplist'}`;
+ case 'track_removed':
+ return `Removed \u201c${event.data.trackName}\u201d from ${event.data.playlistName ?? 'a swaplist'}`;
+ case 'swaplist_created':
+ return `Created a new swaplist`;
+ case 'circle_joined':
+ return `Joined ${event.data.circleName ?? 'your circle'}`;
+ case 'circle_created':
+ return `Created ${event.data.circleName ?? 'a new circle'}`;
+ default:
+ return '';
+ }
+}
+
+export function getListDescription(event: ActivityEvent): string {
+ switch (event.type) {
+ case 'track_added':
+ return `${event.data.trackName} \u00b7 ${event.data.artistName}`;
+ case 'reaction':
+ return `reacted ${reactionToEmoji(event.data.reaction ?? '')} to \u201c${event.data.trackName}\u201d`;
+ case 'member_joined':
+ return `joined ${event.data.playlistName}`;
+ case 'track_removed':
+ return `removed \u201c${event.data.trackName}\u201d`;
+ case 'swaplist_created':
+ return `created \u201c${event.data.playlistName}\u201d`;
+ case 'circle_joined':
+ return `joined ${event.data.circleName ?? 'your circle'}`;
+ case 'circle_created':
+ return `created ${event.data.circleName ?? 'a circle'}`;
+ default:
+ return '';
+ }
+}
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
index 8b124c7..6df8c32 100644
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -114,6 +114,33 @@ export async function requireCircle() {
return { user, membership };
}
+/**
+ * Resolve the active circle for a user: prefer the session's activeCircleId
+ * (if the user is actually a member), otherwise fall back to the user's first
+ * circle membership.
+ *
+ * Returns `{ circleId: string }` or `null` if the user has no memberships.
+ */
+export async function getActiveOrDefaultCircleId(
+ userId: string
+): Promise<{ circleId: string } | null> {
+ const session = await getSession();
+ const activeId = session.activeCircleId ?? null;
+
+ if (activeId) {
+ const membership = await db.query.circleMembers.findFirst({
+ where: and(eq(circleMembers.userId, userId), eq(circleMembers.circleId, activeId)),
+ });
+ if (membership) return { circleId: activeId };
+ }
+
+ // Fall back to first circle membership
+ const first = await db.query.circleMembers.findFirst({
+ where: eq(circleMembers.userId, userId),
+ });
+ return first ? { circleId: first.circleId } : null;
+}
+
/**
* Get all circle memberships for a given user, including circle details and host info.
*/
diff --git a/src/lib/circle-health.ts b/src/lib/circle-health.ts
index 1e7ed77..7545acd 100644
--- a/src/lib/circle-health.ts
+++ b/src/lib/circle-health.ts
@@ -8,6 +8,7 @@ import { circles, circleMembers, users } from '@/db/schema';
import { eq, and, ne } from 'drizzle-orm';
import { logger } from '@/lib/logger';
import { sendEmail } from '@/lib/email';
+import { clearCircleRateLimit } from '@/lib/spotify-budget';
const appUrl = () => process.env.NEXT_PUBLIC_APP_URL || 'https://swapify.312.dev';
@@ -122,12 +123,16 @@ export async function markCircleInvalid(circleId: string): Promise
{
* Called when the host enters a new Client ID in circle settings.
*/
export async function beginCircleMigration(circleId: string, newClientId: string): Promise {
+ // Clear any Spotify rate limit for this circle — rate limits are per Client ID,
+ // so the old limit doesn't apply to the new Client ID.
+ clearCircleRateLimit(circleId);
+
await db
.update(circles)
.set({ spotifyClientId: newClientId, appStatus: 'migrating' })
.where(eq(circles.id, circleId));
- logger.info({ circleId, newClientId }, '[Swapify] Circle migration started');
+ logger.info({ circleId, newClientId }, '[Swapify] Circle migration started — rate limit cleared');
}
/**
diff --git a/src/lib/email-mockups/DESIGN-NOTES.md b/src/lib/email-mockups/DESIGN-NOTES.md
new file mode 100644
index 0000000..de7b88d
--- /dev/null
+++ b/src/lib/email-mockups/DESIGN-NOTES.md
@@ -0,0 +1,73 @@
+# Email Template Design Notes
+
+## Reference Analysis
+
+Studied a set of modern email templates with these key design traits:
+
+### What makes them work
+
+1. **No container border** — content floats freely on the background. No card outlines, no box shadows framing the whole email. This feels more editorial and less "transactional SaaS."
+
+2. **Oversized bold headings** — 28-36px, font-weight 800. The heading IS the design. It dominates the email and communicates the message even if nothing else is read.
+
+3. **Large pill-shaped CTA buttons** — full-width or near-full-width, 18-20px padding, heavy font weight. The button is unmissable and feels tappable on mobile.
+
+4. **Generous whitespace** — 40-56px gaps between sections. Breathing room makes the few elements feel intentional, not crammed.
+
+5. **Minimal footer** — just a line of text and an unsubscribe link. No heavy footer boxes or dark backgrounds.
+
+6. **Typography hierarchy** — only 3 levels: massive heading, medium body, tiny footer. No subheadings, no labels, no visual noise.
+
+7. **Accent badges/tags** — small colored pill badges above headings to provide context ("Playlist Invite", "New Activity").
+
+8. **Bold names in body text** — sender names and key nouns are `` with a lighter color, making body text scannable.
+
+9. **Gradient accents** — thin gradient lines or gradient buttons add visual interest without adding complexity.
+
+10. **Logo as punctuation, not hero** — logo is small and either at the very top or very bottom. It doesn't compete with the heading.
+
+## Our 3 Mockup Approaches
+
+### Mockup A: "Bold Dark"
+- Full dark bg, no card at all
+- Logo at top, thin gradient divider below
+- 32px bold heading, generous spacing
+- Full-width pill CTA button (solid brand blue)
+- Closest to the center (dark) template in reference
+
+### Mockup B: "Soft Glass"
+- Dark bg with a subtle frosted-glass panel (no hard border)
+- Green accent badge above heading
+- Brand-blue colored accent on key word in heading
+- Structured detail rows (From/Playlist/Members) — editorial feel
+- Rounded-rectangle CTA (16px radius, not full pill)
+- Closest to the right template in reference
+
+### Mockup C: "Clean Contrast"
+- Deepest minimalism — no card, no panel, no glass
+- Brand pill tag at very top instead of logo
+- 36px heading, the largest — with underline accent
+- Gradient CTA button (sky blue → lighter blue)
+- Logo at bottom as a sign-off
+- Secondary "copy link" below button
+- Closest to the left template in reference
+
+## Color Palette Used (Arctic Aurora)
+
+| Token | Value | Usage |
+|-------|-------|-------|
+| Background | `#081420` | Email body |
+| Glass panel | `rgba(17,28,46,0.6)` | Mockup B panel |
+| Brand | `#38BDF8` | Buttons, accents, underlines |
+| Brand hover | `#7DD3FC` | Gradient end |
+| Accent green | `#4ADE80` | Badge in Mockup B |
+| Text primary | `#f1f5f9` / `#f8fafc` | Headings |
+| Text body | `#94a3b8` | Body paragraphs |
+| Text bold | `#cbd5e1` / `#e2e8f0` | Strong text in body |
+| Text muted | `#64748b` / `#475569` | Footer, labels |
+
+## Font Stack
+
+Same as app: `-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif`
+
+Logo wordmark uses `letter-spacing: -0.5px` (tight tracking, logo only).
diff --git a/src/lib/email-mockups/iteration-1.html b/src/lib/email-mockups/iteration-1.html
new file mode 100644
index 0000000..c9b5082
--- /dev/null
+++ b/src/lib/email-mockups/iteration-1.html
@@ -0,0 +1,84 @@
+
+
+
+
+
+ Swapify Email - Iteration 1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ You're in.
+ Welcome to
+ "Summer Vibes"
+
+
+
+
+ Alex just added you to their Swaplist. Drop your best tracks, swipe on theirs, and see who actually has taste.
+
+
+
+
+
+
+
+
+ or copy: swapify.312.dev/join/abc123
+
+
+
+
+
+
+
+
+
+ You're getting this because you're on Swapify.
+
+
Unsubscribe
+
+ © 2026 Swapify
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/lib/email-mockups/iteration-2.html b/src/lib/email-mockups/iteration-2.html
new file mode 100644
index 0000000..da8f30a
--- /dev/null
+++ b/src/lib/email-mockups/iteration-2.html
@@ -0,0 +1,124 @@
+
+
+
+
+
+ Swapify Email - Iteration 2
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Alex wants you on
+ Summer Vibes
+
+
+
+
+ They started a Swaplist and want you in. Drop your favorite tracks, explore theirs, and discover something new together.
+
+
+
+
+
+
+
+ (don't leave them hanging)
+
+
+
+
+
How it works
+
+
+
+
+ ♫
+
+
+
Drop your tracks
+
Add songs you've been loving lately
+
+
+
+
+
+
+ ↔
+
+
+
Swipe & react
+
Swipe right if you vibe, left if you don't
+
+
+
+
+
+
+ ✨
+
+
+
Discover together
+
Explore each other's taste and find new favorites
+
+
+
+
+
+
+
+
+
+
+
+
+ You signed up for Swapify, so here we are. Don't want these? Unsubscribe
+
+
+ © 2026 312.dev
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/lib/email-mockups/iteration-3.html b/src/lib/email-mockups/iteration-3.html
new file mode 100644
index 0000000..08ed64e
--- /dev/null
+++ b/src/lib/email-mockups/iteration-3.html
@@ -0,0 +1,91 @@
+
+
+
+
+
+ Swapify Email - Iteration 3
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ New Swaplist
+
+
+
+
+
+
+ You just got added to
+ Summer Vibes
+
+
+
+
+
+ Alex added you. Now it's your turn to show up. Drop tracks, swipe on picks, see who's got the range.
+
+
+
+
+
+
+
+ trust, it's giving good taste already
+
+
+
+
+
+
+
+
+ You're getting this because you're on Swapify. No spam, we promise.
+
+
Unsubscribe
+
+ © 2026 Swapify
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/lib/email-mockups/mockup-a.html b/src/lib/email-mockups/mockup-a.html
new file mode 100644
index 0000000..24c3481
--- /dev/null
+++ b/src/lib/email-mockups/mockup-a.html
@@ -0,0 +1,75 @@
+
+
+
+
+
+ Swapify Email - Mockup A (Bold Dark)
+
+
+
+
+
+
+
+
+
+
+
+
+
+ You're invited to join "Summer Vibes Swaplist"
+
+
+
+
+ Alex invited you to collaborate on a playlist. Add your favorite tracks, react to theirs, and discover new music together.
+
+
+
+
+
+
+
+
+
+
+
+ You're receiving this because you have notifications enabled on Swapify.
+
+
Unsubscribe
+
+
+
+
+ © 2026 Swapify
+
+
+
+
+
\ No newline at end of file
diff --git a/src/lib/email-mockups/mockup-b.html b/src/lib/email-mockups/mockup-b.html
new file mode 100644
index 0000000..d51808e
--- /dev/null
+++ b/src/lib/email-mockups/mockup-b.html
@@ -0,0 +1,92 @@
+
+
+
+
+
+ Swapify Email - Mockup B (Soft Glass)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Playlist Invite
+
+
+
+
+
+ Join "Summer Vibes" on Swapify
+
+
+
+
+ Alex wants you to collaborate on their Swaplist. Add tracks, swipe on picks, and vibe together.
+
+
+
+
+
+ From
+ Alex Johnson
+
+
+ Playlist
+ Summer Vibes
+
+
+ Members
+ 3 people
+
+
+
+
+
+ Accept Invite
+
+
+
+
+
+
+ You're receiving this because you have notifications enabled.
+
+
Unsubscribe
+
+ © 2026 Swapify
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/lib/email-mockups/mockup-c.html b/src/lib/email-mockups/mockup-c.html
new file mode 100644
index 0000000..b9c113f
--- /dev/null
+++ b/src/lib/email-mockups/mockup-c.html
@@ -0,0 +1,86 @@
+
+
+
+
+
+ Swapify Email - Mockup C (Clean Contrast)
+
+
+
+
+
+
+
+
+ Swapify
+
+
+
+
+
+ You've been
+ invited to
+ Summer Vibes
+
+
+
+
+ Alex added you to a Swaplist. Jump in, add your tracks, and start swiping.
+
+
+
+
+
+
+
+
+ or copy link: swapify.312.dev/join/abc123
+
+
+
+
+
+
+
+
+
+
+ You're receiving this because you enabled notifications.
+
+
Unsubscribe
+
+ © 2026 Swapify
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/lib/email.ts b/src/lib/email.ts
deleted file mode 100644
index b01fef4..0000000
--- a/src/lib/email.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-import { Resend } from 'resend';
-
-const resend = process.env.RESEND_API_KEY ? new Resend(process.env.RESEND_API_KEY) : null;
-
-export async function sendEmail(
- to: string,
- subject: string,
- body: string,
- url?: string,
- userId?: string,
- buttonLabel?: string
-): Promise {
- if (!resend) {
- console.warn('[Swapify] Resend not configured, skipping email');
- return;
- }
-
- const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://swapify.312.dev';
- const unsubUrl = userId ? `${baseUrl}/api/email/unsubscribe?uid=${userId}` : null;
-
- try {
- await resend.emails.send({
- from: 'Swapify ',
- to,
- subject: `Swapify: ${subject}`,
- html: emailTemplate(subject, body, url, unsubUrl, buttonLabel),
- ...(unsubUrl
- ? {
- headers: {
- 'List-Unsubscribe': `<${unsubUrl}>`,
- 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
- },
- }
- : {}),
- });
- } catch (error) {
- console.error('[Swapify] Email send failed:', error);
- throw error;
- }
-}
-
-const LOGO_SVG = ` `;
-
-function emailTemplate(
- title: string,
- body: string,
- url?: string,
- unsubUrl?: string | null,
- buttonLabel?: string
-): string {
- const year = new Date().getFullYear();
- const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://swapify.312.dev';
- return `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
${title}
-
${body}
- ${url ? `
` : ''}
-
-
-
-
-
- You're receiving this because you enabled notifications on Swapify.${unsubUrl ? `Unsubscribe ` : ''}
-
-
-
-
-
-
-
- © ${year} Swapify
-
-
-
-
-`;
-}
diff --git a/src/lib/email/assets.ts b/src/lib/email/assets.ts
new file mode 100644
index 0000000..f88ff39
--- /dev/null
+++ b/src/lib/email/assets.ts
@@ -0,0 +1,41 @@
+const BASE_PATH = '/email-assets';
+
+/**
+ * Build a full asset URL for use in emails.
+ * When `relative` is true (for dev preview), returns just the path.
+ */
+function assetUrl(filename: string, relative = false): string {
+ if (relative) return `${BASE_PATH}/${filename}`;
+ const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://swapify.312.dev';
+ return `${baseUrl}${BASE_PATH}/${filename}`;
+}
+
+export function getEmailAssets(relative = false) {
+ return {
+ logoLockup: assetUrl('logo-lockup.png', relative),
+ logoWhite: assetUrl('logo-white.png', relative),
+ // Feature row icons
+ iconMusic: assetUrl('icon-music.png', relative),
+ iconSwipe: assetUrl('icon-swipe.png', relative),
+ iconDiscover: assetUrl('icon-discover.png', relative),
+ // Button action icons (light = white, dark = dark text)
+ btnPlay: assetUrl('btn-play.png', relative),
+ btnPlayDark: assetUrl('btn-play-dark.png', relative),
+ btnCheck: assetUrl('btn-check.png', relative),
+ btnCheckDark: assetUrl('btn-check-dark.png', relative),
+ btnSliders: assetUrl('btn-sliders.png', relative),
+ btnSlidersDark: assetUrl('btn-sliders-dark.png', relative),
+ btnLink: assetUrl('btn-link.png', relative),
+ btnLinkDark: assetUrl('btn-link-dark.png', relative),
+ btnHeadphones: assetUrl('btn-headphones.png', relative),
+ btnHeadphonesDark: assetUrl('btn-headphones-dark.png', relative),
+ // Hand-drawn underline accents
+ underlineBlue: assetUrl('underline-blue.png', relative),
+ underlineGreen: assetUrl('underline-green.png', relative),
+ underlineOrange: assetUrl('underline-orange.png', relative),
+ underlineLime: assetUrl('underline-lime.png', relative),
+ };
+}
+
+/** Default assets with full URLs (for real emails) */
+export const emailAssets = getEmailAssets(false);
diff --git a/src/lib/email/index.ts b/src/lib/email/index.ts
new file mode 100644
index 0000000..c6f52b3
--- /dev/null
+++ b/src/lib/email/index.ts
@@ -0,0 +1,13 @@
+export { sendEmail, sendTypedEmail } from './send';
+export { renderEmail } from './templates';
+export {
+ playlistInviteData,
+ circleInviteData,
+ emailVerifyData,
+ disconnectData,
+ circlePausedHostData,
+ circlePausedMemberData,
+ circleOnlineData,
+ notificationData,
+} from './templates';
+export type { EmailType, TemplateData } from './types';
diff --git a/src/lib/email/send.ts b/src/lib/email/send.ts
new file mode 100644
index 0000000..9d97a54
--- /dev/null
+++ b/src/lib/email/send.ts
@@ -0,0 +1,81 @@
+import { Resend } from 'resend';
+import { renderEmail } from './templates';
+import type { TemplateData } from './types';
+
+const resend = process.env.RESEND_API_KEY ? new Resend(process.env.RESEND_API_KEY) : null;
+
+/**
+ * Send an email using the new template engine.
+ * Drop-in replacement for the old sendEmail() — same signature.
+ */
+export async function sendEmail(
+ to: string,
+ subject: string,
+ body: string,
+ url?: string,
+ userId?: string,
+ buttonLabel?: string
+): Promise {
+ const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://swapify.312.dev';
+ const unsubUrl = userId ? `${baseUrl}/api/email/unsubscribe?uid=${userId}` : null;
+
+ const data: TemplateData = {
+ title: subject,
+ body,
+ url,
+ buttonLabel,
+ unsubUrl,
+ };
+
+ await sendRenderedEmail(to, subject, data, unsubUrl);
+}
+
+/**
+ * Send a typed email with full TemplateData control.
+ */
+export async function sendTypedEmail(
+ to: string,
+ subject: string,
+ data: TemplateData,
+ userId?: string
+): Promise {
+ const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://swapify.312.dev';
+ const unsubUrl =
+ data.unsubUrl ?? (userId ? `${baseUrl}/api/email/unsubscribe?uid=${userId}` : null);
+
+ await sendRenderedEmail(to, subject, { ...data, unsubUrl }, unsubUrl);
+}
+
+async function sendRenderedEmail(
+ to: string,
+ subject: string,
+ data: TemplateData,
+ unsubUrl: string | null
+): Promise {
+ if (!resend) {
+ console.warn('[Swapify] Resend not configured, skipping email');
+ return;
+ }
+
+ const html = renderEmail(data);
+
+ try {
+ await resend.emails.send({
+ from: 'Swapify ',
+ to,
+ subject: `Swapify: ${subject}`,
+ html,
+ ...(unsubUrl
+ ? {
+ headers: {
+ 'List-Unsubscribe': `<${unsubUrl}>`,
+ 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
+ },
+ }
+ : {}),
+ });
+ } catch (error) {
+ console.error('[Swapify] Email send failed:', error);
+ throw error;
+ }
+}
diff --git a/src/lib/email/templates.ts b/src/lib/email/templates.ts
new file mode 100644
index 0000000..0781575
--- /dev/null
+++ b/src/lib/email/templates.ts
@@ -0,0 +1,375 @@
+import type { TemplateData } from './types';
+import { emailAssets, getEmailAssets } from './assets';
+
+type Assets = ReturnType;
+
+interface RenderOptions {
+ /** Use relative asset URLs (for dev preview) */
+ relativeAssets?: boolean;
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function hexToRgba(hex: string, alpha: number): string {
+ const r = Number.parseInt(hex.slice(1, 3), 16);
+ const g = Number.parseInt(hex.slice(3, 5), 16);
+ const b = Number.parseInt(hex.slice(5, 7), 16);
+ return `rgba(${r},${g},${b},${alpha})`;
+}
+
+/**
+ * Returns true if the accent color is "light" enough to need dark text on a
+ * button (orange, lime, light green, etc.).
+ */
+function needsDarkText(hex: string): boolean {
+ const r = Number.parseInt(hex.slice(1, 3), 16);
+ const g = Number.parseInt(hex.slice(3, 5), 16);
+ const b = Number.parseInt(hex.slice(5, 7), 16);
+ // Simple perceived-brightness check (ITU-R BT.601)
+ const brightness = (r * 299 + g * 587 + b * 114) / 1000;
+ return brightness > 140;
+}
+
+// ---------------------------------------------------------------------------
+// renderEmail — produces the full HTML document for any email type
+// ---------------------------------------------------------------------------
+
+export function renderEmail(data: TemplateData, options?: RenderOptions): string {
+ const year = new Date().getFullYear();
+ const assets: Assets = options?.relativeAssets ? getEmailAssets(true) : emailAssets;
+
+ const accentColor = data.accentColor || '#38BDF8';
+ const accentColorLight = data.accentColorLight || '#7DD3FC';
+ const buttonTextColor = needsDarkText(accentColor) ? '#0c1929' : '#f8fafc';
+
+ const buttonLabel = data.buttonLabel || 'Open Swapify';
+ const useDarkIcon = needsDarkText(accentColor);
+ const btnIconMap: Record = {
+ play: useDarkIcon ? assets.btnPlayDark : assets.btnPlay,
+ check: useDarkIcon ? assets.btnCheckDark : assets.btnCheck,
+ sliders: useDarkIcon ? assets.btnSlidersDark : assets.btnSliders,
+ link: useDarkIcon ? assets.btnLinkDark : assets.btnLink,
+ headphones: useDarkIcon ? assets.btnHeadphonesDark : assets.btnHeadphones,
+ };
+ const btnIconUrl = btnIconMap[data.buttonIcon || 'play'];
+ const ctaButton = data.url
+ ? ``
+ : '';
+
+ const aside = data.aside
+ ? `${data.aside}
`
+ : '';
+
+ const featureRows = data.showFeatureRows ? buildFeatureRows(assets) : '';
+
+ const unsubLink = data.unsubUrl
+ ? ` Don't want these? Unsubscribe `
+ : '';
+
+ const badgeHtml = data.badge
+ ? `
+
+
+ ${data.badge}
+
+ `
+ : '';
+
+ let html = `
+
+
+
+
+
+
+
+ Swapify
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${badgeHtml}
+
+
+
+ ${data.title}
+
+
+
+
+
+ ${data.body}
+
+
+
+
+ ${ctaButton}
+
+
+
+ ${aside}
+
+
+
+ ${featureRows}
+
+
+
+
+
+
+
+
+
+
+
+ You signed up for Swapify, so here we are.${unsubLink}
+ © ${year} 312.dev
+
+
+
+
+
+
+
+`;
+
+ // Resolve inline asset paths (underline/icon images in title HTML)
+ // In dev preview keep relative; in production make absolute
+ if (!options?.relativeAssets) {
+ const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://swapify.312.dev';
+ html = html.replaceAll('/email-assets/', baseUrl + '/email-assets/');
+ }
+ return html;
+}
+
+// ---------------------------------------------------------------------------
+// Feature rows — "How it works" section (table-based for Outlook compat)
+// ---------------------------------------------------------------------------
+
+function buildFeatureRows(assets: Assets): string {
+ const rows = [
+ {
+ icon: assets.iconMusic,
+ title: 'Drop your tracks',
+ description: "Add songs you've been loving lately",
+ },
+ {
+ icon: assets.iconSwipe,
+ title: 'Swipe & react',
+ description: "Swipe right if you vibe, left if you don't",
+ },
+ {
+ icon: assets.iconDiscover,
+ title: 'Discover together',
+ description: "Explore each other's taste and find new favorites",
+ },
+ ];
+
+ const rowsHtml = rows
+ .map(
+ (row) => `
+
+
+
+
+ ${row.title}
+ ${row.description}
+
+ `
+ )
+ .join('\n ');
+
+ return ``;
+}
+
+// ---------------------------------------------------------------------------
+// Per-type content builders
+// ---------------------------------------------------------------------------
+
+export function playlistInviteData(
+ inviterName: string,
+ playlistName: string,
+ url: string,
+ description?: string
+): TemplateData {
+ const accentColor = '#38BDF8';
+ const accentColorLight = '#7DD3FC';
+ const descHtml = description ? ' ' + description + ' ' : '';
+
+ return {
+ title:
+ '' +
+ inviterName +
+ ' wants you on ' +
+ playlistName +
+ ' ',
+ body:
+ 'They started a Swaplist and want you in. Drop your favorite tracks, explore theirs, and discover something new together.' +
+ descHtml,
+ url,
+ buttonLabel: 'Jump in',
+ showFeatureRows: true,
+ aside: "(don't leave them hanging)",
+ badge: 'SWAPLIST INVITE',
+ accentColor,
+ accentColorLight,
+ };
+}
+
+export function circleInviteData(
+ inviterName: string,
+ circleName: string,
+ url: string
+): TemplateData {
+ const accentColor = '#38BDF8';
+ const accentColorLight = '#7DD3FC';
+
+ return {
+ title:
+ '' +
+ inviterName +
+ ' invited you to ' +
+ circleName +
+ ' ',
+ body: "You're in. Join the circle, start sharing playlists, and discover what everyone's been listening to.",
+ url,
+ buttonLabel: 'Jump in',
+ showFeatureRows: true,
+ aside: "(they're waiting for you)",
+ badge: 'CIRCLE INVITE',
+ accentColor,
+ accentColorLight,
+ };
+}
+
+export function emailVerifyData(url: string): TemplateData {
+ const accentColor = '#4ADE80';
+ const accentColorLight = '#86EFAC';
+
+ return {
+ title: 'Confirm youremail address ',
+ body: "Tap below to verify your email for Swapify. If you didn't request this, you can safely ignore it.",
+ url,
+ buttonLabel: 'Verify Email',
+ buttonIcon: 'check',
+ badge: 'VERIFY',
+ accentColor,
+ accentColorLight,
+ };
+}
+
+export function disconnectData(url: string): TemplateData {
+ const accentColor = '#FB923C';
+ const accentColorLight = '#FDBA74';
+
+ return {
+ title: 'You\'ve beendisconnected ',
+ body: "We couldn't reach Spotify on your behalf. This usually happens when your session expires or you revoke access. Log back in to reconnect.",
+ url,
+ buttonLabel: 'Reconnect',
+ buttonIcon: 'link',
+ badge: 'ACTION NEEDED',
+ accentColor,
+ accentColorLight,
+ };
+}
+
+export function circlePausedHostData(circleName: string, url: string): TemplateData {
+ const accentColor = '#FB923C';
+ const accentColorLight = '#FDBA74';
+
+ return {
+ title: 'Your Spotify appneeds attention ',
+ body:
+ 'The Spotify developer app connected to your circle "' +
+ circleName +
+ ' " is no longer responding. Your circle has been paused. Head to settings to connect a new one.',
+ url,
+ buttonLabel: 'Go to Settings',
+ buttonIcon: 'sliders',
+ badge: 'ACTION NEEDED',
+ accentColor,
+ accentColorLight,
+ };
+}
+
+export function circlePausedMemberData(circleName: string, url: string): TemplateData {
+ const accentColor = '#FB923C';
+ const accentColorLight = '#FDBA74';
+
+ return {
+ title: 'Your circle hasbeen paused ',
+ body:
+ '"' +
+ circleName +
+ ' " has been paused because the host\'s Spotify app stopped responding. Hang tight — the host can reconnect it, or you can join another circle.',
+ url,
+ buttonLabel: 'Open Swapify',
+ buttonIcon: 'headphones',
+ badge: 'HEADS UP',
+ accentColor,
+ accentColorLight,
+ };
+}
+
+export function circleOnlineData(circleName: string, url: string): TemplateData {
+ const accentColor = '#c4f441';
+ const accentColorLight = '#d9f99d';
+
+ return {
+ title:
+ circleName +
+ ' is back online',
+ body: 'The host reconnected the Spotify app. Log in to Swapify and reconnect your account to pick up where you left off.',
+ url,
+ buttonLabel: 'Reconnect',
+ buttonIcon: 'link',
+ badge: 'GOOD NEWS',
+ accentColor,
+ accentColorLight,
+ };
+}
+
+export function notificationData(title: string, body: string, url?: string): TemplateData {
+ return { title, body, url, buttonLabel: 'Open Swapify' };
+}
diff --git a/src/lib/email/types.ts b/src/lib/email/types.ts
new file mode 100644
index 0000000..07a0816
--- /dev/null
+++ b/src/lib/email/types.ts
@@ -0,0 +1,36 @@
+export type EmailType =
+ | 'playlist-invite'
+ | 'circle-invite'
+ | 'email-verify'
+ | 'disconnect'
+ | 'circle-paused-host'
+ | 'circle-paused-member'
+ | 'circle-online'
+ | 'notification';
+
+export interface TemplateData {
+ /** Bold heading text (HTML supported for accent spans) */
+ title: string;
+ /** Body paragraph (supports tags) */
+ body: string;
+ /** CTA button URL */
+ url?: string;
+ /** CTA button text (default: "Open Swapify") */
+ buttonLabel?: string;
+ /** Button icon key (default: 'play') */
+ buttonIcon?: 'play' | 'check' | 'sliders' | 'link' | 'headphones';
+ /** Unsubscribe URL (auto-generated from userId if not provided) */
+ unsubUrl?: string | null;
+ /** Show "How it works" feature rows (invite emails only) */
+ showFeatureRows?: boolean;
+ /** Italic aside text below CTA (e.g. "(don't leave them hanging)") */
+ aside?: string;
+ /** Primary accent color (hex, e.g. "#38BDF8") */
+ accentColor?: string;
+ /** Secondary/lighter accent for gradients */
+ accentColorLight?: string;
+ /** Small uppercase badge text above heading (e.g. "SWAPLIST INVITE") */
+ badge?: string;
+ /** Badge background color (10% opacity version auto-computed in template) */
+ badgeColor?: string;
+}
diff --git a/src/lib/polling-audit.ts b/src/lib/polling-audit.ts
index 664e3a9..c51bc2f 100644
--- a/src/lib/polling-audit.ts
+++ b/src/lib/polling-audit.ts
@@ -218,11 +218,41 @@ async function adoptExternalTrack(
// ─── Liked Playlist Sync ─────────────────────────────────────────────────
+/** Clear all liked-playlist sync columns when the destination no longer exists. */
+async function clearDeletedLikedPlaylist(playlistId: string, userId: string): Promise {
+ const membership = await db.query.playlistMembers.findFirst({
+ where: and(eq(playlistMembers.playlistId, playlistId), eq(playlistMembers.userId, userId)),
+ });
+ if (membership) {
+ await db
+ .update(playlistMembers)
+ .set({ likedPlaylistId: null, likedSyncMode: null, likedPlaylistName: null })
+ .where(eq(playlistMembers.id, membership.id));
+ }
+}
+
+/** Batch-add and batch-remove URIs on a Spotify playlist (100 at a time). */
+async function applyPlaylistDiff(
+ userId: string,
+ circleId: string,
+ spotifyPlaylistId: string,
+ toAdd: string[],
+ toRemove: string[]
+): Promise {
+ for (let i = 0; i < toAdd.length; i += 100) {
+ await addItemsToPlaylist(userId, circleId, spotifyPlaylistId, toAdd.slice(i, i + 100));
+ }
+ for (let i = 0; i < toRemove.length; i += 100) {
+ await removeItemsFromPlaylist(userId, circleId, spotifyPlaylistId, toRemove.slice(i, i + 100));
+ }
+}
+
export async function syncLikedPlaylist(
userId: string,
circleId: string,
playlistId: string,
- likedPlaylistId: string
+ likedPlaylistId: string,
+ mode: 'created' | 'funnel' = 'created'
): Promise {
// Get user's liked reactions
const likedReactions = await db.query.trackReactions.findMany({
@@ -252,16 +282,7 @@ export async function syncLikedPlaylist(
spotifyItems = await getPlaylistItems(userId, circleId, likedPlaylistId);
} catch (error) {
if (String(error).includes('404') || String(error).includes('Not Found')) {
- // Playlist was deleted — clear likedPlaylistId
- const membership = await db.query.playlistMembers.findFirst({
- where: and(eq(playlistMembers.playlistId, playlistId), eq(playlistMembers.userId, userId)),
- });
- if (membership) {
- await db
- .update(playlistMembers)
- .set({ likedPlaylistId: null })
- .where(eq(playlistMembers.id, membership.id));
- }
+ await clearDeletedLikedPlaylist(playlistId, userId);
return false;
}
throw error;
@@ -269,20 +290,12 @@ export async function syncLikedPlaylist(
const spotifyUris = new Set(spotifyItems.map((item) => item.item.uri));
- // Diff
+ // Diff — funnel mode is additive only (never remove tracks from the destination)
const toAdd = [...desiredUris].filter((uri) => !spotifyUris.has(uri));
- const toRemove = [...spotifyUris].filter((uri) => !desiredUris.has(uri));
+ const toRemove =
+ mode === 'created' ? [...spotifyUris].filter((uri) => !desiredUris.has(uri)) : [];
- if (toAdd.length > 0) {
- for (let i = 0; i < toAdd.length; i += 100) {
- await addItemsToPlaylist(userId, circleId, likedPlaylistId, toAdd.slice(i, i + 100));
- }
- }
- if (toRemove.length > 0) {
- for (let i = 0; i < toRemove.length; i += 100) {
- await removeItemsFromPlaylist(userId, circleId, likedPlaylistId, toRemove.slice(i, i + 100));
- }
- }
+ await applyPlaylistDiff(userId, circleId, likedPlaylistId, toAdd, toRemove);
return true;
}
@@ -301,11 +314,13 @@ export async function syncAllLikedPlaylists(): Promise {
if (isCircleRateLimited(playlist.circleId) || isCircleOverBudget(playlist.circleId)) break;
try {
+ const syncMode = (member.likedSyncMode as 'created' | 'funnel') ?? 'created';
await syncLikedPlaylist(
member.userId,
playlist.circleId,
member.playlistId,
- member.likedPlaylistId!
+ member.likedPlaylistId!,
+ syncMode
);
} catch (error) {
if (error instanceof TokenInvalidError) {
diff --git a/src/lib/spotify-budget.ts b/src/lib/spotify-budget.ts
index f98c758..872e84d 100644
--- a/src/lib/spotify-budget.ts
+++ b/src/lib/spotify-budget.ts
@@ -189,5 +189,10 @@ export function loadRateLimits(): void {
}
}
+/** Reset all in-memory circle state. For testing only. */
+export function _resetAllCircleState(): void {
+ circleStates.clear();
+}
+
// Load on module initialization
loadRateLimits();
diff --git a/src/lib/spotify-config.ts b/src/lib/spotify-config.ts
index 6181c1f..62c1eaf 100644
--- a/src/lib/spotify-config.ts
+++ b/src/lib/spotify-config.ts
@@ -93,25 +93,3 @@ export {
isCircleOverBudget,
waitForCircleBudget,
} from '@/lib/spotify-budget';
-
-/** @deprecated Use isCircleOverBudget(circleId) instead */
-export function isOverBudget(): boolean {
- // Legacy: conservative — returns false since we can't check without a circleId.
- // Callers should migrate to isCircleOverBudget(circleId).
- return false;
-}
-
-/** @deprecated Use getCircleCallsInWindow(circleId) instead */
-export function getCallsInWindow(): number {
- return 0;
-}
-
-/** @deprecated Use trackCircleApiCall(circleId) instead */
-export function trackSpotifyApiCall(): void {
- // No-op — callers should migrate to trackCircleApiCall(circleId)
-}
-
-/** @deprecated Use waitForCircleBudget(circleId) instead */
-export async function waitForBudget(): Promise {
- // No-op — callers should migrate to waitForCircleBudget(circleId)
-}
diff --git a/src/lib/spotify-errors.ts b/src/lib/spotify-errors.ts
new file mode 100644
index 0000000..0e364f9
--- /dev/null
+++ b/src/lib/spotify-errors.ts
@@ -0,0 +1,31 @@
+import { NextResponse } from 'next/server';
+import { SpotifyRateLimitError, AppInvalidError, TokenInvalidError } from '@/lib/spotify-core';
+
+/**
+ * Map a known Spotify error to an appropriate JSON response.
+ * Returns a NextResponse for handled errors, or null if the error is unrecognized.
+ */
+export function handleSpotifyError(err: unknown): NextResponse | null {
+ if (err instanceof SpotifyRateLimitError) {
+ return NextResponse.json(
+ {
+ error: 'Spotify is a bit busy right now. Please try again in a minute.',
+ rateLimited: true,
+ },
+ { status: 429 }
+ );
+ }
+ if (err instanceof AppInvalidError) {
+ return NextResponse.json(
+ { error: "This circle's Spotify app is no longer responding.", appInvalid: true },
+ { status: 503 }
+ );
+ }
+ if (err instanceof TokenInvalidError) {
+ return NextResponse.json(
+ { error: 'Your Spotify session has expired. Please reconnect.', needsReauth: true },
+ { status: 401 }
+ );
+ }
+ return null;
+}
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
index 1b702a8..6d8803d 100644
--- a/src/lib/utils.ts
+++ b/src/lib/utils.ts
@@ -30,10 +30,6 @@ export function formatPlaylistName(memberNames: string[], groupName?: string): s
return `${initials} Swapify`;
}
-export function suggestGroupNames(): string[] {
- return ['Squad', 'Our', 'The Crew', 'Homies', 'Fam', 'Gang', 'Team', 'Club'];
-}
-
export function needsGroupName(memberCount: number): boolean {
return memberCount > 3;
}