Add paste-your-songs manual import feature
Users can now paste a list of songs as text to create a playlist without needing any service integration. Supports multiple formats: "Artist - Title", "Title by Artist", "Artist: Title", and numbered lists. Includes a live song count preview in the modal and free tier playlist limit enforcement.
This commit is contained in:
112
backend/app/api/endpoints/manual_import.py
Normal file
112
backend/app/api/endpoints/manual_import.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.playlist import Playlist
|
||||||
|
from app.models.track import Track
|
||||||
|
from app.services.recommender import build_taste_profile
|
||||||
|
from app.schemas.playlist import PlaylistDetailResponse
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/import", tags=["import"])
|
||||||
|
|
||||||
|
|
||||||
|
class PasteImportRequest(BaseModel):
|
||||||
|
name: str
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
def parse_song_line(line: str) -> dict | None:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Strip leading numbering: "1.", "2)", "1 -", "01.", etc.
|
||||||
|
line = re.sub(r"^\d+[\.\)\-\:]\s*", "", line).strip()
|
||||||
|
if not line:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Try "Artist - Title" (most common)
|
||||||
|
if " - " in line:
|
||||||
|
parts = line.split(" - ", 1)
|
||||||
|
return {"artist": parts[0].strip(), "title": parts[1].strip()}
|
||||||
|
|
||||||
|
# Try "Title by Artist"
|
||||||
|
if " by " in line.lower():
|
||||||
|
idx = line.lower().index(" by ")
|
||||||
|
return {"title": line[:idx].strip(), "artist": line[idx + 4 :].strip()}
|
||||||
|
|
||||||
|
# Try "Artist: Title"
|
||||||
|
if ": " in line:
|
||||||
|
parts = line.split(": ", 1)
|
||||||
|
return {"artist": parts[0].strip(), "title": parts[1].strip()}
|
||||||
|
|
||||||
|
# Fallback: treat whole line as title with unknown artist
|
||||||
|
return {"title": line, "artist": "Unknown"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/paste", response_model=PlaylistDetailResponse)
|
||||||
|
async def import_pasted_songs(
|
||||||
|
data: PasteImportRequest,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
if not data.name.strip():
|
||||||
|
raise HTTPException(status_code=400, detail="Playlist name is required")
|
||||||
|
if not data.text.strip():
|
||||||
|
raise HTTPException(status_code=400, detail="No songs provided")
|
||||||
|
|
||||||
|
# Free tier limit
|
||||||
|
if not user.is_pro:
|
||||||
|
result = await db.execute(
|
||||||
|
select(Playlist).where(Playlist.user_id == user.id)
|
||||||
|
)
|
||||||
|
existing = list(result.scalars().all())
|
||||||
|
if len(existing) >= settings.FREE_MAX_PLAYLISTS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Free tier limited to 1 playlist. Upgrade to Pro for unlimited.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse lines
|
||||||
|
lines = data.text.strip().splitlines()
|
||||||
|
parsed = [parse_song_line(line) for line in lines]
|
||||||
|
parsed = [p for p in parsed if p is not None]
|
||||||
|
|
||||||
|
if not parsed:
|
||||||
|
raise HTTPException(status_code=400, detail="Could not parse any songs from the text")
|
||||||
|
|
||||||
|
# Create playlist
|
||||||
|
playlist = Playlist(
|
||||||
|
user_id=user.id,
|
||||||
|
name=data.name.strip(),
|
||||||
|
platform_source="manual",
|
||||||
|
track_count=len(parsed),
|
||||||
|
)
|
||||||
|
db.add(playlist)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Create tracks
|
||||||
|
tracks = []
|
||||||
|
for p in parsed:
|
||||||
|
track = Track(
|
||||||
|
playlist_id=playlist.id,
|
||||||
|
title=p["title"],
|
||||||
|
artist=p["artist"],
|
||||||
|
)
|
||||||
|
db.add(track)
|
||||||
|
tracks.append(track)
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Build taste profile
|
||||||
|
playlist.taste_profile = build_taste_profile(tracks)
|
||||||
|
playlist.tracks = tracks
|
||||||
|
|
||||||
|
return playlist
|
||||||
@@ -2,7 +2,7 @@ from fastapi import FastAPI
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.api.endpoints import auth, billing, playlists, recommendations, youtube_music
|
from app.api.endpoints import auth, billing, lastfm, manual_import, playlists, recommendations, youtube_music
|
||||||
|
|
||||||
app = FastAPI(title="Vynl API", version="1.0.0")
|
app = FastAPI(title="Vynl API", version="1.0.0")
|
||||||
|
|
||||||
@@ -19,6 +19,8 @@ app.include_router(billing.router, prefix="/api")
|
|||||||
app.include_router(playlists.router, prefix="/api")
|
app.include_router(playlists.router, prefix="/api")
|
||||||
app.include_router(recommendations.router, prefix="/api")
|
app.include_router(recommendations.router, prefix="/api")
|
||||||
app.include_router(youtube_music.router, prefix="/api")
|
app.include_router(youtube_music.router, prefix="/api")
|
||||||
|
app.include_router(manual_import.router, prefix="/api")
|
||||||
|
app.include_router(lastfm.router, prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|||||||
@@ -171,6 +171,30 @@ export const importYouTubePlaylist = (url: string) =>
|
|||||||
export const searchYouTubeMusic = (query: string) =>
|
export const searchYouTubeMusic = (query: string) =>
|
||||||
api.post<YouTubeTrackResult[]>('/youtube-music/search', { query }).then((r) => r.data)
|
api.post<YouTubeTrackResult[]>('/youtube-music/search', { query }).then((r) => r.data)
|
||||||
|
|
||||||
|
// Last.fm Import
|
||||||
|
export interface LastfmPreviewTrack {
|
||||||
|
title: string
|
||||||
|
artist: string
|
||||||
|
playcount: number
|
||||||
|
image_url: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LastfmPreviewResponse {
|
||||||
|
display_name: string
|
||||||
|
track_count: number
|
||||||
|
sample_tracks: LastfmPreviewTrack[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const previewLastfm = (username: string) =>
|
||||||
|
api.get<LastfmPreviewResponse>('/lastfm/preview', { params: { username } }).then((r) => r.data)
|
||||||
|
|
||||||
|
export const importLastfm = (username: string, period: string) =>
|
||||||
|
api.post<PlaylistDetailResponse>('/lastfm/import', { username, period }).then((r) => r.data)
|
||||||
|
|
||||||
|
// Manual Import (paste songs)
|
||||||
|
export const importPastedSongs = (name: string, text: string) =>
|
||||||
|
api.post<PlaylistDetailResponse>('/import/paste', { name, text }).then((r) => r.data)
|
||||||
|
|
||||||
// Billing
|
// Billing
|
||||||
export interface BillingStatusResponse {
|
export interface BillingStatusResponse {
|
||||||
is_pro: boolean
|
is_pro: boolean
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ export default function Discover() {
|
|||||||
<textarea
|
<textarea
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder='e.g., "Upbeat indie rock with jangly guitars" or "Dreamy synth-pop for late night drives"'
|
placeholder='e.g., "Upbeat indie rock with jangly guitars", "Dreamy synth-pop for late night drives", or just type artists/songs like "Radiohead, Tame Impala"'
|
||||||
rows={3}
|
rows={3}
|
||||||
className="w-full px-4 py-3 bg-cream/50 border border-purple-100 rounded-xl text-charcoal placeholder:text-charcoal-muted/40 focus:outline-none focus:ring-2 focus:ring-purple/30 focus:border-purple transition-colors text-sm resize-none"
|
className="w-full px-4 py-3 bg-cream/50 border border-purple-100 rounded-xl text-charcoal placeholder:text-charcoal-muted/40 focus:outline-none focus:ring-2 focus:ring-purple/30 focus:border-purple transition-colors text-sm resize-none"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { ListMusic, Plus, Loader2, Music, ChevronRight, Download, X, Youtube, Link2 } from 'lucide-react'
|
import { ListMusic, Plus, Loader2, Music, ChevronRight, Download, X, Youtube, Link2, ClipboardPaste } from 'lucide-react'
|
||||||
import { getPlaylists, getSpotifyPlaylists, importSpotifyPlaylist, importYouTubePlaylist, type PlaylistResponse, type SpotifyPlaylistItem } from '../lib/api'
|
import { getPlaylists, getSpotifyPlaylists, importSpotifyPlaylist, importYouTubePlaylist, previewLastfm, importLastfm, importPastedSongs, type PlaylistResponse, type SpotifyPlaylistItem, type LastfmPreviewResponse } from '../lib/api'
|
||||||
|
|
||||||
export default function Playlists() {
|
export default function Playlists() {
|
||||||
const [playlists, setPlaylists] = useState<PlaylistResponse[]>([])
|
const [playlists, setPlaylists] = useState<PlaylistResponse[]>([])
|
||||||
@@ -14,6 +14,16 @@ export default function Playlists() {
|
|||||||
const [loadingSpotify, setLoadingSpotify] = useState(false)
|
const [loadingSpotify, setLoadingSpotify] = useState(false)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const [showLastfmImport, setShowLastfmImport] = useState(false)
|
||||||
|
const [lastfmUsername, setLastfmUsername] = useState('')
|
||||||
|
const [lastfmPeriod, setLastfmPeriod] = useState('overall')
|
||||||
|
const [lastfmPreview, setLastfmPreview] = useState<LastfmPreviewResponse | null>(null)
|
||||||
|
const [loadingLastfmPreview, setLoadingLastfmPreview] = useState(false)
|
||||||
|
const [importingLastfm, setImportingLastfm] = useState(false)
|
||||||
|
const [showPasteImport, setShowPasteImport] = useState(false)
|
||||||
|
const [pasteName, setPasteName] = useState('')
|
||||||
|
const [pasteText, setPasteText] = useState('')
|
||||||
|
const [importingPaste, setImportingPaste] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadPlaylists()
|
loadPlaylists()
|
||||||
@@ -59,6 +69,59 @@ export default function Playlists() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleLastfmPreview = async () => {
|
||||||
|
if (!lastfmUsername.trim()) return
|
||||||
|
setLoadingLastfmPreview(true)
|
||||||
|
setError('')
|
||||||
|
setLastfmPreview(null)
|
||||||
|
try {
|
||||||
|
const data = await previewLastfm(lastfmUsername.trim())
|
||||||
|
setLastfmPreview(data)
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.detail || 'Last.fm user not found')
|
||||||
|
} finally {
|
||||||
|
setLoadingLastfmPreview(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLastfmImport = async () => {
|
||||||
|
if (!lastfmUsername.trim()) return
|
||||||
|
setImportingLastfm(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const imported = await importLastfm(lastfmUsername.trim(), lastfmPeriod)
|
||||||
|
setPlaylists((prev) => [...prev, imported])
|
||||||
|
setLastfmUsername('')
|
||||||
|
setLastfmPreview(null)
|
||||||
|
setShowLastfmImport(false)
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.detail || 'Failed to import from Last.fm')
|
||||||
|
} finally {
|
||||||
|
setImportingLastfm(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePasteImport = async () => {
|
||||||
|
if (!pasteName.trim() || !pasteText.trim()) return
|
||||||
|
setImportingPaste(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const imported = await importPastedSongs(pasteName.trim(), pasteText.trim())
|
||||||
|
setPlaylists((prev) => [...prev, imported])
|
||||||
|
setPasteName('')
|
||||||
|
setPasteText('')
|
||||||
|
setShowPasteImport(false)
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.detail || 'Failed to import pasted songs')
|
||||||
|
} finally {
|
||||||
|
setImportingPaste(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedLineCount = pasteText
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => line.trim().length > 0).length
|
||||||
|
|
||||||
const handleImport = async (playlistId: string) => {
|
const handleImport = async (playlistId: string) => {
|
||||||
setImporting(playlistId)
|
setImporting(playlistId)
|
||||||
try {
|
try {
|
||||||
@@ -102,6 +165,20 @@ export default function Playlists() {
|
|||||||
<Youtube className="w-4 h-4" />
|
<Youtube className="w-4 h-4" />
|
||||||
Import from YouTube Music
|
Import from YouTube Music
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowLastfmImport(true)}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 bg-[#d51007] text-white font-medium rounded-xl hover:bg-[#b30d06] transition-colors cursor-pointer border-none text-sm"
|
||||||
|
>
|
||||||
|
<Music className="w-4 h-4" />
|
||||||
|
Import from Last.fm
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPasteImport(true)}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 bg-charcoal text-white font-medium rounded-xl hover:bg-charcoal/80 transition-colors cursor-pointer border-none text-sm"
|
||||||
|
>
|
||||||
|
<ClipboardPaste className="w-4 h-4" />
|
||||||
|
Paste Your Songs
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -136,6 +213,20 @@ export default function Playlists() {
|
|||||||
<Youtube className="w-4 h-4" />
|
<Youtube className="w-4 h-4" />
|
||||||
Import from YouTube Music
|
Import from YouTube Music
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowLastfmImport(true)}
|
||||||
|
className="inline-flex items-center gap-2 px-6 py-3 bg-[#d51007] text-white font-medium rounded-xl hover:bg-[#b30d06] transition-colors cursor-pointer border-none text-sm"
|
||||||
|
>
|
||||||
|
<Music className="w-4 h-4" />
|
||||||
|
Import from Last.fm
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPasteImport(true)}
|
||||||
|
className="inline-flex items-center gap-2 px-6 py-3 bg-charcoal text-white font-medium rounded-xl hover:bg-charcoal/80 transition-colors cursor-pointer border-none text-sm"
|
||||||
|
>
|
||||||
|
<ClipboardPaste className="w-4 h-4" />
|
||||||
|
Paste Your Songs
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -228,6 +319,175 @@ export default function Playlists() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Last.fm Import Modal */}
|
||||||
|
{showLastfmImport && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-2xl shadow-xl max-w-lg w-full overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-purple-100">
|
||||||
|
<h2 className="text-lg font-semibold text-charcoal">Import from Last.fm</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowLastfmImport(false); setLastfmPreview(null); setLastfmUsername(''); }}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-purple-50 transition-colors cursor-pointer bg-transparent border-none"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5 text-charcoal-muted" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<p className="text-sm text-charcoal-muted">
|
||||||
|
Enter your Last.fm username to import your top tracks. No login required.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-charcoal mb-1">Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={lastfmUsername}
|
||||||
|
onChange={(e) => { setLastfmUsername(e.target.value); setLastfmPreview(null); }}
|
||||||
|
placeholder="your-lastfm-username"
|
||||||
|
className="w-full px-4 py-3 bg-cream/50 border border-purple-100 rounded-xl text-charcoal placeholder:text-charcoal-muted/40 focus:outline-none focus:ring-2 focus:ring-purple/30 focus:border-purple transition-colors text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-charcoal mb-1">Time Period</label>
|
||||||
|
<select
|
||||||
|
value={lastfmPeriod}
|
||||||
|
onChange={(e) => setLastfmPeriod(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 bg-cream/50 border border-purple-100 rounded-xl text-charcoal focus:outline-none focus:ring-2 focus:ring-purple/30 focus:border-purple transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<option value="overall">All Time</option>
|
||||||
|
<option value="7day">Last 7 Days</option>
|
||||||
|
<option value="1month">Last Month</option>
|
||||||
|
<option value="3month">Last 3 Months</option>
|
||||||
|
<option value="6month">Last 6 Months</option>
|
||||||
|
<option value="12month">Last Year</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleLastfmPreview}
|
||||||
|
disabled={loadingLastfmPreview || !lastfmUsername.trim()}
|
||||||
|
className="flex-1 py-3 bg-charcoal/10 text-charcoal font-medium rounded-xl hover:bg-charcoal/20 transition-colors cursor-pointer border-none text-sm disabled:opacity-50 flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{loadingLastfmPreview ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
Checking...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Preview'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleLastfmImport}
|
||||||
|
disabled={importingLastfm || !lastfmUsername.trim()}
|
||||||
|
className="flex-1 py-3 bg-[#d51007] text-white font-medium rounded-xl hover:bg-[#b30d06] transition-colors cursor-pointer border-none text-sm disabled:opacity-50 flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{importingLastfm ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
Importing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
Import
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lastfmPreview && (
|
||||||
|
<div className="bg-cream/50 rounded-xl p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-charcoal">{lastfmPreview.display_name}</span>
|
||||||
|
<span className="text-xs text-charcoal-muted">{lastfmPreview.track_count} top tracks</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{lastfmPreview.sample_tracks.map((t, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3 text-sm">
|
||||||
|
<span className="text-charcoal-muted w-5 text-right">{i + 1}.</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="text-charcoal font-medium truncate block">{t.title}</span>
|
||||||
|
<span className="text-charcoal-muted text-xs">{t.artist} · {t.playcount} plays</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Paste Songs Import Modal */}
|
||||||
|
{showPasteImport && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-2xl shadow-xl max-w-lg w-full overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-purple-100">
|
||||||
|
<h2 className="text-lg font-semibold text-charcoal">Paste Your Songs</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPasteImport(false)}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-purple-50 transition-colors cursor-pointer bg-transparent border-none"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5 text-charcoal-muted" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-charcoal mb-1.5">Playlist name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={pasteName}
|
||||||
|
onChange={(e) => setPasteName(e.target.value)}
|
||||||
|
placeholder="My favorite songs"
|
||||||
|
className="w-full px-4 py-3 bg-cream/50 border border-purple-100 rounded-xl text-charcoal placeholder:text-charcoal-muted/40 focus:outline-none focus:ring-2 focus:ring-purple/30 focus:border-purple transition-colors text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<label className="block text-sm font-medium text-charcoal">Songs</label>
|
||||||
|
{parsedLineCount > 0 && (
|
||||||
|
<span className="text-xs text-purple font-medium">
|
||||||
|
{parsedLineCount} {parsedLineCount === 1 ? 'song' : 'songs'} detected
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={pasteText}
|
||||||
|
onChange={(e) => setPasteText(e.target.value)}
|
||||||
|
placeholder={`Paste your songs, one per line:\n\nRadiohead - Everything In Its Right Place\nTame Impala - Let It Happen\nBeach House - Space Song\nLevitation by Beach House\nPink Floyd: Comfortably Numb`}
|
||||||
|
rows={8}
|
||||||
|
className="w-full px-4 py-3 bg-cream/50 border border-purple-100 rounded-xl text-charcoal placeholder:text-charcoal-muted/40 focus:outline-none focus:ring-2 focus:ring-purple/30 focus:border-purple transition-colors text-sm resize-none font-mono"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-charcoal-muted mt-1.5">
|
||||||
|
Supports formats: Artist - Title, Title by Artist, Artist: Title
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handlePasteImport}
|
||||||
|
disabled={importingPaste || !pasteName.trim() || !pasteText.trim()}
|
||||||
|
className="w-full py-3 bg-charcoal text-white font-medium rounded-xl hover:bg-charcoal/80 transition-colors cursor-pointer border-none text-sm disabled:opacity-50 flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{importingPaste ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
Importing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
Import {parsedLineCount > 0 ? `${parsedLineCount} Songs` : 'Songs'}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Import Modal */}
|
{/* Import Modal */}
|
||||||
{showImport && (
|
{showImport && (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user