navigate(-1)} />;
+ }
+
+ return (
+
+ {/* Header */}
+
+
+ {/* Card Stack */}
+
+
+ {/* Voting Actions */}
+
+
+ );
+}
diff --git a/src/pages/ExploreSetPage/SetExploreCard.tsx b/src/pages/ExploreSetPage/SetExploreCard.tsx
new file mode 100644
index 0000000..6e2c95f
--- /dev/null
+++ b/src/pages/ExploreSetPage/SetExploreCard.tsx
@@ -0,0 +1,152 @@
+import { FestivalSet } from "@/hooks/queries/sets/useSets";
+import { Card } from "@/components/ui/card";
+import { motion, PanInfo } from "framer-motion";
+import { useState } from "react";
+import { SetCardHeader } from "./SetExploreCard/SetCardHeader";
+import { PrimaryArtistDisplay } from "./SetExploreCard/PrimaryArtistDisplay";
+import { SupportingArtists } from "./SetExploreCard/SupportingArtists";
+import { SetAudioPlayer } from "./SetExploreCard/SetAudioPlayer";
+
+interface SetExploreCardProps {
+ set: FestivalSet;
+ isFront?: boolean;
+ onSwipe?: (direction: "left" | "right") => void;
+ onTap?: () => void;
+ onDragUpdate?: (
+ direction: "left" | "right" | null,
+ intensity: number,
+ ) => void;
+}
+
+export function SetExploreCard({
+ set,
+ isFront,
+ onSwipe,
+ onTap,
+ onDragUpdate,
+}: SetExploreCardProps) {
+ const [imageLoaded, setImageLoaded] = useState(false);
+ const [isDragging, setIsDragging] = useState(false);
+
+ // Get primary artist (first artist) for main display
+ const primaryArtist = set.artists[0];
+ const supportingArtists = set.artists.slice(1);
+
+ return (
+
+
+ {/* Background Image */}
+
+ {primaryArtist?.image_url && (
+ <>
+

setImageLoaded(true)}
+ />
+
+ >
+ )}
+
+ {/* Content */}
+
+ {/* Header Info */}
+
+
+ {/* Main Content */}
+
+ {/* Set Name */}
+
+
{set.name}
+
+
+ {/* Primary Artist */}
+ {primaryArtist && (
+
e.stopPropagation()}
+ />
+ )}
+
+ {/* Supporting Artists */}
+
+
+
+
+
+
+
+ {/* Footer */}
+
+
+ Swipe or tap buttons to vote
+
+
+
+
+
+
+ );
+
+ function handleDragEnd(
+ _event: MouseEvent | TouchEvent | PointerEvent,
+ info: PanInfo,
+ ) {
+ // Reset drag feedback
+ onDragUpdate?.(null, 0);
+ setIsDragging(false);
+
+ const swipeThreshold = 100;
+ const velocityThreshold = 500;
+
+ if (
+ Math.abs(info.offset.x) > swipeThreshold ||
+ Math.abs(info.velocity.x) > velocityThreshold
+ ) {
+ if (info.offset.x > 0) {
+ onSwipe?.("right");
+ } else {
+ onSwipe?.("left");
+ }
+ }
+ }
+
+ function handleDrag(
+ _event: MouseEvent | TouchEvent | PointerEvent,
+ info: PanInfo,
+ ) {
+ const dragDistance = Math.abs(info.offset.x);
+ const maxDistance = 150; // Max distance for full intensity
+ const intensity = Math.min(dragDistance / maxDistance, 1);
+
+ if (dragDistance > 10) {
+ // Minimum drag threshold
+ const direction = info.offset.x > 0 ? "right" : "left";
+ setIsDragging(true);
+ onDragUpdate?.(direction, intensity);
+ } else {
+ setIsDragging(false);
+ onDragUpdate?.(null, 0);
+ }
+ }
+}
diff --git a/src/pages/ExploreSetPage/SetExploreCard/PrimaryArtistDisplay.tsx b/src/pages/ExploreSetPage/SetExploreCard/PrimaryArtistDisplay.tsx
new file mode 100644
index 0000000..3958a95
--- /dev/null
+++ b/src/pages/ExploreSetPage/SetExploreCard/PrimaryArtistDisplay.tsx
@@ -0,0 +1,44 @@
+import { Users } from "lucide-react";
+import { SoundCloudBadge } from "./SoundCloudBadge";
+import { Artist } from "@/hooks/queries/artists/useArtists";
+
+interface PrimaryArtistDisplayProps {
+ artist: Artist;
+ onSoundCloudClick?: (e: React.MouseEvent) => void;
+}
+
+export function PrimaryArtistDisplay({
+ artist,
+ onSoundCloudClick,
+}: PrimaryArtistDisplayProps) {
+ return (
+
+
+ {artist.image_url ? (
+

+ ) : (
+
+
+
+ )}
+
+
{artist.name}
+ {artist.description && (
+
+ {artist.description}
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/src/pages/ExploreSetPage/SetExploreCard/SetAudioPlayer.tsx b/src/pages/ExploreSetPage/SetExploreCard/SetAudioPlayer.tsx
new file mode 100644
index 0000000..33891ae
--- /dev/null
+++ b/src/pages/ExploreSetPage/SetExploreCard/SetAudioPlayer.tsx
@@ -0,0 +1,44 @@
+interface SetAudioPlayerProps {
+ soundcloudUrl?: string;
+ isActive?: boolean;
+}
+
+export function SetAudioPlayer({
+ soundcloudUrl,
+ isActive = true,
+}: SetAudioPlayerProps) {
+ if (!isActive || !soundcloudUrl) {
+ return null;
+ }
+
+ // Build SoundCloud iframe URL with parameters
+ const baseUrl = "https://w.soundcloud.com/player/";
+ const encodedUrl = encodeURIComponent(soundcloudUrl);
+ const params = new URLSearchParams({
+ url: encodedUrl,
+ auto_play: "true",
+ color: "8b5cf6",
+ buying: "false",
+ sharing: "false",
+ show_artwork: "false",
+ show_playcount: "false",
+ show_user: "false",
+ single_active: "true",
+ download: "false",
+ // start_track: "0",
+ });
+ const widgetUrl = `${baseUrl}?${params.toString()}`;
+
+ return (
+
+ {/* SoundCloud iframe player */}
+
+
+ );
+}
diff --git a/src/pages/ExploreSetPage/SetExploreCard/SetCardHeader.tsx b/src/pages/ExploreSetPage/SetExploreCard/SetCardHeader.tsx
new file mode 100644
index 0000000..75c2d10
--- /dev/null
+++ b/src/pages/ExploreSetPage/SetExploreCard/SetCardHeader.tsx
@@ -0,0 +1,60 @@
+import { Badge } from "@/components/ui/badge";
+import { Clock } from "lucide-react";
+import { StageBadge } from "@/components/StageBadge";
+import { useStageQuery } from "@/hooks/queries/stages/useStageQuery";
+
+interface SetCardHeaderProps {
+ stageId?: string;
+ timeStart: string | null;
+}
+
+export function SetCardHeader({ stageId, timeStart }: SetCardHeaderProps) {
+ const stageQuery = useStageQuery(stageId);
+
+ function formatTime(dateString: string | null) {
+ if (!dateString) return "";
+ const date = new Date(dateString);
+ return date.toLocaleTimeString("en-US", {
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+ });
+ }
+
+ function formatDate(dateString: string | null) {
+ if (!dateString) return "";
+ const date = new Date(dateString);
+ return date.toLocaleDateString("en-US", {
+ weekday: "short",
+ month: "short",
+ day: "numeric",
+ });
+ }
+
+ return (
+
+
+
+ {formatDate(timeStart)}
+
+ {timeStart && (
+
+
+ {formatTime(timeStart)}
+
+ )}
+
+
+ {stageQuery.data && (
+
+ )}
+
+ );
+}
diff --git a/src/pages/ExploreSetPage/SetExploreCard/SoundCloudBadge.tsx b/src/pages/ExploreSetPage/SetExploreCard/SoundCloudBadge.tsx
new file mode 100644
index 0000000..c29e5cc
--- /dev/null
+++ b/src/pages/ExploreSetPage/SetExploreCard/SoundCloudBadge.tsx
@@ -0,0 +1,44 @@
+interface SoundCloudBadgeProps {
+ soundcloudUrl?: string | null;
+ onClick?: (e: React.MouseEvent) => void;
+}
+
+export function SoundCloudBadge({
+ soundcloudUrl,
+ onClick,
+}: SoundCloudBadgeProps) {
+ const soundCloudIcon = (
+
+ );
+
+ if (soundcloudUrl) {
+ return (
+
+ {soundCloudIcon}
+ SoundCloud
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/src/pages/ExploreSetPage/SetExploreCard/SupportingArtists.tsx b/src/pages/ExploreSetPage/SetExploreCard/SupportingArtists.tsx
new file mode 100644
index 0000000..1c50be7
--- /dev/null
+++ b/src/pages/ExploreSetPage/SetExploreCard/SupportingArtists.tsx
@@ -0,0 +1,43 @@
+import { Badge } from "@/components/ui/badge";
+
+interface Artist {
+ id: string;
+ name: string;
+}
+
+interface SupportingArtistsProps {
+ artists: Artist[];
+}
+
+export function SupportingArtists({ artists }: SupportingArtistsProps) {
+ if (artists.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+ {artists.length === 1 ? "With" : "With"}
+
+
+ {artists.slice(0, 3).map((artist) => (
+
+ {artist.name}
+
+ ))}
+ {artists.length > 3 && (
+
+ +{artists.length - 3} more
+
+ )}
+
+
+ );
+}
diff --git a/src/pages/ExploreSetPage/VotingActions.tsx b/src/pages/ExploreSetPage/VotingActions.tsx
new file mode 100644
index 0000000..833bce7
--- /dev/null
+++ b/src/pages/ExploreSetPage/VotingActions.tsx
@@ -0,0 +1,113 @@
+import { Button } from "@/components/ui/button";
+import { VOTE_CONFIG } from "@/lib/voteConfig";
+import { motion } from "framer-motion";
+
+interface VotingActionsProps {
+ onVote: (voteType: number) => void;
+ onSkip: () => void;
+ dragFeedback?: {
+ direction: "left" | "right" | null;
+ intensity: number;
+ };
+}
+
+export function VotingActions({
+ onVote,
+ onSkip,
+ dragFeedback,
+}: VotingActionsProps) {
+ const wontGoConfig = VOTE_CONFIG.wontGo;
+ const interestedConfig = VOTE_CONFIG.interested;
+ const mustGoConfig = VOTE_CONFIG.mustGo;
+
+ const WontGoIcon = wontGoConfig.icon;
+ const InterestedIcon = interestedConfig.icon;
+ const MustGoIcon = mustGoConfig.icon;
+
+ // Calculate highlight intensity based on drag feedback
+ const isLeftDrag = dragFeedback?.direction === "left";
+ const isRightDrag = dragFeedback?.direction === "right";
+ const intensity = dragFeedback?.intensity || 0;
+
+ return (
+
+ {/* Won't Go */}
+
+
+
+
+ {/* Skip without voting */}
+
+
+
+
+ {/* Must Go */}
+
+
+
+
+ {/* Interested */}
+
+
+
+
+ );
+}
diff --git a/src/pages/ExploreSetPage/components/CardStackContainer.tsx b/src/pages/ExploreSetPage/components/CardStackContainer.tsx
new file mode 100644
index 0000000..91fa74a
--- /dev/null
+++ b/src/pages/ExploreSetPage/components/CardStackContainer.tsx
@@ -0,0 +1,66 @@
+import { motion, AnimatePresence } from "framer-motion";
+import { SetExploreCard } from "../SetExploreCard";
+import { FestivalSet } from "@/hooks/queries/sets/useSets";
+
+interface CardStackContainerProps {
+ currentSet: FestivalSet | undefined;
+ nextSet: FestivalSet | undefined;
+ direction: "left" | "right" | null;
+ onSwipe: (direction: "left" | "right") => void;
+ onDragUpdate: (direction: "left" | "right" | null, intensity: number) => void;
+ isLastSet: boolean;
+}
+
+export function CardStackContainer({
+ currentSet,
+ nextSet,
+ direction,
+ onSwipe,
+ onDragUpdate,
+ isLastSet,
+}: CardStackContainerProps) {
+ return (
+
+
+
+ {currentSet && (
+
+
+
+ )}
+
+
+ {/* Preview next card */}
+ {!isLastSet && nextSet && (
+
+
+
+ )}
+
+
+ );
+}
diff --git a/src/pages/ExploreSetPage/components/EmptyState.tsx b/src/pages/ExploreSetPage/components/EmptyState.tsx
new file mode 100644
index 0000000..bdd7a68
--- /dev/null
+++ b/src/pages/ExploreSetPage/components/EmptyState.tsx
@@ -0,0 +1,23 @@
+import { Button } from "@/components/ui/button";
+import { ArrowLeft } from "lucide-react";
+
+interface EmptyStateProps {
+ onGoBack: () => void;
+}
+
+export function EmptyState({ onGoBack }: EmptyStateProps) {
+ return (
+
+
+
No Sets Available
+
+ There are no sets to explore for this festival edition.
+
+
+
+
+ );
+}
diff --git a/src/pages/ExploreSetPage/components/ExplorePageHeader.tsx b/src/pages/ExploreSetPage/components/ExplorePageHeader.tsx
new file mode 100644
index 0000000..deb02a7
--- /dev/null
+++ b/src/pages/ExploreSetPage/components/ExplorePageHeader.tsx
@@ -0,0 +1,49 @@
+import { Link } from "react-router-dom";
+import { Button } from "@/components/ui/button";
+import { ArrowLeft, Info } from "lucide-react";
+import { ExplorationProgress } from "../ExplorationProgress";
+
+interface ExplorePageHeaderProps {
+ basePath: string;
+ editionName: string;
+ currentIndex: number;
+ totalSets: number;
+}
+
+export function ExplorePageHeader({
+ basePath,
+ editionName,
+ currentIndex,
+ totalSets,
+}: ExplorePageHeaderProps) {
+ return (
+
+
+
+
+
+
+
{editionName}
+
+
+
+
+
+ );
+}
diff --git a/src/pages/ExploreSetPage/components/LoadingState.tsx b/src/pages/ExploreSetPage/components/LoadingState.tsx
new file mode 100644
index 0000000..5ffd944
--- /dev/null
+++ b/src/pages/ExploreSetPage/components/LoadingState.tsx
@@ -0,0 +1,7 @@
+export function LoadingState() {
+ return (
+
+ );
+}
diff --git a/src/pages/ExploreSetPage/components/VotingSection.tsx b/src/pages/ExploreSetPage/components/VotingSection.tsx
new file mode 100644
index 0000000..6d724a0
--- /dev/null
+++ b/src/pages/ExploreSetPage/components/VotingSection.tsx
@@ -0,0 +1,33 @@
+import { VotingActions } from "../VotingActions";
+import { FestivalSet } from "@/hooks/queries/sets/useSets";
+
+interface VotingSectionProps {
+ currentSet: FestivalSet | undefined;
+ onVote: (voteType: number) => void;
+ onSkip: () => void;
+ dragFeedback: {
+ direction: "left" | "right" | null;
+ intensity: number;
+ };
+}
+
+export function VotingSection({
+ currentSet,
+ onVote,
+ onSkip,
+ dragFeedback,
+}: VotingSectionProps) {
+ if (!currentSet) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/src/pages/admin/ArtistsManagement/components/BulkEditorHeader.tsx b/src/pages/admin/ArtistsManagement/components/BulkEditorHeader.tsx
index 524bb6b..46513b9 100644
--- a/src/pages/admin/ArtistsManagement/components/BulkEditorHeader.tsx
+++ b/src/pages/admin/ArtistsManagement/components/BulkEditorHeader.tsx
@@ -2,6 +2,7 @@ import { CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Grid3X3, Plus, Copy } from "lucide-react";
import { Link } from "react-router-dom";
+import { SoundCloudSyncButton } from "./SoundCloudSyncButton";
interface BulkEditorHeaderProps {
onAddArtist: () => void;
@@ -13,9 +14,11 @@ export function BulkEditorHeader({ onAddArtist }: BulkEditorHeaderProps) {
- Artists Management
+ Artists
+
+