Add files via upload

This commit is contained in:
Xoconoch
2025-01-23 19:58:51 -06:00
committed by GitHub
parent 90edde3465
commit b1495c0271
25 changed files with 1020 additions and 0 deletions

399
frontend/css/main.css Normal file
View File

@@ -0,0 +1,399 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap');
:root {
--primary-accent: #1DB954;
--background-dark: #121212;
--surface-dark: #1E1E1E;
--border-dark: #333;
--text-primary: #e0e0e0;
}
* {
box-sizing: border-box;
}
html {
font-size: 16px;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
background-color: var(--background-dark);
color: var(--text-primary);
line-height: 1.6;
-webkit-tap-highlight-color: transparent;
}
.search-container {
background: var(--surface-dark);
padding: 1rem;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0,0,0,0.25);
margin-bottom: 1.5rem;
border: 1px solid var(--border-dark);
}
.search-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1rem;
}
select, input, button {
width: 100%;
padding: 0.8rem;
border-radius: 8px;
font-size: 1rem;
transition: all 0.2s ease;
}
select, input {
background: #2D2D2D;
border: 1px solid var(--border-dark);
color: var(--text-primary);
}
button {
background-color: var(--primary-accent);
color: white;
border: none;
font-weight: 500;
justify-content: center;
}
.container-result {
background: #2D2D2D;
border: 1px solid #3A3A3A;
border-radius: 12px;
padding: 1rem;
transition: all 0.2s ease;
position: relative;
}
.container-result:hover {
border-color: #4A4A4A;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
}
.items-grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
padding: 1rem 0;
}
.item-card {
background: transparent;
position: static;
}
.item-image {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 8px;
margin-bottom: 1rem;
}
.item-info {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.item-info > div {
flex: 1;
min-width: 0; /* Prevent text overflow */
}
.item-info h3 {
margin: 0 0 0.5rem;
display: flex;
align-items: center;
word-break: break-word;
}
.explicit {
color: #ff4d4d;
font-size: 0.8rem;
margin-left: 0.5rem;
background: rgba(255,77,77,0.1);
padding: 0.2rem 0.4rem;
border-radius: 4px;
}
.download-btn {
background: rgba(255, 255, 255, 0.1);
border: none;
padding: 0.5rem;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
flex-shrink: 0;
margin-left: 0.5rem;
}
.download-btn:hover {
background: rgba(255,255,255,0.15);
transform: scale(1.1);
}
.download-btn img {
filter: invert(1);
width: 20px;
height: 20px;
transition: opacity 0.2s ease;
}
.download-btn.loading img {
animation: spin 1.5s linear infinite;
}
.download-btn.completed img {
animation: none !important;
}
.category summary {
padding: 0.75rem;
font-size: 0.95rem;
background: #2D2D2D;
border-radius: 12px;
cursor: pointer;
list-style: none;
}
.category[open] summary {
border-radius: 12px 12px 0 0;
}
@media (min-width: 480px) {
.search-form {
flex-direction: row;
}
select, input {
flex: 1;
}
button {
width: auto;
min-width: 120px;
}
}
@media (min-width: 600px) {
html {
font-size: 17px;
}
body {
padding: 1.5rem;
}
.items-grid {
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
}
.item-image {
height: 200px;
}
}
@media (min-width: 768px) {
.search-container {
padding: 1.5rem;
}
.search-form {
gap: 1rem;
margin-bottom: 1.5rem;
}
.category summary {
padding: 1rem 1.25rem;
}
.container-result {
padding: 1.25rem;
}
}
@media (max-width: 480px) {
.item-info {
flex-direction: column;
gap: 0.5rem;
}
.download-btn {
width: 36px;
height: 36px;
padding: 0.4rem;
margin-left: 0;
align-self: flex-end;
}
.download-btn img {
width: 18px;
height: 18px;
}
}
button:active {
transform: scale(0.98);
}
input, select, button {
-webkit-appearance: none;
appearance: none;
}
select {
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23e0e0e0'%3e%3cpath d='M7 10l5 5 5-5z'/%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 0.75rem center;
background-size: 1em;
}
@media (hover: hover) {
html {
overflow-y: scroll;
}
}
@media (prefers-reduced-motion: reduce) {
* {
transition: none !important;
animation: none !important;
}
}
:focus-visible {
outline: 2px solid var(--primary-accent);
outline-offset: 2px;
}
.download-progress {
margin-top: 1rem;
height: 20px;
background: var(--surface-dark);
border-radius: 10px;
overflow: hidden;
position: relative;
border: 1px solid var(--border-dark);
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, var(--primary-accent), #1ED760);
transition: width 0.3s ease;
}
.progress-text {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
font-size: 0.75rem;
font-weight: 500;
color: var(--text-primary);
mix-blend-mode: difference;
pointer-events: none;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.download-btn.completed {
background: rgba(29, 185, 84, 0.2);
cursor: default;
}
.download-btn.completed img {
filter: invert(0.8);
}
@keyframes checkmark-appear {
0% { transform: scale(0); }
90% { transform: scale(1.1); }
100% { transform: scale(1); }
}
.download-btn.completed img.checkmark {
animation: checkmark-appear 0.3s ease-out;
}
@media (max-width: 480px) {
.download-progress {
height: 18px;
}
.progress-text {
font-size: 0.7rem;
right: 6px;
}
}
.category {
margin-bottom: 1.5rem;
}
.items-grid {
gap: 1.5rem;
}
.container-result {
margin-bottom: 0.5rem;
}
.item-info p {
margin: 0.25rem 0;
line-height: 1.4;
color: #b0b0b0;
}
.item-info p:last-child {
margin-bottom: 0.5rem;
}
@media (min-width: 600px) {
.items-grid {
gap: 2rem;
}
.container-result {
margin-bottom: 0.75rem;
}
}
@media (max-width: 480px) {
.items-grid {
gap: 1.25rem;
}
.container-result {
margin-bottom: 0.25rem;
}
}
.loading-gif {
display: block;
margin: 2rem auto;
border-radius: 12px;
width: 80px;
height: 80px;
object-fit: contain;
}

BIN
frontend/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

24
frontend/index.html Normal file
View File

@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Zotifarr</title>
<link rel="stylesheet" href="/css/main.css">
</head>
<body>
<div class="search-container">
<h1>Zotifarr</h1>
<form class="search-form" id="searchForm">
<select id="accountSelect" required>
<option value="">Loading accounts...</option>
</select>
<input type="text" id="searchQuery" placeholder="Enter search query" required>
<button type="submit">Search</button>
</form>
<div id="resultsContainer" class="results-section"></div>
</div>
<script type="module" src="./js/app.js"></script>
</body>
</html>

9
frontend/js/app.js Normal file
View File

@@ -0,0 +1,9 @@
import { loadAccounts } from './components/load.js';
import { initSearch } from './components/search.js';
import { initDownload } from './components/download.js';
document.addEventListener('DOMContentLoaded', () => {
loadAccounts();
initSearch();
initDownload();
});

View File

@@ -0,0 +1,228 @@
export function initSearch() {
document.getElementById('searchForm').addEventListener('submit', handleSearch);
}
async function handleSearch(e) {
e.preventDefault();
const account = document.getElementById('accountSelect').value;
const query = document.getElementById('searchQuery').value;
if (!account || !query) return;
try {
const response = await fetch(`/api/search?account=${encodeURIComponent(account)}&query=${encodeURIComponent(query)}`);
const data = await response.json();
displayResults(data);
} catch (error) {
console.error('Search failed:', error);
document.getElementById('resultsContainer').innerHTML = '<div class="error">Search failed. Please try again.</div>';
}
}
function displayResults(data) {
const container = document.getElementById('resultsContainer');
container.innerHTML = '';
['albums', 'tracks'].forEach(type => {
if (data[type]?.length > 0) {
const section = document.createElement('details');
section.className = 'category';
section.innerHTML = `
<summary>${type.charAt(0).toUpperCase() + type.slice(1)} (${data[type].length})</summary>
<div class="items-grid">
${data[type].map(item => `
<div class="container-result">
${createItemCard(item)}
</div>
`).join('')}
</div>
`;
container.appendChild(section);
}
});
}
function createItemCard(item) {
return `
<div class="item-card"
data-spotify-id="${item.spotify_id}"
data-type="${item.type}"
data-total-tracks="${item.total_tracks || 0}">
${item.image_url ? `<img src="${item.image_url}" alt="${item.name}" class="item-image">` : ''}
<div class="item-info">
<div>
<h3>${item.name}${item.explicit ? '<span class="explicit">E</span>' : ''}</h3>
${item.artists ? `<p>Artists: ${item.artists.join(', ')}</p>` : ''}
${item.owner ? `<p>Owner: ${item.owner}</p>` : ''}
${item.total_tracks ? `<p>Total Tracks: ${item.total_tracks}</p>` : ''}
</div>
${['album', 'track'].includes(item.type) ? `
<button class="download-btn">
<img src="https://brandeps.com/icon-download/D/Download-icon-vector-09.svg"
alt="Download"
width="20"
height="20">
</button>
` : ''}
</div>
</div>
`;
}
export function initDownload() {
document.getElementById('resultsContainer').addEventListener('click', async (e) => {
if (e.target.closest('.download-btn')) {
const card = e.target.closest('.item-card');
const btn = card.querySelector('.download-btn');
const account = document.getElementById('accountSelect').value;
const { spotifyId, type } = card.dataset;
const originalBtnHTML = btn.innerHTML;
try {
// Show loading state
btn.innerHTML = `<img src="https://i.gifer.com/5RT9.gif"
alt="Loading"
style="width: 20px; height: 20px; object-fit: cover">`;
btn.classList.add('loading');
btn.style.pointerEvents = 'none';
const statusLine = document.createElement('div');
statusLine.className = 'status-line';
card.appendChild(statusLine);
const response = await fetch(
`/api/download?account=${encodeURIComponent(account)}&type=${type}&id=${spotifyId}`
);
const reader = response.body
.pipeThrough(new TextDecoderStream())
.getReader();
let buffer = '';
let lastPercentage = -1;
let totalTracks = 0;
let completedTracks = 0;
let isArtistDownload = type === 'artist';
let firstAlbumStart = isArtistDownload;
while (true) {
const { done, value } = await reader.read();
if (done) {
// Update to checkmark and stop animation
btn.innerHTML = `<img src="https://www.svgrepo.com/show/166018/check-mark.svg"
alt="Completed"
style="width: 20px; height: 20px; filter: invert(1)">`;
btn.classList.remove('loading');
btn.classList.add('completed');
btn.style.pointerEvents = 'none';
if (isArtistDownload) {
statusLine.textContent = 'Artist download complete!';
statusLine.classList.add('completed');
}
break;
}
buffer += value;
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
try {
const data = JSON.parse(line);
switch(data.type) {
case 'track_start':
statusLine.textContent = 'Starting track download...';
break;
case 'album_start':
totalTracks = data.total_tracks;
if (isArtistDownload && firstAlbumStart) {
statusLine.textContent = `Downloading ${data.artist}`;
firstAlbumStart = false;
} else {
statusLine.textContent = `Downloading album: ${data.album}`;
}
break;
case 'playlist_start':
statusLine.textContent = `Starting playlist: ${data.name}`;
break;
case 'metadata':
if (type === 'track') {
statusLine.textContent = `Downloading: ${data.name} by ${data.artists[0]}`;
}
break;
case 'download_start':
statusLine.textContent += ` (${formatBytes(data.total_size)})`;
break;
case 'download_progress':
if (Math.floor(data.percentage) > lastPercentage) {
lastPercentage = Math.floor(data.percentage);
const progress = `${lastPercentage}% of ${formatBytes(data.total)}`;
statusLine.textContent = type === 'track'
? `Downloading: ${progress}`
: `Downloading track ${completedTracks + 1}: ${progress}`;
}
break;
case 'album_track_start':
statusLine.textContent = `Downloading track ${data.number}/${totalTracks}: ${data.track}`;
break;
case 'playlist_track_start':
statusLine.textContent = `Downloading track ${data.position}: ${data.track}`;
break;
case 'conversion_start':
statusLine.textContent = `Converting to ${data.format}...`;
break;
case 'track_complete':
completedTracks++;
statusLine.textContent = `Completed: ${data.output_path.split('/').pop()}`;
statusLine.classList.add('completed');
break;
case 'track_skip':
completedTracks++;
statusLine.textContent = `Skipped: ${data.reason === 'already_exists' ? 'Already exists' : data.reason}`;
statusLine.classList.add('warning');
break;
case 'album_complete':
statusLine.textContent = `Album complete! ${data.successful}/${data.total} tracks`;
statusLine.classList.add('completed');
break;
case 'playlist_complete':
statusLine.textContent = `Playlist complete! ${data.downloaded} tracks`;
statusLine.classList.add('completed');
break;
}
} catch (e) {
console.error('Error parsing JSON:', e);
}
}
}
} catch (error) {
console.error('Download failed:', error);
// Restore original button state
btn.innerHTML = originalBtnHTML;
btn.classList.remove('loading', 'completed');
btn.style.pointerEvents = 'auto';
const statusLine = card.querySelector('.status-line');
statusLine.textContent = `Download failed: ${error.message}`;
statusLine.classList.add('error');
}
}
});
}

View File

@@ -0,0 +1,14 @@
// Account loading functionality
export async function loadAccounts() {
try {
const response = await fetch('/api/check');
const data = await response.json();
const select = document.getElementById('accountSelect');
select.innerHTML = data.accounts.length
? data.accounts.map(acc => `<option value="${acc}">${acc}</option>`).join('')
: '<option value="">No accounts found</option>';
} catch (error) {
console.error('Error loading accounts:', error);
}
}

View File

@@ -0,0 +1,75 @@
export function initSearch() {
document.getElementById('searchForm').addEventListener('submit', handleSearch);
}
async function handleSearch(e) {
e.preventDefault();
const account = document.getElementById('accountSelect').value;
const query = document.getElementById('searchQuery').value;
if (!account || !query) return;
const container = document.getElementById('resultsContainer');
container.innerHTML = '<img src="https://i.imgur.com/VUjU03O.gif" alt="Loading..." class="loading-gif">';
try {
const response = await fetch(`/api/search?account=${encodeURIComponent(account)}&query=${encodeURIComponent(query)}`);
const data = await response.json();
displayResults(data);
} catch (error) {
console.error('Search failed:', error);
container.innerHTML = '<div class="error">Search failed. Please try again.</div>';
}
}
function displayResults(data) {
const container = document.getElementById('resultsContainer');
container.innerHTML = '';
['albums', 'tracks'].forEach(type => {
if (data[type]?.length > 0) {
const section = document.createElement('details');
section.className = 'category';
section.innerHTML = `
<summary>${type.charAt(0).toUpperCase() + type.slice(1)} (${data[type].length})</summary>
<div class="items-grid">
${data[type].map(item => `
<div class="container-result">
${createItemCard(item)}
</div>
`).join('')}
</div>
`;
container.appendChild(section);
}
});
}
function createItemCard(item) {
return `
<div class="container-result">
<div class="item-card"
data-spotify-id="${item.spotify_id}"
data-type="${item.type}"
data-total-tracks="${item.total_tracks || 0}">
${item.image_url ? `<img src="${item.image_url}" alt="${item.name}" class="item-image">` : ''}
<div class="item-info">
<div>
<h3>${item.name}${item.explicit ? '<span class="explicit">E</span>' : ''}</h3>
${item.artists ? `<p>Artists: ${item.artists.join(', ')}</p>` : ''}
${item.owner ? `<p>Owner: ${item.owner}</p>` : ''}
${item.total_tracks ? `<p>Total Tracks: ${item.total_tracks}</p>` : ''}
</div>
${['album', 'track'].includes(item.type) ? `
<button class="download-btn">
<img src="https://brandeps.com/icon-download/D/Download-icon-vector-09.svg"
alt="Download"
width="20"
height="20">
</button>
` : ''}
</div>
</div>
</div>
`;
}