diff --git a/.gitignore b/.gitignore index 89520ff..b209a73 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ /flask_server.log routes/__pycache__/ routes/utils/__pycache__/ + +test.sh diff --git a/app.py b/app.py index f256db3..13e1d28 100644 --- a/app.py +++ b/app.py @@ -1,4 +1,4 @@ -from flask import Flask, request +from flask import Flask, request, send_from_directory, render_template from flask_cors import CORS from routes.search import search_bp from routes.credentials import credentials_bp @@ -6,8 +6,8 @@ from routes.album import album_bp from routes.track import track_bp from routes.playlist import playlist_bp import logging -from datetime import datetime import time +from pathlib import Path def create_app(): app = Flask(__name__) @@ -33,7 +33,16 @@ def create_app(): app.register_blueprint(credentials_bp, url_prefix='/api/credentials') app.register_blueprint(album_bp, url_prefix='/api/album') app.register_blueprint(track_bp, url_prefix='/api/track') - app.register_blueprint(playlist_bp, url_prefix='/api/playlist') + app.register_blueprint(playlist_bp, url_prefix='/api/playlist') + + # Serve frontend + @app.route('/') + def serve_index(): + return render_template('index.html') + + @app.route('/static/') + def serve_static(path): + return send_from_directory('static', path) # Add request logging middleware @app.before_request diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ada89e9 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "spotizer", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/routes/__pycache__/search.cpython-312.pyc b/routes/__pycache__/search.cpython-312.pyc index 654d88e..98cd1dd 100644 Binary files a/routes/__pycache__/search.cpython-312.pyc and b/routes/__pycache__/search.cpython-312.pyc differ diff --git a/routes/search.py b/routes/search.py index 7f8a536..e7756a1 100644 --- a/routes/search.py +++ b/routes/search.py @@ -1,5 +1,5 @@ from flask import Blueprint, jsonify, request -from routes.utils.search import search # Renamed import +from routes.utils.search import search # Corrected import search_bp = Blueprint('search', __name__) @@ -8,7 +8,7 @@ def handle_search(): try: # Get query parameters query = request.args.get('q', '') - search_type = request.args.get('type', 'track') + search_type = request.args.get('search_type', '') limit = int(request.args.get('limit', 10)) # Validate parameters @@ -19,10 +19,10 @@ def handle_search(): if search_type not in valid_types: return jsonify({'error': 'Invalid search type'}), 400 - # Perform the search + # Perform the search with corrected parameter name raw_results = search( query=query, - search_type=search_type, + search_type=search_type, # Fixed parameter name limit=limit ) diff --git a/routes/utils/__pycache__/search.cpython-312.pyc b/routes/utils/__pycache__/search.cpython-312.pyc index b7afcbc..9872c54 100644 Binary files a/routes/utils/__pycache__/search.cpython-312.pyc and b/routes/utils/__pycache__/search.cpython-312.pyc differ diff --git a/routes/utils/search.py b/routes/utils/search.py index 2386640..f87bef4 100644 --- a/routes/utils/search.py +++ b/routes/utils/search.py @@ -10,4 +10,4 @@ def search( # Perform the Spotify search and return the raw response spotify_response = Spo.search(query=query, search_type=search_type, limit=limit) - return spotify_response \ No newline at end of file + return spotify_response diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..1f91602 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,272 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; +} + +body { + background-color: #121212; + color: #ffffff; + min-height: 100vh; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +.search-header { + display: flex; + gap: 15px; + margin-bottom: 30px; + position: sticky; + top: 0; + background-color: #121212; + padding: 20px 0; + z-index: 100; +} + +.search-input { + flex: 1; + padding: 12px 20px; + border: none; + border-radius: 25px; + background: #2a2a2a; + color: white; + font-size: 16px; +} + +.search-type { + padding: 12px 15px; + background: #2a2a2a; + border: none; + border-radius: 25px; + color: white; + cursor: pointer; +} + +.search-button { + padding: 12px 30px; + background-color: #1DB954; + border: none; + border-radius: 25px; + color: white; + font-weight: bold; + cursor: pointer; + transition: background-color 0.3s; +} + +.search-button:hover { + background-color: #1ed760; +} + +.results-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 20px; +} + +.result-card { + background: #181818; + padding: 15px; + border-radius: 8px; + transition: background-color 0.3s; + cursor: pointer; +} + +.result-card:hover { + background-color: #282828; +} + +.album-art { + width: 100%; + border-radius: 4px; + margin-bottom: 15px; +} + +.track-title { + font-size: 16px; + font-weight: 600; + margin-bottom: 8px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.track-artist { + color: #b3b3b3; + font-size: 14px; + margin-bottom: 4px; +} + +.track-details { + color: #b3b3b3; + font-size: 12px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.duration { + background: #2a2a2a; + padding: 4px 8px; + border-radius: 4px; +} + +.loading { + text-align: center; + padding: 50px; + font-size: 18px; + color: #1DB954; +} + +.error { + color: #ff5555; + text-align: center; + padding: 20px; +} + +/* Add to your CSS file */ +.settings-icon { + position: fixed; + top: 20px; + left: 20px; + background: none; + border: none; + color: #ffffff; + font-size: 24px; + cursor: pointer; + z-index: 1000; + transition: transform 0.3s; +} + +.settings-icon:hover { + transform: rotate(90deg); +} + +.sidebar { + position: fixed; + top: 0; + left: -350px; + width: 350px; + height: 100vh; + background: #181818; + padding: 20px; + transition: left 0.3s; + z-index: 1001; + overflow-y: auto; + color: #ffffff; +} + +.sidebar.active { + left: 0; +} + +.sidebar-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.close-btn { + background: none; + border: none; + color: #ffffff; + font-size: 24px; + cursor: pointer; +} + +.service-tabs { + display: flex; + gap: 10px; + margin-bottom: 20px; +} + +.tab-button { + padding: 10px 20px; + border: none; + border-radius: 20px; + background: #2a2a2a; + color: #ffffff; + cursor: pointer; +} + +.tab-button.active { + background: #1DB954; +} + +.credentials-list { + margin-bottom: 20px; +} + +.credential-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 15px; + background: #2a2a2a; + border-radius: 8px; + margin-bottom: 10px; +} + +.credential-actions button { + margin-left: 10px; + padding: 5px 10px; + border: none; + border-radius: 4px; + cursor: pointer; +} + +.edit-btn { + background: #1DB954; + color: white; +} + +.delete-btn { + background: #ff5555; + color: white; +} + +.form-group { + margin-bottom: 15px; +} + +.form-group label { + display: block; + margin-bottom: 5px; +} + +.form-group input, +.form-group textarea { + width: 100%; + padding: 8px; + border-radius: 4px; + border: none; + background: #2a2a2a; + color: white; +} + +.save-btn { + background: #1DB954; + color: white; + padding: 10px 20px; + border: none; + border-radius: 20px; + cursor: pointer; + width: 100%; +} + +.deezer-field { + display: none; +} + +.service-tabs { + margin-bottom: 20px; +} + +.service-tabs button.active { + background: #1DB954; +} \ No newline at end of file diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..52f9933 --- /dev/null +++ b/static/js/app.js @@ -0,0 +1,292 @@ +function logRequest(method, url, body = null) { + console.log(`Sending ${method} request to: ${url}`); + if (body) { + console.log('Request payload:', body); + } +} + +const serviceConfig = { + spotify: { + fields: [ + { id: 'username', label: 'Username', type: 'text' }, + { id: 'credentials', label: 'Credentials', type: 'text' } + ], + validator: (data) => ({ + username: data.username, + credentials: data.credentials + }) + }, + deezer: { + fields: [ + { id: 'arl', label: 'ARL', type: 'text' }, + { id: 'email', label: 'Email', type: 'email' }, + { id: 'password', label: 'Password', type: 'password' } + ], + validator: (data) => ({ + arl: data.arl, + email: data.email, + password: data.password + }) + } +}; + +let currentService = 'spotify'; +let currentCredential = null; + +document.addEventListener('DOMContentLoaded', () => { + const searchButton = document.getElementById('searchButton'); + const searchInput = document.getElementById('searchInput'); + const settingsIcon = document.getElementById('settingsIcon'); + const sidebar = document.getElementById('settingsSidebar'); + const closeSidebar = document.getElementById('closeSidebar'); + const serviceTabs = document.querySelectorAll('.tab-button'); + + // Search functionality + searchButton.addEventListener('click', performSearch); + searchInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') performSearch(); + }); + + // Settings functionality + settingsIcon.addEventListener('click', () => { + sidebar.classList.add('active'); + loadCredentials(currentService); + }); + + closeSidebar.addEventListener('click', () => { + sidebar.classList.remove('active'); + resetForm(); + }); + + serviceTabs.forEach(tab => { + tab.addEventListener('click', () => { + serviceTabs.forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + currentService = tab.dataset.service; + loadCredentials(currentService); + updateFormFields(); + }); + }); + + document.getElementById('credentialForm').addEventListener('submit', handleCredentialSubmit); +}); + +// Search functions remain the same +function performSearch() { + const query = document.getElementById('searchInput').value.trim(); + const searchType = document.getElementById('searchType').value; + const resultsContainer = document.getElementById('resultsContainer'); + + if (!query) { + showError('Please enter a search term'); + return; + } + + resultsContainer.innerHTML = '
Searching...
'; + + fetch(`/api/search?q=${encodeURIComponent(query)}&search_type=${searchType}&limit=30`) + .then(response => response.json()) + .then(data => { + if (data.error) throw new Error(data.error); + const items = data.data[`${searchType}s`]?.items; + + resultsContainer.innerHTML = items?.length + ? items.map(item => createResultCard(item, searchType)).join('') + : '
No results found
'; + }) + .catch(error => showError(error.message)); +} + +function createResultCard(item, type) { + const card = document.createElement('div'); + card.className = 'result-card'; + + let imageUrl, title, subtitle, details; + + switch(type) { + case 'track': + imageUrl = item.album.images[0]?.url || ''; + title = item.name; + subtitle = item.artists.map(a => a.name).join(', '); + details = ` + ${item.album.name} + ${msToMinutesSeconds(item.duration_ms)} + `; + break; + case 'playlist': + imageUrl = item.images[0]?.url || ''; + title = item.name; + subtitle = item.owner.display_name; + details = ` + ${item.tracks.total} tracks + ${item.description || 'No description'} + `; + break; + case 'album': + imageUrl = item.images[0]?.url || ''; + title = item.name; + subtitle = item.artists.map(a => a.name).join(', '); + details = ` + ${item.release_date} + ${item.total_tracks} tracks + `; + break; + } + + card.innerHTML = ` + ${type} cover +
${title}
+
${subtitle}
+
${details}
+ `; + card.addEventListener('click', () => window.open(item.external_urls.spotify, '_blank')); + return card; +} + +// Credential management functions +async function loadCredentials(service) { + try { + const response = await fetch(`/api/credentials/${service}`); + renderCredentialsList(service, await response.json()); + } catch (error) { + showSidebarError(error.message); + } +} + +function renderCredentialsList(service, credentials) { + const list = document.querySelector('.credentials-list'); + list.innerHTML = credentials.map(name => ` +
+ ${name} +
+ + +
+
+ `).join(''); + + list.querySelectorAll('.delete-btn').forEach(btn => { + btn.addEventListener('click', async (e) => { + try { + await fetch(`/api/credentials/${service}/${e.target.dataset.name}`, { method: 'DELETE' }); + loadCredentials(service); + } catch (error) { + showSidebarError(error.message); + } + }); + }); + + list.querySelectorAll('.edit-btn').forEach(btn => { + btn.addEventListener('click', async (e) => { + const service = e.target.dataset.service; + const name = e.target.dataset.name; + + try { + document.querySelector(`[data-service="${service}"]`).click(); + await new Promise(resolve => setTimeout(resolve, 50)); + + const response = await fetch(`/api/credentials/${service}/${name}`); + const data = await response.json(); + + currentCredential = name; + document.getElementById('credentialName').value = name; + document.getElementById('credentialName').disabled = true; + populateFormFields(service, data); + } catch (error) { + showSidebarError(error.message); + } + }); + }); +} + +function updateFormFields() { + const serviceFields = document.getElementById('serviceFields'); + serviceFields.innerHTML = serviceConfig[currentService].fields.map(field => ` +
+ + +
+ `).join(''); +} + +function populateFormFields(service, data) { + serviceConfig[service].fields.forEach(field => { + const element = document.getElementById(field.id); + if (element) element.value = data[field.id] || ''; + }); +} + +async function handleCredentialSubmit(e) { + e.preventDefault(); + const service = document.querySelector('.tab-button.active').dataset.service; + const nameInput = document.getElementById('credentialName'); + const name = nameInput.value.trim(); + + try { + // Validate name exists for new credentials + if (!currentCredential && !name) { + throw new Error('Credential name is required'); + } + + // Collect form data + const formData = {}; + serviceConfig[service].fields.forEach(field => { + formData[field.id] = document.getElementById(field.id).value.trim(); + }); + + // Validate using service config + const data = serviceConfig[service].validator(formData); + + // Use currentCredential for updates, name for new entries + const endpointName = currentCredential || name; + const method = currentCredential ? 'PUT' : 'POST'; + + const response = await fetch(`/api/credentials/${service}/${endpointName}`, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to save credentials'); + } + + loadCredentials(service); + resetForm(); + } catch (error) { + showSidebarError(error.message); + console.error('Submission error:', error); + } +} + + +function resetForm() { + currentCredential = null; + const nameInput = document.getElementById('credentialName'); + nameInput.value = ''; + nameInput.disabled = false; + document.getElementById('credentialForm').reset(); +} + + +// Helper functions +function msToMinutesSeconds(ms) { + const minutes = Math.floor(ms / 60000); + const seconds = ((ms % 60000) / 1000).toFixed(0); + return `${minutes}:${seconds.padStart(2, '0')}`; +} + +function showError(message) { + document.getElementById('resultsContainer').innerHTML = `
${message}
`; +} + +function showSidebarError(message) { + const errorDiv = document.getElementById('sidebarError'); + errorDiv.textContent = message; + setTimeout(() => errorDiv.textContent = '', 3000); +} \ No newline at end of file diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..390b8b7 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,49 @@ + + + + + + Spotify Search + + + + + +
+
+ + + +
+
+
+ + + + \ No newline at end of file