Added frontend
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -8,3 +8,5 @@
|
|||||||
/flask_server.log
|
/flask_server.log
|
||||||
routes/__pycache__/
|
routes/__pycache__/
|
||||||
routes/utils/__pycache__/
|
routes/utils/__pycache__/
|
||||||
|
|
||||||
|
test.sh
|
||||||
|
|||||||
13
app.py
13
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 flask_cors import CORS
|
||||||
from routes.search import search_bp
|
from routes.search import search_bp
|
||||||
from routes.credentials import credentials_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.track import track_bp
|
||||||
from routes.playlist import playlist_bp
|
from routes.playlist import playlist_bp
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
|
||||||
import time
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
def create_app():
|
def create_app():
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
@@ -35,6 +35,15 @@ def create_app():
|
|||||||
app.register_blueprint(track_bp, url_prefix='/api/track')
|
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
|
# Add request logging middleware
|
||||||
@app.before_request
|
@app.before_request
|
||||||
def log_request():
|
def log_request():
|
||||||
|
|||||||
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "spotizer",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -1,5 +1,5 @@
|
|||||||
from flask import Blueprint, jsonify, request
|
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__)
|
search_bp = Blueprint('search', __name__)
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ def handle_search():
|
|||||||
try:
|
try:
|
||||||
# Get query parameters
|
# Get query parameters
|
||||||
query = request.args.get('q', '')
|
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))
|
limit = int(request.args.get('limit', 10))
|
||||||
|
|
||||||
# Validate parameters
|
# Validate parameters
|
||||||
@@ -19,10 +19,10 @@ def handle_search():
|
|||||||
if search_type not in valid_types:
|
if search_type not in valid_types:
|
||||||
return jsonify({'error': 'Invalid search type'}), 400
|
return jsonify({'error': 'Invalid search type'}), 400
|
||||||
|
|
||||||
# Perform the search
|
# Perform the search with corrected parameter name
|
||||||
raw_results = search(
|
raw_results = search(
|
||||||
query=query,
|
query=query,
|
||||||
search_type=search_type,
|
search_type=search_type, # Fixed parameter name
|
||||||
limit=limit
|
limit=limit
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
272
static/css/style.css
Normal file
272
static/css/style.css
Normal 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
292
static/js/app.js
Normal 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
49
templates/index.html
Normal 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">×</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>
|
||||||
Reference in New Issue
Block a user