Add Crate Digger feature for swipe-based music discovery
- POST /api/recommendations/crate endpoint generates diverse crate of discoveries - POST /api/recommendations/crate-save endpoint saves individual picks - CrateDigger.tsx page with card UI, pass/save buttons, slide animations - Progress bar, save counter, and end-of-crate stats summary - Added to nav, routing, and API client
This commit is contained in:
@@ -378,6 +378,228 @@ Return ONLY the JSON object."""
|
||||
)
|
||||
|
||||
|
||||
class CrateRequest(BaseModel):
|
||||
count: int = 20
|
||||
|
||||
|
||||
class CrateItem(BaseModel):
|
||||
title: str
|
||||
artist: str
|
||||
album: str | None = None
|
||||
reason: str
|
||||
youtube_url: str | None = None
|
||||
|
||||
|
||||
@router.post("/crate", response_model=list[CrateItem])
|
||||
async def fill_crate(
|
||||
data: CrateRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if data.count < 1 or data.count > 50:
|
||||
raise HTTPException(status_code=400, detail="Count must be between 1 and 50")
|
||||
|
||||
# Build taste context from user's playlists
|
||||
taste_context = "No listening history yet — give a diverse mix of great music across genres and eras."
|
||||
result = await db.execute(
|
||||
select(Playlist).where(Playlist.user_id == user.id)
|
||||
)
|
||||
playlists = list(result.scalars().all())
|
||||
all_tracks = []
|
||||
for p in playlists:
|
||||
track_result = await db.execute(select(Track).where(Track.playlist_id == p.id))
|
||||
all_tracks.extend(track_result.scalars().all())
|
||||
if all_tracks:
|
||||
profile = build_taste_profile(all_tracks)
|
||||
taste_context = f"The user's taste profile from {len(all_tracks)} tracks:\n{json.dumps(profile, indent=2)}"
|
||||
|
||||
prompt = f"""You are Vynl, filling a vinyl crate for a music lover to dig through.
|
||||
|
||||
{taste_context}
|
||||
|
||||
Fill a crate with {data.count} diverse music discoveries. Mix it up:
|
||||
- Some familiar-adjacent picks they'll instantly love
|
||||
- Some wildcards from genres they haven't explored
|
||||
- Some deep cuts and rarities
|
||||
- Some brand new artists
|
||||
- Some classics they may have missed
|
||||
|
||||
Make each pick interesting and varied. This should feel like flipping through records at a great shop.
|
||||
|
||||
Respond with a JSON array of objects with: title, artist, album, reason (1 sentence why it's in the crate).
|
||||
Only recommend real songs. Return ONLY the JSON array."""
|
||||
|
||||
client = anthropic.Anthropic(api_key=settings.ANTHROPIC_API_KEY)
|
||||
message = client.messages.create(
|
||||
model="claude-haiku-4-5-20251001",
|
||||
max_tokens=4000,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
|
||||
response_text = message.content[0].text.strip()
|
||||
if response_text.startswith("```"):
|
||||
response_text = response_text.split("\n", 1)[1]
|
||||
response_text = response_text.rsplit("```", 1)[0]
|
||||
|
||||
try:
|
||||
parsed = json.loads(response_text)
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(status_code=500, detail="Failed to parse AI response")
|
||||
|
||||
items = []
|
||||
for rec in parsed:
|
||||
artist = rec.get("artist", "Unknown")
|
||||
title = rec.get("title", "Unknown")
|
||||
youtube_url = f"https://music.youtube.com/search?q={quote_plus(f'{artist} {title}')}"
|
||||
items.append(CrateItem(
|
||||
title=title,
|
||||
artist=artist,
|
||||
album=rec.get("album"),
|
||||
reason=rec.get("reason", ""),
|
||||
youtube_url=youtube_url,
|
||||
))
|
||||
|
||||
return items
|
||||
|
||||
|
||||
class CrateSaveRequest(BaseModel):
|
||||
title: str
|
||||
artist: str
|
||||
album: str | None = None
|
||||
reason: str
|
||||
|
||||
|
||||
@router.post("/crate-save")
|
||||
async def crate_save(
|
||||
data: CrateSaveRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
youtube_url = f"https://music.youtube.com/search?q={quote_plus(f'{data.artist} {data.title}')}"
|
||||
r = Recommendation(
|
||||
user_id=user.id,
|
||||
title=data.title,
|
||||
artist=data.artist,
|
||||
album=data.album,
|
||||
reason=data.reason,
|
||||
saved=True,
|
||||
youtube_url=youtube_url,
|
||||
query="crate-digger",
|
||||
)
|
||||
db.add(r)
|
||||
await db.flush()
|
||||
return {"id": r.id, "saved": True}
|
||||
|
||||
|
||||
class RabbitHoleStep(BaseModel):
|
||||
title: str
|
||||
artist: str
|
||||
album: str | None = None
|
||||
reason: str
|
||||
connection: str # How this connects to the previous step
|
||||
youtube_url: str | None = None
|
||||
|
||||
|
||||
class RabbitHoleResponse(BaseModel):
|
||||
theme: str
|
||||
steps: list[RabbitHoleStep]
|
||||
|
||||
|
||||
class RabbitHoleRequest(BaseModel):
|
||||
seed_artist: str | None = None
|
||||
seed_title: str | None = None
|
||||
steps: int = 8
|
||||
|
||||
|
||||
@router.post("/rabbit-hole", response_model=RabbitHoleResponse)
|
||||
async def rabbit_hole(
|
||||
data: RabbitHoleRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Generate a musical rabbit hole — a chain of connected songs."""
|
||||
if data.steps < 3 or data.steps > 15:
|
||||
raise HTTPException(status_code=400, detail="Steps must be between 3 and 15")
|
||||
|
||||
# Build taste context
|
||||
taste_context = ""
|
||||
result = await db.execute(
|
||||
select(Playlist).where(Playlist.user_id == user.id)
|
||||
)
|
||||
playlists = list(result.scalars().all())
|
||||
all_tracks = []
|
||||
for p in playlists:
|
||||
track_result = await db.execute(select(Track).where(Track.playlist_id == p.id))
|
||||
all_tracks.extend(track_result.scalars().all())
|
||||
if all_tracks:
|
||||
profile = build_taste_profile(all_tracks)
|
||||
taste_context = f"\n\nThe user's taste profile from {len(all_tracks)} tracks:\n{json.dumps(profile, indent=2)}"
|
||||
|
||||
seed_info = ""
|
||||
if data.seed_artist:
|
||||
seed_info = f"Starting from: {data.seed_artist}"
|
||||
if data.seed_title:
|
||||
seed_info += f" - {data.seed_title}"
|
||||
|
||||
prompt = f"""You are Vynl, a music guide taking someone on a journey. Create a musical rabbit hole — a chain of connected songs where each one leads naturally to the next through a shared quality.
|
||||
|
||||
{seed_info}
|
||||
{taste_context}
|
||||
|
||||
Create a {data.steps}-step rabbit hole. Each step should connect to the previous one through ONE specific quality — maybe the same producer, a shared influence, a similar guitar tone, a lyrical theme, a tempo shift, etc. The connections should feel like "if you liked THAT about the last song, wait until you hear THIS."
|
||||
|
||||
Respond with JSON:
|
||||
{{
|
||||
"theme": "A fun 1-sentence description of where this rabbit hole goes",
|
||||
"steps": [
|
||||
{{
|
||||
"title": "...",
|
||||
"artist": "...",
|
||||
"album": "...",
|
||||
"reason": "Why this song is great",
|
||||
"connection": "How this connects to the previous song (leave empty for first step)"
|
||||
}}
|
||||
]
|
||||
}}
|
||||
Only use real songs. Return ONLY the JSON."""
|
||||
|
||||
client = anthropic.Anthropic(api_key=settings.ANTHROPIC_API_KEY)
|
||||
message = client.messages.create(
|
||||
model="claude-haiku-4-5-20251001",
|
||||
max_tokens=4000,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
|
||||
response_text = message.content[0].text.strip()
|
||||
if response_text.startswith("```"):
|
||||
response_text = response_text.split("\n", 1)[1]
|
||||
response_text = response_text.rsplit("```", 1)[0]
|
||||
|
||||
try:
|
||||
parsed = json.loads(response_text)
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(status_code=500, detail="Failed to parse AI response")
|
||||
|
||||
theme = parsed.get("theme", "A musical journey")
|
||||
steps_data = parsed.get("steps", [])
|
||||
|
||||
steps = []
|
||||
for s in steps_data:
|
||||
artist = s.get("artist", "Unknown")
|
||||
title = s.get("title", "Unknown")
|
||||
yt_url = f"https://music.youtube.com/search?q={quote_plus(f'{artist} {title}')}"
|
||||
steps.append(RabbitHoleStep(
|
||||
title=title,
|
||||
artist=artist,
|
||||
album=s.get("album"),
|
||||
reason=s.get("reason", ""),
|
||||
connection=s.get("connection", ""),
|
||||
youtube_url=yt_url,
|
||||
))
|
||||
|
||||
return RabbitHoleResponse(theme=theme, steps=steps)
|
||||
|
||||
|
||||
@router.post("/{rec_id}/save")
|
||||
async def save_recommendation(
|
||||
rec_id: int,
|
||||
|
||||
@@ -19,6 +19,7 @@ import ArtistDive from './pages/ArtistDive'
|
||||
import PlaylistGenerator from './pages/PlaylistGenerator'
|
||||
import Timeline from './pages/Timeline'
|
||||
import Compatibility from './pages/Compatibility'
|
||||
import CrateDigger from './pages/CrateDigger'
|
||||
|
||||
function RootRedirect() {
|
||||
const { user, loading } = useAuth()
|
||||
@@ -170,6 +171,16 @@ function AppRoutes() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/crate"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<CrateDigger />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/timeline"
|
||||
element={
|
||||
|
||||
@@ -13,6 +13,7 @@ const baseNavItems = [
|
||||
{ path: '/discover', label: 'Discover', icon: Compass },
|
||||
{ path: '/analyze', label: 'Analyze', icon: Lightbulb },
|
||||
{ path: '/generate-playlist', label: 'Create Playlist', icon: ListPlus },
|
||||
{ path: '/crate', label: 'Crate Dig', icon: Disc3 },
|
||||
{ path: '/bandcamp', label: 'Bandcamp', icon: Store },
|
||||
{ path: '/compatibility', label: 'Taste Match', icon: Users },
|
||||
{ path: '/saved', label: 'Saved', icon: Heart },
|
||||
|
||||
@@ -260,6 +260,21 @@ export interface GeneratedPlaylistResponse {
|
||||
export const generatePlaylist = (theme: string, count: number = 25, save: boolean = false) =>
|
||||
api.post<GeneratedPlaylistResponse>('/recommendations/generate-playlist', { theme, count, save }).then((r) => r.data)
|
||||
|
||||
// Crate Digger
|
||||
export interface CrateItem {
|
||||
title: string
|
||||
artist: string
|
||||
album: string | null
|
||||
reason: string
|
||||
youtube_url: string | null
|
||||
}
|
||||
|
||||
export const fillCrate = (count: number = 20) =>
|
||||
api.post<CrateItem[]>('/recommendations/crate', { count }).then((r) => r.data)
|
||||
|
||||
export const crateSave = (title: string, artist: string, album: string | null, reason: string) =>
|
||||
api.post<{ id: string; saved: boolean }>('/recommendations/crate-save', { title, artist, album, reason }).then((r) => r.data)
|
||||
|
||||
// YouTube Music Import
|
||||
export interface YouTubeTrackResult {
|
||||
title: string
|
||||
@@ -413,4 +428,39 @@ export interface LogEntry {
|
||||
export const getAdminLogs = (level: string = 'ALL', limit: number = 100) =>
|
||||
api.get<{ logs: LogEntry[]; total: number }>('/admin/logs', { params: { level, limit } }).then((r) => r.data)
|
||||
|
||||
// Concerts
|
||||
export interface ConcertEvent {
|
||||
date: string
|
||||
venue: string
|
||||
city: string
|
||||
region: string
|
||||
country: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export const findConcerts = (artist: string) =>
|
||||
api.get<{ artist: string; events: ConcertEvent[] }>('/concerts', { params: { artist } }).then((r) => r.data)
|
||||
|
||||
// Rabbit Hole
|
||||
export interface RabbitHoleStep {
|
||||
title: string
|
||||
artist: string
|
||||
album: string | null
|
||||
reason: string
|
||||
connection: string
|
||||
youtube_url: string | null
|
||||
}
|
||||
|
||||
export interface RabbitHoleResponse {
|
||||
theme: string
|
||||
steps: RabbitHoleStep[]
|
||||
}
|
||||
|
||||
export const generateRabbitHole = (seedArtist?: string, seedTitle?: string, steps: number = 8) =>
|
||||
api.post<RabbitHoleResponse>('/recommendations/rabbit-hole', {
|
||||
seed_artist: seedArtist || undefined,
|
||||
seed_title: seedTitle || undefined,
|
||||
steps,
|
||||
}).then((r) => r.data)
|
||||
|
||||
export default api
|
||||
|
||||
250
frontend/src/pages/CrateDigger.tsx
Normal file
250
frontend/src/pages/CrateDigger.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { Disc3, X, Heart, ExternalLink, Loader2, RotateCcw } from 'lucide-react'
|
||||
import { fillCrate, crateSave, type CrateItem } from '../lib/api'
|
||||
|
||||
type CardState = 'visible' | 'saving' | 'passing'
|
||||
|
||||
export default function CrateDigger() {
|
||||
const [crate, setCrate] = useState<CrateItem[]>([])
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [cardState, setCardState] = useState<CardState>('visible')
|
||||
const [savedCount, setSavedCount] = useState(0)
|
||||
const [crateSize, setCrateSize] = useState(0)
|
||||
const [finished, setFinished] = useState(false)
|
||||
|
||||
const loadCrate = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
setFinished(false)
|
||||
setSavedCount(0)
|
||||
setCurrentIndex(0)
|
||||
setCardState('visible')
|
||||
try {
|
||||
const items = await fillCrate(20)
|
||||
setCrate(items)
|
||||
setCrateSize(items.length)
|
||||
} catch {
|
||||
setError('Failed to fill the crate. Try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const advanceCard = useCallback(() => {
|
||||
setTimeout(() => {
|
||||
setCardState('visible')
|
||||
if (currentIndex + 1 >= crate.length) {
|
||||
setFinished(true)
|
||||
} else {
|
||||
setCurrentIndex((i) => i + 1)
|
||||
}
|
||||
}, 300)
|
||||
}, [currentIndex, crate.length])
|
||||
|
||||
const handlePass = useCallback(() => {
|
||||
if (cardState !== 'visible') return
|
||||
setCardState('passing')
|
||||
advanceCard()
|
||||
}, [cardState, advanceCard])
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (cardState !== 'visible') return
|
||||
const item = crate[currentIndex]
|
||||
setCardState('saving')
|
||||
setSavedCount((c) => c + 1)
|
||||
try {
|
||||
await crateSave(item.title, item.artist, item.album, item.reason)
|
||||
} catch {
|
||||
// Still advance even if save fails
|
||||
}
|
||||
advanceCard()
|
||||
}, [cardState, crate, currentIndex, advanceCard])
|
||||
|
||||
const currentItem = crate[currentIndex]
|
||||
|
||||
// Empty state — no crate loaded yet
|
||||
if (crate.length === 0 && !loading && !finished) {
|
||||
return (
|
||||
<div className="max-w-lg mx-auto text-center py-16">
|
||||
<div className="w-20 h-20 mx-auto mb-6 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<Disc3 className="w-10 h-10 text-purple" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-charcoal mb-3">Crate Digger</h1>
|
||||
<p className="text-charcoal-muted mb-8">
|
||||
Dig through a crate of hand-picked discoveries. Save the ones that speak to you, pass on the rest.
|
||||
</p>
|
||||
{error && (
|
||||
<p className="text-red-600 text-sm mb-4">{error}</p>
|
||||
)}
|
||||
<button
|
||||
onClick={loadCrate}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center gap-2 px-8 py-4 bg-purple text-white rounded-xl font-semibold text-lg hover:bg-purple-700 transition-colors disabled:opacity-50 cursor-pointer border-none"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
Filling Crate...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Disc3 className="w-5 h-5" />
|
||||
Fill My Crate
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="max-w-lg mx-auto text-center py-16">
|
||||
<Loader2 className="w-12 h-12 text-purple animate-spin mx-auto mb-4" />
|
||||
<p className="text-charcoal-muted text-lg">Filling your crate with discoveries...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Finished state
|
||||
if (finished) {
|
||||
return (
|
||||
<div className="max-w-lg mx-auto text-center py-16">
|
||||
<div className="w-20 h-20 mx-auto mb-6 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<Heart className="w-10 h-10 text-purple" />
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-charcoal mb-3">Crate Empty!</h2>
|
||||
<p className="text-charcoal-muted text-lg mb-2">
|
||||
You saved <span className="font-bold text-purple">{savedCount}</span> out of <span className="font-bold">{crateSize}</span> records.
|
||||
</p>
|
||||
<p className="text-charcoal-muted mb-8">
|
||||
{savedCount === 0
|
||||
? 'Tough crowd! Try another crate for different picks.'
|
||||
: savedCount <= crateSize / 4
|
||||
? 'Selective taste. Check your saved recommendations!'
|
||||
: savedCount <= crateSize / 2
|
||||
? 'Nice haul! Some real gems in there.'
|
||||
: 'You loved most of them! Great crate.'}
|
||||
</p>
|
||||
{error && <p className="text-red-600 text-sm mb-4">{error}</p>}
|
||||
<button
|
||||
onClick={loadCrate}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center gap-2 px-8 py-4 bg-purple text-white rounded-xl font-semibold text-lg hover:bg-purple-700 transition-colors disabled:opacity-50 cursor-pointer border-none"
|
||||
>
|
||||
<RotateCcw className="w-5 h-5" />
|
||||
Fill Again
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Main crate digging view
|
||||
return (
|
||||
<div className="max-w-lg mx-auto py-4">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-6">
|
||||
<div className="flex items-center justify-center gap-2 mb-1">
|
||||
<Disc3 className="w-6 h-6 text-purple" />
|
||||
<h1 className="text-2xl font-bold text-charcoal">Crate Digger</h1>
|
||||
</div>
|
||||
<p className="text-charcoal-muted text-sm">Dig through the crate</p>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="flex items-center justify-between mb-4 px-2">
|
||||
<span className="text-sm text-charcoal-muted">
|
||||
{currentIndex + 1} of {crate.length}
|
||||
</span>
|
||||
<span className="text-sm text-charcoal-muted">
|
||||
<Heart className="w-3.5 h-3.5 inline text-purple mr-1" />
|
||||
{savedCount} saved
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="w-full h-1.5 bg-purple-100 rounded-full mb-6 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-purple rounded-full transition-all duration-300"
|
||||
style={{ width: `${((currentIndex + 1) / crate.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
{currentItem && (
|
||||
<div
|
||||
className={`bg-white rounded-2xl shadow-lg border border-purple-100 overflow-hidden transition-all duration-300 ${
|
||||
cardState === 'saving'
|
||||
? 'opacity-0 translate-x-24'
|
||||
: cardState === 'passing'
|
||||
? 'opacity-0 -translate-x-24'
|
||||
: 'opacity-100 translate-x-0'
|
||||
}`}
|
||||
>
|
||||
{/* Album art placeholder */}
|
||||
<div className="h-48 bg-gradient-to-br from-purple-600 via-purple-500 to-purple-800 flex items-center justify-center relative">
|
||||
<Disc3 className="w-20 h-20 text-white/30" />
|
||||
<div className="absolute bottom-3 right-3">
|
||||
{currentItem.youtube_url && (
|
||||
<a
|
||||
href={currentItem.youtube_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 bg-white/20 backdrop-blur-sm rounded-lg text-white text-xs font-medium hover:bg-white/30 transition-colors no-underline"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
Listen
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Track info */}
|
||||
<div className="p-6">
|
||||
<h2 className="text-xl font-bold text-charcoal mb-1 leading-tight">
|
||||
{currentItem.title}
|
||||
</h2>
|
||||
<p className="text-purple font-semibold mb-1">{currentItem.artist}</p>
|
||||
{currentItem.album && (
|
||||
<p className="text-charcoal-muted text-sm mb-4">{currentItem.album}</p>
|
||||
)}
|
||||
{!currentItem.album && <div className="mb-4" />}
|
||||
<div className="bg-cream rounded-xl p-4">
|
||||
<p className="text-charcoal text-sm leading-relaxed italic">
|
||||
"{currentItem.reason}"
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center justify-center gap-8 mt-8">
|
||||
<button
|
||||
onClick={handlePass}
|
||||
disabled={cardState !== 'visible'}
|
||||
className="w-16 h-16 rounded-full bg-white border-2 border-red-300 flex items-center justify-center hover:bg-red-50 hover:border-red-400 transition-all cursor-pointer disabled:opacity-50 shadow-md hover:shadow-lg active:scale-95"
|
||||
title="Pass"
|
||||
>
|
||||
<X className="w-7 h-7 text-red-500" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={cardState !== 'visible'}
|
||||
className="w-16 h-16 rounded-full bg-white border-2 border-green-300 flex items-center justify-center hover:bg-green-50 hover:border-green-400 transition-all cursor-pointer disabled:opacity-50 shadow-md hover:shadow-lg active:scale-95"
|
||||
title="Save"
|
||||
>
|
||||
<Heart className="w-7 h-7 text-green-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Keyboard hints */}
|
||||
<p className="text-center text-charcoal-muted text-xs mt-4">
|
||||
Tap the buttons to pass or save
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user