Install Swapify
diff --git a/src/components/JoinCircleSection.tsx b/src/components/JoinCircleSection.tsx
new file mode 100644
index 0000000..7a1576f
--- /dev/null
+++ b/src/components/JoinCircleSection.tsx
@@ -0,0 +1,317 @@
+'use client';
+
+import { useState } from 'react';
+import { m, AnimatePresence } from 'motion/react';
+import { Crown, Users, UserPlus, AlertCircle, ArrowLeft } from 'lucide-react';
+import { springs, STAGGER_DELAY } from '@/lib/motion';
+
+interface CirclePreview {
+ id: string;
+ name: string;
+ hostName: string;
+ memberCount: number;
+ spotifyClientId: string;
+}
+
+interface JoinCircleSectionProps {
+ delayIndex: number;
+}
+
+export default function JoinCircleSection({ delayIndex }: Readonly) {
+ const [showJoinForm, setShowJoinForm] = useState(false);
+ const [joinCode, setJoinCode] = useState('');
+ const [joinPreview, setJoinPreview] = useState(null);
+ const [isLookingUp, setIsLookingUp] = useState(false);
+ const [isJoining, setIsJoining] = useState(false);
+ const [joinError, setJoinError] = useState(null);
+
+ function resetJoinState() {
+ setShowJoinForm(false);
+ setJoinCode('');
+ setJoinPreview(null);
+ setIsLookingUp(false);
+ setIsJoining(false);
+ setJoinError(null);
+ }
+
+ async function lookupCircleCode(code: string) {
+ setIsLookingUp(true);
+ setJoinError(null);
+ setJoinPreview(null);
+
+ try {
+ const res = await fetch(`/api/circles/resolve?code=${encodeURIComponent(code)}`);
+ if (!res.ok) {
+ const data = await res.json();
+ throw new Error(data.error || 'Invalid invite code');
+ }
+ const circle: CirclePreview = await res.json();
+ setJoinPreview(circle);
+ } catch (err) {
+ setJoinError(err instanceof Error ? err.message : 'Invalid invite code');
+ } finally {
+ setIsLookingUp(false);
+ }
+ }
+
+ function handleJoinCircle() {
+ if (!joinPreview) return;
+ setIsJoining(true);
+
+ const loginUrl =
+ `/api/auth/login?clientId=${encodeURIComponent(joinPreview.spotifyClientId)}` +
+ `&circleAction=join` +
+ `&circleId=${encodeURIComponent(joinPreview.id)}` +
+ `&returnTo=${encodeURIComponent('/dashboard')}`;
+
+ window.location.href = loginUrl;
+ }
+
+ return (
+
+
+ {!showJoinForm ? (
+ setShowJoinForm(true)}
+ className="w-full glass rounded-xl p-4 flex items-center gap-3 text-left active:scale-[0.98] transition-transform"
+ >
+
+
+
+ Join a Circle
+
+ ) : (
+
+ {/* Header row */}
+
+
+
+
+
+ Join a Circle
+
+
+
+
+ {!joinPreview ? (
+
+ ) : (
+ {
+ setJoinPreview(null);
+ setJoinCode('');
+ setJoinError(null);
+ }}
+ />
+ )}
+
+
+ )}
+
+
+ );
+}
+
+// --- Join code input form ---
+
+function JoinCodeInput({
+ joinCode,
+ setJoinCode,
+ joinError,
+ setJoinError,
+ isLookingUp,
+ onLookup,
+ onBack,
+}: Readonly<{
+ joinCode: string;
+ setJoinCode: (v: string) => void;
+ joinError: string | null;
+ setJoinError: (v: string | null) => void;
+ isLookingUp: boolean;
+ onLookup: (code: string) => void;
+ onBack: () => void;
+}>) {
+ return (
+
+
+
+
+ {
+ setJoinCode(e.target.value);
+ if (joinError) setJoinError(null);
+ }}
+ placeholder="Enter invite code"
+ className="input-glass flex-1"
+ enterKeyHint="go"
+ autoComplete="off"
+ autoCapitalize="off"
+ autoCorrect="off"
+ spellCheck={false}
+ />
+
+
+
+
+ {joinError && (
+
+
+ {joinError}
+
+ )}
+
+
+
+ );
+}
+
+// --- Join preview card ---
+
+function JoinPreviewCard({
+ preview,
+ isJoining,
+ onJoin,
+ onBack,
+}: Readonly<{
+ preview: CirclePreview;
+ isJoining: boolean;
+ onJoin: () => void;
+ onBack: () => void;
+}>) {
+ return (
+
+
+ {/* Circle icon */}
+
+
+
+
+
{preview.name}
+
+
+
+
+ {preview.hostName}
+
+ ·
+
+ {preview.memberCount} member
+ {preview.memberCount !== 1 ? 's' : ''}
+
+
+
+
+
+
+ The host may need to add your Spotify email to their developer app first.
+
+
+
+
+
+ );
+}
diff --git a/src/components/LayoutShell.tsx b/src/components/LayoutShell.tsx
index 2f7e57d..5fe389b 100644
--- a/src/components/LayoutShell.tsx
+++ b/src/components/LayoutShell.tsx
@@ -7,29 +7,50 @@ import TooltipProvider from './TooltipProvider';
import PageTransition from './PageTransition';
import { TransitionDirectionProvider } from '@/lib/TransitionContext';
import UnreadActivityProvider from './UnreadActivityProvider';
+import UnplayedCountProvider from './UnplayedCountProvider';
+import CirclesProvider from './CirclesProvider';
+import CircleSwitcher from './CircleSwitcher';
-const AUTHENTICATED_PREFIXES = ['/dashboard', '/activity', '/profile', '/playlist', '/circle'];
+const AUTHENTICATED_PREFIXES = [
+ '/dashboard',
+ '/swaplists',
+ '/activity',
+ '/profile',
+ '/playlist',
+ '/circle',
+];
export default function LayoutShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const showBottomNav = AUTHENTICATED_PREFIXES.some((prefix) => pathname.startsWith(prefix));
+ const showCircleBar =
+ showBottomNav && !pathname.startsWith('/profile') && !pathname.startsWith('/playlist');
return (
-
- {showBottomNav ? (
-
-
{children}
+
+
+
+ {showCircleBar && (
+
+
+
+ )}
+ {showBottomNav ? (
+
+ ) : (
+ children
+ )}
- ) : (
- children
- )}
-
-
-
+
+
+
+
diff --git a/src/components/LikedTracksView.tsx b/src/components/LikedTracksView.tsx
index d868b97..19e3697 100644
--- a/src/components/LikedTracksView.tsx
+++ b/src/components/LikedTracksView.tsx
@@ -1,10 +1,12 @@
'use client';
import { useState } from 'react';
+import Image from 'next/image';
import { motion, AnimatePresence } from 'motion/react';
import { springs, STAGGER_DELAY } from '@/lib/motion';
-import AlbumArt from '@/components/AlbumArt';
-import { Repeat2 } from 'lucide-react';
+import { Music, Play } from 'lucide-react';
+import { formatDuration, NowPlayingBars } from '@/components/UnplayedTrackRow';
+import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip';
import { toast } from 'sonner';
interface LikedTrack {
@@ -12,7 +14,9 @@ interface LikedTrack {
spotifyTrackUri: string;
trackName: string;
artistName: string;
+ albumName: string | null;
albumImageUrl: string | null;
+ durationMs: number | null;
addedBy: { id: string; displayName: string; avatarUrl: string | null };
addedAt: string;
removedAt: string | null;
@@ -30,6 +34,8 @@ interface LikedTracksViewProps {
likedTracks: LikedTrack[];
likedPlaylistId: string | null;
onPlaylistCreated: (spotifyPlaylistId: string) => void;
+ playingTrackId: string | null;
+ onPlay: (spotifyTrackUri: string, spotifyTrackId: string) => void;
}
export default function LikedTracksView({
@@ -37,28 +43,11 @@ export default function LikedTracksView({
likedTracks,
likedPlaylistId,
onPlaylistCreated,
+ playingTrackId,
+ onPlay,
}: LikedTracksViewProps) {
const [creating, setCreating] = useState(false);
- const [playingTrack, setPlayingTrack] = useState
(null);
-
- async function handlePlay(spotifyTrackUri: string) {
- setPlayingTrack(spotifyTrackUri);
- try {
- const res = await fetch('/api/spotify/play', {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ trackUri: spotifyTrackUri }),
- });
- if (!res.ok) {
- const data = await res.json();
- toast.error(data.error || 'Playback failed');
- }
- } catch {
- toast.error('Could not reach server');
- } finally {
- setPlayingTrack(null);
- }
- }
+ const [hoveredTrackId, setHoveredTrackId] = useState(null);
async function handleOpenInSpotify() {
if (likedPlaylistId) {
@@ -126,62 +115,149 @@ export default function LikedTracksView({
{/* Track list */}
- {likedTracks.map((track, index) => (
-
-