Added frontend

This commit is contained in:
cool.gitter.choco
2025-01-26 13:06:27 -06:00
parent 48db9a1606
commit bf0f50c440
10 changed files with 638 additions and 8 deletions

2
.gitignore vendored
View File

@@ -8,3 +8,5 @@
/flask_server.log
routes/__pycache__/
routes/utils/__pycache__/
test.sh

15
app.py
View File

@@ -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/<path:path>')
def serve_static(path):
return send_from_directory('static', path)
# Add request logging middleware
@app.before_request

6
package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "spotizer",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View File

@@ -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
)

View File

@@ -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
return spotify_response

272
static/css/style.css Normal file
View File

@@ -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;
}

292
static/js/app.js Normal file
View File

@@ -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 = '<div class="loading">Searching...</div>';
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('')
: '<div class="error">No results found</div>';
})
.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 = `
<span>${item.album.name}</span>
<span class="duration">${msToMinutesSeconds(item.duration_ms)}</span>
`;
break;
case 'playlist':
imageUrl = item.images[0]?.url || '';
title = item.name;
subtitle = item.owner.display_name;
details = `
<span>${item.tracks.total} tracks</span>
<span class="duration">${item.description || 'No description'}</span>
`;
break;
case 'album':
imageUrl = item.images[0]?.url || '';
title = item.name;
subtitle = item.artists.map(a => a.name).join(', ');
details = `
<span>${item.release_date}</span>
<span class="duration">${item.total_tracks} tracks</span>
`;
break;
}
card.innerHTML = `
<img src="${imageUrl}" class="album-art" alt="${type} cover">
<div class="track-title">${title}</div>
<div class="track-artist">${subtitle}</div>
<div class="track-details">${details}</div>
`;
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 => `
<div class="credential-item">
<span>${name}</span>
<div class="credential-actions">
<button class="edit-btn" data-name="${name}" data-service="${service}">Edit</button>
<button class="delete-btn" data-name="${name}">Delete</button>
</div>
</div>
`).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 => `
<div class="form-group">
<label>${field.label}:</label>
<input type="${field.type}"
id="${field.id}"
name="${field.id}"
required
${field.type === 'password' ? 'autocomplete="new-password"' : ''}>
</div>
`).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 = `<div class="error">${message}</div>`;
}
function showSidebarError(message) {
const errorDiv = document.getElementById('sidebarError');
errorDiv.textContent = message;
setTimeout(() => errorDiv.textContent = '', 3000);
}

49
templates/index.html Normal file
View File

@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Spotify Search</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<button id="settingsIcon" class="settings-icon">⚙️</button>
<div id="settingsSidebar" class="sidebar">
<div class="sidebar-header">
<h2>Credentials Management</h2>
<button id="closeSidebar" class="close-btn">&times;</button>
</div>
<div class="service-tabs">
<button class="tab-button active" data-service="spotify">Spotify</button>
<button class="tab-button" data-service="deezer">Deezer</button>
</div>
<div class="credentials-list"></div>
<div class="credentials-form">
<h3>Add/Edit Credential</h3>
<form id="credentialForm">
<div class="form-group">
<label>Name:</label>
<input type="text" id="credentialName" required>
</div>
<div id="serviceFields"></div>
<button type="submit" class="save-btn">Save</button>
</form>
</div>
<div id="sidebarError" class="error"></div>
</div>
<div class="container">
<div class="search-header">
<input type="text" class="search-input" placeholder="Search tracks, albums, or playlists..." id="searchInput">
<select class="search-type" id="searchType">
<option value="track">Tracks</option>
<option value="album">Albums</option>
<option value="playlist">Playlists</option>
</select>
<button class="search-button" id="searchButton">Search</button>
</div>
<div id="resultsContainer" class="results-grid"></div>
</div>
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
</body>
</html>