remove server-sid history

This commit is contained in:
2026-05-05 20:27:42 +02:00
parent 2f413766fd
commit 353f9b9e9b
4 changed files with 1121 additions and 603 deletions
+3 -65
View File
@@ -23,7 +23,6 @@ import subprocess # NEU: Für FFmpeg Aufruf
import traceback # NEU: Für detaillierte Fehlermeldungen import traceback # NEU: Für detaillierte Fehlermeldungen
# --- Konstanten --- # --- Konstanten ---
HISTORY_FILE = "download_history.json"
STATS_FILE = "stats.json" STATS_FILE = "stats.json"
RANDOM_NAME_LENGTH = 4 RANDOM_NAME_LENGTH = 4
MAX_FILENAME_RETRIES = 10 MAX_FILENAME_RETRIES = 10
@@ -72,7 +71,6 @@ if env_loaded_successfully: logging.info(f".env Datei gefunden und geladen von:
else: logging.warning(".env Datei nicht gefunden...") else: logging.warning(".env Datei nicht gefunden...")
# --- Konfiguration aus .env lesen --- # --- Konfiguration aus .env lesen ---
ENABLE_HISTORY = os.getenv('ENABLE_HISTORY', 'true').lower() == 'true'
try: try:
MAX_WORKERS = int(os.getenv('MAX_WORKERS', '1')) MAX_WORKERS = int(os.getenv('MAX_WORKERS', '1'))
if MAX_WORKERS < 1: if MAX_WORKERS < 1:
@@ -84,7 +82,6 @@ except ValueError:
if MAX_WORKERS <= 0: MAX_WORKERS = 1 # Sicherstellen, dass mindestens 1 Worker läuft if MAX_WORKERS <= 0: MAX_WORKERS = 1 # Sicherstellen, dass mindestens 1 Worker läuft
logging.info(f"Verlauf aktiviert: {ENABLE_HISTORY}")
logging.info(f"Maximale Worker-Threads (für Hintergrundverarbeitung): {MAX_WORKERS}") logging.info(f"Maximale Worker-Threads (für Hintergrundverarbeitung): {MAX_WORKERS}")
# --- Flask App Initialisierung --- # --- Flask App Initialisierung ---
@@ -455,42 +452,7 @@ def upload_to_s3(job_id, file_path, object_name, file_extension, bucket_name, aw
update_status(job_id, error=error_msg, running=False) update_status(job_id, error=error_msg, running=False)
return False return False
# --- History Funktionen (Backend - unverändert) --- # --- Statistik Funktionen ---
def load_history():
if not ENABLE_HISTORY: return []
try:
if os.path.exists(HISTORY_FILE):
if os.path.getsize(HISTORY_FILE) == 0: logging.warning(f"{HISTORY_FILE} ist leer."); return []
with open(HISTORY_FILE, 'r', encoding='utf-8') as f: history = json.load(f)
return history if isinstance(history, list) else []
else: return []
except json.JSONDecodeError as e: logging.error(f"Fehler Laden History (JSON ungültig): {e}."); return []
except Exception as e: logging.error(f"Fehler Laden History (Allgemein): {e}"); return []
def save_history(history_data):
if not ENABLE_HISTORY: return True
try:
with open(HISTORY_FILE, 'w', encoding='utf-8') as f: json.dump(history_data, f, indent=4, ensure_ascii=False)
logging.info(f"History gespeichert: {HISTORY_FILE}")
return True
except Exception as e: logging.error(f"Fehler Speichern History: {e}"); return False
def add_history_entry(platform, title, source_url, s3_url):
if not ENABLE_HISTORY: return True
history = load_history()
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
entry = {"timestamp": timestamp, "platform": platform, "title": title, "source_url": source_url, "s3_url": s3_url}
history.insert(0, entry)
return save_history(history)
def clear_history_file():
if not ENABLE_HISTORY: return True
try:
if os.path.exists(HISTORY_FILE): os.remove(HISTORY_FILE); logging.info("History-Datei gelöscht.")
return True
except Exception as e: logging.error(f"Fehler Löschen History-Datei: {e}"); return False
# --- Statistik Funktionen (Backend - unverändert) ---
def load_stats(): def load_stats():
default_stats = {'total_jobs': 0, 'successful_jobs': 0, 'total_duration_seconds': 0.0, 'total_size_bytes': 0} default_stats = {'total_jobs': 0, 'successful_jobs': 0, 'total_duration_seconds': 0.0, 'total_size_bytes': 0}
try: try:
@@ -633,10 +595,6 @@ def run_download_upload_task(job_id, url, platform, format_preference, mp3_bitra
update_status(job_id, message="Abgeschlossen! (Keine Public URL Base konfiguriert)") update_status(job_id, message="Abgeschlossen! (Keine Public URL Base konfiguriert)")
logging.warning(f"[{job_id}] Öffentliche URL kann nicht angezeigt werden (S3_PUBLIC_URL_BASE fehlt).") logging.warning(f"[{job_id}] Öffentliche URL kann nicht angezeigt werden (S3_PUBLIC_URL_BASE fehlt).")
if not add_history_entry(platform, track_title, url, final_s3_url_for_history):
logging.warning(f"[{job_id}] Konnte Eintrag nicht zur History hinzufügen.")
update_status(job_id, log_entry="WARNUNG: Konnte Eintrag nicht zur History hinzufügen.")
except Exception as e: except Exception as e:
logging.exception(f"[{job_id}] Unerwarteter Fehler im Hauptverarbeitungsblock für URL {url}:") logging.exception(f"[{job_id}] Unerwarteter Fehler im Hauptverarbeitungsblock für URL {url}:")
final_error_message = f"Unerwarteter Verarbeitungsfehler: {strip_ansi_codes(str(e))}" final_error_message = f"Unerwarteter Verarbeitungsfehler: {strip_ansi_codes(str(e))}"
@@ -757,7 +715,7 @@ def index():
if not isinstance(job_statuses, type(manager.dict())): if not isinstance(job_statuses, type(manager.dict())):
job_statuses = manager.dict() job_statuses = manager.dict()
logging.warning("job_statuses wurde neu initialisiert (wahrscheinlich nach Reload).") logging.warning("job_statuses wurde neu initialisiert (wahrscheinlich nach Reload).")
return render_template('index.html', history_enabled=ENABLE_HISTORY) return render_template('index.html')
@app.route('/start_download', methods=['POST']) @app.route('/start_download', methods=['POST'])
def start_download(): def start_download():
@@ -787,14 +745,6 @@ def start_download():
if platform == "Twitter": error_msg += " Stelle sicher, dass es ein Tweet-Link ist (enthält /status/)." if platform == "Twitter": error_msg += " Stelle sicher, dass es ein Tweet-Link ist (enthält /status/)."
return jsonify({"error": error_msg}), 400 return jsonify({"error": error_msg}), 400
if ENABLE_HISTORY:
history = load_history()
for entry in history:
entry_url = entry.get('source_url') or entry.get('soundcloud_url')
if entry_url == url:
entry_platform = entry.get('platform', 'Unbekannt')
return jsonify({"error": f"Dieser Link ({entry_platform}) wurde bereits verarbeitet (Verlauf aktiv)."}), 400
access_key = os.getenv('AWS_ACCESS_KEY_ID'); secret_key = os.getenv('AWS_SECRET_ACCESS_KEY') access_key = os.getenv('AWS_ACCESS_KEY_ID'); secret_key = os.getenv('AWS_SECRET_ACCESS_KEY')
bucket_name = os.getenv('AWS_S3_BUCKET_NAME'); region_name = os.getenv('AWS_REGION') bucket_name = os.getenv('AWS_S3_BUCKET_NAME'); region_name = os.getenv('AWS_REGION')
endpoint_url = os.getenv('S3_ENDPOINT_URL') endpoint_url = os.getenv('S3_ENDPOINT_URL')
@@ -845,18 +795,6 @@ def get_status():
current_status_copy["logs"] = list(current_status_copy["logs"]) current_status_copy["logs"] = list(current_status_copy["logs"])
return jsonify(current_status_copy) return jsonify(current_status_copy)
@app.route('/history')
def get_history():
history = load_history()
return jsonify(history)
@app.route('/clear_history', methods=['POST'])
def clear_history_route():
if clear_history_file():
return jsonify({"message": "Verlauf gelöscht (falls aktiviert)."}), 200
else:
return jsonify({"error": "Fehler beim Löschen des Verlaufs."}), 500
@app.route('/stats') @app.route('/stats')
def get_stats(): def get_stats():
stats_data = load_stats() stats_data = load_stats()
@@ -942,7 +880,7 @@ if __name__ == '__main__':
else: print("\nINFO: FFmpeg gefunden.\n") else: print("\nINFO: FFmpeg gefunden.\n")
print(f"\nFlask App startet (lokaler Modus)..."); print(f"\nFlask App startet (lokaler Modus)...");
print(f"Download-Verzeichnis: {DOWNLOAD_DIR}") print(f"Download-Verzeichnis: {DOWNLOAD_DIR}")
print(f"Verlauf aktiviert: {ENABLE_HISTORY}") print(f"History: Browser localStorage only")
print(f"Öffne http://127.0.0.1:5000 oder http://<Deine-IP>:5000 im Browser.") print(f"Öffne http://127.0.0.1:5000 oder http://<Deine-IP>:5000 im Browser.")
print("(Beende mit STRG+C)\n") print("(Beende mit STRG+C)\n")
app.run(debug=False, host='0.0.0.0', port=5000, use_reloader=False) app.run(debug=False, host='0.0.0.0', port=5000, use_reloader=False)
+241 -187
View File
@@ -1,19 +1,15 @@
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const dom = { const dom = {
// Form & Main
form: document.getElementById('upload-form'), form: document.getElementById('upload-form'),
urlInput: document.getElementById('url'), urlInput: document.getElementById('url'),
submitBtn: document.getElementById('submit-button'),
platformInput: document.getElementById('input-platform'), platformInput: document.getElementById('input-platform'),
// Detection UI
detectionArea: document.getElementById('detection-area'), detectionArea: document.getElementById('detection-area'),
detectedText: document.getElementById('detected-text'), detectedText: document.getElementById('detected-text'),
detectedIcon: document.getElementById('detected-icon'), detectedIcon: document.getElementById('detected-icon'),
platformPulse: document.getElementById('platform-pulse'),
platformBadge: document.getElementById('platform-badge'),
urlIcon: document.getElementById('url-icon'), urlIcon: document.getElementById('url-icon'),
inputWrapper: document.getElementById('input-wrapper'),
// Options
optionsContainer: document.getElementById('options-container'),
ytOptions: document.getElementById('youtube-options'), ytOptions: document.getElementById('youtube-options'),
codecSection: document.getElementById('codec-options-section'), codecSection: document.getElementById('codec-options-section'),
ytRadios: document.querySelectorAll('input[name="yt_format"]'), ytRadios: document.querySelectorAll('input[name="yt_format"]'),
@@ -21,283 +17,341 @@ document.addEventListener('DOMContentLoaded', () => {
mp4Select: document.getElementById('mp4_quality'), mp4Select: document.getElementById('mp4_quality'),
codecSwitch: document.getElementById('codec-switch'), codecSwitch: document.getElementById('codec-switch'),
codecPref: document.getElementById('codec_preference'), codecPref: document.getElementById('codec_preference'),
// Views
processView: document.getElementById('process-view'), processView: document.getElementById('process-view'),
resultView: document.getElementById('result-view'), resultView: document.getElementById('result-view'),
formContainer: document.querySelector('.form-container'), formContainer: document.querySelector('.form-container'),
// Progress & Logs
statusMsg: document.getElementById('status-message'), statusMsg: document.getElementById('status-message'),
progressBar: document.getElementById('progress-bar'), progressBar: document.getElementById('progress-bar'),
progressPct: document.getElementById('progress-pct'),
logContent: document.getElementById('pseudo-log-content'), logContent: document.getElementById('pseudo-log-content'),
// Result
resultUrl: document.getElementById('result-url'), resultUrl: document.getElementById('result-url'),
errorMsg: document.getElementById('error-message'), errorMsg: document.getElementById('error-message'),
historyTbody: document.querySelector('#history-table tbody'),
// History clearHistoryBtn:document.getElementById('clear-history-button'),
historyTableBody: document.querySelector('#history-table tbody'),
clearHistoryBtn: document.getElementById('clear-history-button')
}; };
let currentJobId = null; let jobId = null;
let pollingInterval = null; let pollTimer = null;
let pseudoLogInterval = null; let pseudoTimer = null;
let lastLogIndex = 0;
let realLogsActive = false;
const platforms = [ // ---- Platform definitions ----
{ name: 'SoundCloud', pattern: /soundcloud\.com/, icon: 'fa-soundcloud', color: '#ff5500' }, const PLATFORMS = [
{ name: 'YouTube', pattern: /(youtube\.com|youtu\.be)/, icon: 'fa-youtube', color: '#ff0000' }, { name:'SoundCloud', pattern:/soundcloud\.com/, icon:'fa-soundcloud', color:'#ff5500', rgb:'255,85,0' },
{ name: 'TikTok', pattern: /tiktok\.com/, icon: 'fa-tiktok', color: '#fe2c55' }, { name:'YouTube', pattern:/(youtube\.com|youtu\.be)/,icon:'fa-youtube', color:'#ff0000', rgb:'255,0,0' },
{ name: 'Instagram', pattern: /instagram\.com/, icon: 'fa-instagram', color: '#E1306C' }, { name:'TikTok', pattern:/tiktok\.com/, icon:'fa-tiktok', color:'#fe2c55', rgb:'254,44,85' },
{ name: 'Twitter', pattern: /(twitter\.com|x\.com)/, icon: 'fa-x-twitter', color: '#fff' } { name:'Instagram', pattern:/instagram\.com/, icon:'fa-instagram', color:'#E1306C', rgb:'225,48,108' },
{ name:'Twitter', pattern:/(twitter\.com|x\.com)/, icon:'fa-x-twitter', color:'#1d9bf0', rgb:'29,155,240' },
]; ];
const pseudoLogs = [ const PSEUDO_LOGS = [
"Connecting to media node...", 'Connecting to media node...',
"Handshaking with API...", 'Handshaking with API endpoint...',
"Resolving stream URL...", 'Resolving stream metadata...',
"Allocating buffer...", 'Allocating download buffer...',
"Starting download stream...", 'Starting download stream...',
"Processing data chunks...", 'Processing data chunks...',
"Verifying integrity...", 'Verifying file integrity...',
"Optimizing container...", 'Optimizing audio/video container...',
"Finalizing upload..." 'Preparing upload to storage...',
'Syncing with remote bucket...',
]; ];
// ---- Init ----
function init() { function init() {
setupEvents(); bindEvents();
loadHistory(); renderHistory();
} }
function setupEvents() { function bindEvents() {
dom.urlInput.addEventListener('input', handleUrlInput); dom.urlInput.addEventListener('input', onUrlChange);
dom.form.addEventListener('submit', handleSubmit); dom.form.addEventListener('submit', onSubmit);
dom.ytRadios.forEach(r => r.addEventListener('change', updateQualitySelect));
dom.ytRadios.forEach(r => r.addEventListener('change', updateYtOptions)); if (dom.codecSwitch) {
if(dom.codecSwitch) {
dom.codecSwitch.addEventListener('change', e => { dom.codecSwitch.addEventListener('change', e => {
dom.codecPref.value = e.target.checked ? 'h264' : 'original'; dom.codecPref.value = e.target.checked ? 'h264' : 'original';
}); });
} }
if (dom.clearHistoryBtn) {
if(dom.clearHistoryBtn) dom.clearHistoryBtn.addEventListener('click', clearHistory); dom.clearHistoryBtn.addEventListener('click', clearHistory);
}
} }
// --- Detection --- // ---- URL detection ----
function handleUrlInput() { function onUrlChange() {
const url = dom.urlInput.value.trim(); const val = dom.urlInput.value.trim();
const detected = platforms.find(p => p.pattern.test(url)); const match = PLATFORMS.find(p => p.pattern.test(val));
if (detected) { if (match) {
dom.platformInput.value = detected.name; applyPlatform(match);
dom.detectedText.textContent = detected.name + " detected"; } else if (!val) {
dom.detectedIcon.className = `fab ${detected.icon}`; resetPlatform();
dom.detectionArea.style.opacity = '1'; }
dom.detectionArea.style.transform = 'translateY(0)'; }
// Icon in Input function applyPlatform(p) {
dom.urlIcon.className = `fab ${detected.icon}`; dom.platformInput.value = p.name;
dom.urlIcon.style.color = detected.color; dom.detectedText.textContent = p.name + ' detected';
dom.detectedIcon.className = 'fab ' + p.icon;
dom.urlIcon.innerHTML = `<i class="fab ${p.icon}"></i>`;
dom.urlIcon.style.color = p.color;
showOptions(detected.name); // CSS vars for color theming
} else { document.documentElement.style.setProperty('--platform-color', p.color);
if(url.length === 0) { document.documentElement.style.setProperty('--platform-rgb', p.rgb);
dom.detectionArea.style.opacity = '0';
dom.detectionArea.style.transform = 'translateY(-10px)'; // Badge colors
dom.urlIcon.className = 'fas fa-link'; dom.platformBadge.style.borderColor = `rgba(${p.rgb}, 0.3)`;
dom.platformBadge.style.background = `rgba(${p.rgb}, 0.1)`;
dom.platformBadge.style.color = p.color;
if (dom.platformPulse) {
dom.platformPulse.style.background = p.color;
dom.platformPulse.style.boxShadow = `0 0 8px ${p.color}`;
}
// Input glow tint
dom.inputWrapper.style.setProperty('--platform-glow', `rgba(${p.rgb}, 0.15)`);
dom.detectionArea.classList.add('visible');
showOptions(p.name);
}
function resetPlatform() {
document.documentElement.style.setProperty('--platform-color', '#8b5cf6');
dom.urlIcon.innerHTML = '<i class="fas fa-link"></i>';
dom.urlIcon.style.color = ''; dom.urlIcon.style.color = '';
} dom.detectionArea.classList.remove('visible');
// Keep options hidden if no match
}
} }
function showOptions(platform) { function showOptions(name) {
dom.ytOptions.classList.add('d-none'); dom.ytOptions.classList.add('d-none');
dom.codecSection.classList.add('d-none'); dom.codecSection.classList.add('d-none');
if (name === 'YouTube') {
if (platform === 'YouTube') {
dom.ytOptions.classList.remove('d-none'); dom.ytOptions.classList.remove('d-none');
updateYtOptions(); updateQualitySelect();
} else if (['TikTok', 'Instagram', 'Twitter'].includes(platform)) { } else if (['TikTok','Instagram','Twitter'].includes(name)) {
dom.codecSection.classList.remove('d-none'); dom.codecSection.classList.remove('d-none');
} }
} }
function updateYtOptions() { function updateQualitySelect() {
const format = document.querySelector('input[name="yt_format"]:checked').value; const fmt = document.querySelector('input[name="yt_format"]:checked')?.value;
if (format === 'mp3') { dom.mp3Select.classList.toggle('d-none', fmt !== 'mp3');
dom.mp3Select.classList.remove('d-none'); dom.mp4Select.classList.toggle('d-none', fmt === 'mp3');
dom.mp4Select.classList.add('d-none');
} else {
dom.mp3Select.classList.add('d-none');
dom.mp4Select.classList.remove('d-none');
}
} }
// --- Processing --- // ---- Submit ----
async function handleSubmit(e) { async function onSubmit(e) {
e.preventDefault(); e.preventDefault();
// UI Switch
dom.formContainer.classList.add('d-none'); dom.formContainer.classList.add('d-none');
dom.processView.classList.remove('d-none'); dom.processView.classList.remove('d-none');
dom.resultView.classList.add('d-none'); dom.resultView.classList.add('d-none');
dom.errorMsg.classList.add('d-none'); dom.errorMsg.classList.add('d-none');
dom.logContent.innerHTML = '';
lastLogIndex = 0;
realLogsActive = false;
setProgress(0);
dom.statusMsg.textContent = 'INITIALIZING...';
startPseudoLogs(); startPseudoLogs();
const formData = new FormData(dom.form);
try { try {
const res = await fetch('/start_download', { method: 'POST', body: formData }); const res = await fetch('/start_download', { method:'POST', body: new FormData(dom.form) });
const data = await res.json(); const data = await res.json();
if (res.ok && data.job_id) { if (res.ok && data.job_id) {
currentJobId = data.job_id; jobId = data.job_id;
startPolling(); startPolling();
} else { } else {
throw new Error(data.error || "Start failed"); throw new Error(data.error || 'Failed to start job');
} }
} catch (err) { } catch (err) {
showError(err.message); showError(err.message);
} }
} }
// ---- Polling ----
function startPolling() { function startPolling() {
pollingInterval = setInterval(async () => { pollTimer = setInterval(async () => {
if (!jobId) return;
try { try {
if(!currentJobId) return; const res = await fetch(`/status?job_id=${jobId}`);
const res = await fetch(`/status?job_id=${currentJobId}`); if (res.status === 404) { showError('Job not found'); return; }
if (res.status === 404) { showError("Job not found"); return; } const s = await res.json();
const status = await res.json(); setProgress(s.progress || 0);
if (s.message) dom.statusMsg.textContent = s.message.toUpperCase();
// Update UI // Show real server logs as they arrive
dom.progressBar.style.width = (status.progress || 0) + "%"; if (Array.isArray(s.logs) && s.logs.length > lastLogIndex) {
if(status.message) dom.statusMsg.textContent = status.message.toUpperCase(); if (!realLogsActive) {
realLogsActive = true;
if (status.status === 'completed') { clearInterval(pseudoTimer);
finishJob(status.result_url); removeCursor();
} else if (status.status === 'error' || status.error) {
showError(status.error || status.message);
} }
} catch (e) { s.logs.slice(lastLogIndex).forEach(line => addLine(line, 'is-real'));
console.error(e); lastLogIndex = s.logs.length;
}
if (s.status === 'completed') {
onJobDone(s.result_url);
} else if (s.status === 'error' || s.error) {
showError(s.error || s.message);
}
} catch {
// network blip — keep polling
} }
}, 1000); }, 1000);
} }
function finishJob(url) { // ---- Terminal ----
clearInterval(pollingInterval); function addLine(text, cls = '') {
clearInterval(pseudoLogInterval); removeCursor();
dom.processView.classList.add('d-none'); const el = document.createElement('div');
dom.resultView.classList.remove('d-none'); el.className = 'terminal-line' + (cls ? ' ' + cls : '');
dom.resultUrl.href = url; el.textContent = (cls === 'is-real' ? '» ' : '> ') + text;
saveHistory(url); const cur = document.createElement('span');
cur.className = 'term-cursor';
el.appendChild(cur);
dom.logContent.appendChild(el);
dom.logContent.scrollTop = dom.logContent.scrollHeight;
} }
function showError(msg) { function removeCursor() {
clearInterval(pollingInterval); dom.logContent.querySelectorAll('.term-cursor').forEach(c => c.remove());
clearInterval(pseudoLogInterval); }
function startPseudoLogs() {
let idx = 0;
addLine(PSEUDO_LOGS[idx++]);
pseudoTimer = setInterval(() => {
if (realLogsActive) { clearInterval(pseudoTimer); return; }
if (idx < PSEUDO_LOGS.length) addLine(PSEUDO_LOGS[idx++]);
}, 2400);
}
// ---- Progress ----
function setProgress(val) {
const pct = Math.min(100, Math.max(0, Math.round(val)));
dom.progressBar.style.width = pct + '%';
if (dom.progressPct) dom.progressPct.textContent = pct + '%';
}
// ---- Finish ----
function onJobDone(url) {
clearInterval(pollTimer);
clearInterval(pseudoTimer);
removeCursor();
const done = document.createElement('div');
done.className = 'terminal-line is-success';
done.textContent = '✓ Upload complete. Your file is ready.';
dom.logContent.appendChild(done);
dom.logContent.scrollTop = dom.logContent.scrollHeight;
setProgress(100);
setTimeout(() => {
dom.processView.classList.add('d-none'); dom.processView.classList.add('d-none');
dom.formContainer.classList.remove('d-none'); // Back to form dom.resultView.classList.remove('d-none');
if (url) dom.resultUrl.href = url;
}, 700);
persistHistory(url);
}
// ---- Error ----
function showError(msg) {
clearInterval(pollTimer);
clearInterval(pseudoTimer);
dom.processView.classList.add('d-none');
dom.formContainer.classList.remove('d-none');
dom.errorMsg.textContent = msg; dom.errorMsg.textContent = msg;
dom.errorMsg.classList.remove('d-none'); dom.errorMsg.classList.remove('d-none');
} }
// --- Pseudo Logs (CLI Effect) --- // ---- LocalStorage History ----
function startPseudoLogs() { const LS_KEY = 'mdl_history';
dom.logContent.innerHTML = ''; const TTL = 7 * 24 * 60 * 60 * 1000; // 7 days
let index = 0;
function addLine() { function persistHistory(resultUrl) {
if (index >= pseudoLogs.length) index = 0; // Loop or stop const entry = {
const div = document.createElement('div'); url: resultUrl,
div.className = 'terminal-line'; source: dom.urlInput.value.trim(),
div.textContent = "> " + pseudoLogs[index]; platform: dom.platformInput.value,
dom.logContent.appendChild(div); title: dom.platformInput.value + ' Download',
ts: Date.now(),
// Auto scroll };
const win = document.querySelector('.terminal-window .terminal-body'); let data = readHistory();
win.scrollTop = win.scrollHeight; data.unshift(entry);
if (data.length > 30) data.length = 30;
index++; localStorage.setItem(LS_KEY, JSON.stringify(data));
renderHistory();
} }
addLine(); // First one immediate function readHistory() {
pseudoLogInterval = setInterval(addLine, 2000); try {
const raw = localStorage.getItem(LS_KEY);
if (!raw) return [];
const data = JSON.parse(raw).filter(i => Date.now() - i.ts < TTL);
localStorage.setItem(LS_KEY, JSON.stringify(data));
return data;
} catch { return []; }
} }
// --- Local History --- function renderHistory() {
function loadHistory() { const data = readHistory();
const raw = localStorage.getItem('mdl_history'); if (!dom.historyTbody) return;
if(!raw) {
dom.historyTableBody.innerHTML = '<tr><td colspan="5" class="text-center text-white-50 py-3">No history available</td></tr>'; if (!data.length) {
dom.historyTbody.innerHTML =
'<tr><td colspan="5" style="text-align:center;padding:2rem;color:rgba(255,255,255,0.25);font-size:0.82rem">No downloads yet</td></tr>';
return; return;
} }
let data = JSON.parse(raw); dom.historyTbody.innerHTML = '';
// Clean expired (>7 days) data.forEach((item, i) => {
const now = Date.now(); const d = new Date(item.ts);
data = data.filter(i => (now - i.ts) < (7 * 24 * 60 * 60 * 1000)); const date = d.toLocaleDateString([], { month:'short', day:'numeric' });
localStorage.setItem('mdl_history', JSON.stringify(data)); const time = d.toLocaleTimeString([], { hour:'2-digit', minute:'2-digit' });
renderHistory(data);
}
function renderHistory(data) {
dom.historyTableBody.innerHTML = '';
data.forEach(item => {
const date = new Date(item.ts);
const time = date.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
const tr = document.createElement('tr'); const tr = document.createElement('tr');
tr.className = 'history-row-enter';
tr.style.animationDelay = (i * 0.04) + 's';
tr.innerHTML = ` tr.innerHTML = `
<td class="ps-4 text-white-50 small font-monospace">${time}</td> <td class="ps-4" style="font-family:var(--mono);font-size:0.72rem;color:rgba(255,255,255,0.35);white-space:nowrap">${date} ${time}</td>
<td class="text-center text-primary-light small">${item.platform}</td> <td><span style="background:rgba(139,92,246,0.12);border:1px solid rgba(139,92,246,0.2);color:var(--accent);padding:2px 10px;border-radius:100px;font-size:0.7rem">${escHtml(item.platform)}</span></td>
<td class="text-truncate" style="max-width: 150px;">${item.title || 'Unknown'}</td> <td style="max-width:140px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(item.title || 'Download')}</td>
<td class="text-center"> <td style="text-align:center">
<a href="${item.source}" target="_blank" class="text-white-50" title="Open source: ${item.source}"> ${item.source
<i class="fas fa-link"></i> ? `<a href="${escHtml(item.source)}" target="_blank" rel="noopener" title="${escHtml(item.source)}"><i class="fas fa-arrow-up-right-from-square fa-sm"></i></a>`
</a> : '<span style="color:rgba(255,255,255,0.2)">—</span>'}
</td> </td>
<td class="text-end pe-4"> <td class="text-end pe-4">
<a href="${item.url}" target="_blank" class="text-primary-light" title="Download"> <a href="${escHtml(item.url)}" target="_blank" rel="noopener"
<i class="fas fa-download"></i> style="background:rgba(139,92,246,0.12);border:1px solid rgba(139,92,246,0.2);color:var(--accent);padding:3px 12px;border-radius:100px;font-size:0.72rem;text-decoration:none;white-space:nowrap">
<i class="fas fa-download me-1"></i>Get
</a> </a>
</td> </td>`;
`; dom.historyTbody.appendChild(tr);
dom.historyTableBody.appendChild(tr);
}); });
} }
function saveHistory(resultUrl) {
const pf = dom.platformInput.value;
const sourceUrl = dom.urlInput.value; // Capture source URL
const entry = {
url: resultUrl,
source: sourceUrl,
platform: pf,
title: pf + " Download",
ts: Date.now()
};
let data = JSON.parse(localStorage.getItem('mdl_history') || '[]');
data.unshift(entry);
if(data.length > 20) data.pop(); // Max 20 entries
localStorage.setItem('mdl_history', JSON.stringify(data));
loadHistory();
}
function clearHistory() { function clearHistory() {
if(confirm("Really clear history?")) { if (!confirm('Clear all download history?')) return;
localStorage.removeItem('mdl_history'); localStorage.removeItem(LS_KEY);
loadHistory(); renderHistory();
} }
function escHtml(s) {
return String(s)
.replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
} }
init(); init();
+703 -194
View File
@@ -1,296 +1,805 @@
/* --- Variables & Reset --- */ /* ===========================
Variables
=========================== */
:root { :root {
--bg-deep: #0f0c29; --bg: #06040f;
--bg-mid: #302b63; --primary: #7c3aed;
--bg-light: #24243e; --primary-mid: #8b5cf6;
--primary-light: #a78bfa;
--accent: #c4b5fd;
--accent-soft: #ede9fe;
--primary: #8b5cf6; /* Violet 500 */ --platform-color: #8b5cf6;
--primary-glow: #a78bfa; --platform-rgb: 139, 92, 246;
--accent: #d8b4fe;
--glass-bg: rgba(255, 255, 255, 0.05); --glass: rgba(255,255,255,0.04);
--glass-border: rgba(255, 255, 255, 0.1); --glass-hover: rgba(255,255,255,0.07);
--glass-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37); --border: rgba(255,255,255,0.08);
--border-hover: rgba(255,255,255,0.15);
--font-ui: 'Outfit', sans-serif; --font: 'Outfit', sans-serif;
--font-mono: 'JetBrains Mono', monospace; --mono: 'JetBrains Mono', monospace;
--ease: cubic-bezier(0.4, 0, 0.2, 1);
--spring: cubic-bezier(0.34, 1.56, 0.64, 1);
} }
/* ===========================
Reset & Base
=========================== */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { body {
font-family: var(--font-ui); font-family: var(--font);
background-color: var(--bg-deep); background: var(--bg);
color: #fff; color: #fff;
overflow-x: hidden; overflow-x: hidden;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
} }
/* --- Animated Background --- */ /* ===========================
.bg-gradient-animate { Background Layers
position: fixed; =========================== */
top: 0; left: 0; width: 100%; height: 100%; .bg-base {
background: linear-gradient(-45deg, #0f0c29, #302b63, #24243e, #1a1a2e); position: fixed; inset: 0; z-index: -4;
background-size: 400% 400%; background:
animation: gradientBG 15s ease infinite; radial-gradient(ellipse 80% 60% at 20% 10%, rgba(124,58,237,0.18) 0%, transparent 60%),
z-index: -2; radial-gradient(ellipse 60% 50% at 80% 90%, rgba(76,29,149,0.22) 0%, transparent 60%),
#06040f;
} }
@keyframes gradientBG { .bg-mesh {
0% { background-position: 0% 50%; } position: fixed; inset: 0; z-index: -3;
50% { background-position: 100% 50%; } background-image:
100% { background-position: 0% 50%; } linear-gradient(rgba(139,92,246,0.025) 1px, transparent 1px),
linear-gradient(90deg, rgba(139,92,246,0.025) 1px, transparent 1px);
background-size: 60px 60px;
animation: meshMove 40s linear infinite;
}
@keyframes meshMove {
from { transform: translate(0,0); }
to { transform: translate(60px,60px); }
} }
.bg-noise {
position: fixed; inset: 0; z-index: -2; opacity: 0.025; pointer-events: none;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
background-size: 256px;
}
/* Orbs */
.bg-glow { .bg-glow {
position: fixed; position: fixed; border-radius: 50%;
border-radius: 50%; filter: blur(90px); pointer-events: none; z-index: -1;
filter: blur(100px);
opacity: 0.4;
z-index: -1;
animation: floatBlob 10s ease-in-out infinite;
} }
.bg-glow-1 { .bg-glow-1 {
top: -10%; left: -10%; width: 50vw; height: 50vw; top: -20%; left: -15%; width: 65vw; height: 65vw;
background: var(--primary); background: radial-gradient(circle, rgba(124,58,237,0.35) 0%, transparent 70%);
opacity: 0.5;
animation: orb1 22s ease-in-out infinite;
} }
.bg-glow-2 { .bg-glow-2 {
bottom: -10%; right: -10%; width: 40vw; height: 40vw; bottom: -20%; right: -15%; width: 55vw; height: 55vw;
background: #4c1d95; /* Darker Purple */ background: radial-gradient(circle, rgba(76,29,149,0.4) 0%, transparent 70%);
animation-delay: -5s; opacity: 0.4;
animation: orb2 28s ease-in-out infinite;
}
.bg-glow-3 {
top: 40%; left: 50%; transform: translate(-50%,-50%);
width: 35vw; height: 35vw;
background: radial-gradient(circle, rgba(49,22,105,0.5) 0%, transparent 70%);
opacity: 0.25;
animation: orb3 16s ease-in-out infinite;
}
@keyframes orb1 {
0%,100% { transform: translate(0,0); }
33% { transform: translate(50px,-70px); }
66% { transform: translate(-30px,40px); }
}
@keyframes orb2 {
0%,100% { transform: translate(0,0); }
50% { transform: translate(-60px,-50px); }
}
@keyframes orb3 {
0%,100% { transform: translate(-50%,-50%) scale(1); }
50% { transform: translate(-50%,-50%) scale(1.4); }
} }
@keyframes floatBlob { /* Floating Particles */
0%, 100% { transform: translate(0, 0); } .particles { position: fixed; inset: 0; overflow: hidden; pointer-events: none; z-index: 0; }
50% { transform: translate(30px, -20px); } .particle {
position: absolute;
border-radius: 50%;
background: var(--primary-light);
opacity: 0;
animation: particleFloat linear infinite;
}
.p1 { width:3px;height:3px; left:10%; animation-duration:18s; animation-delay:0s; top:100%; }
.p2 { width:2px;height:2px; left:25%; animation-duration:24s; animation-delay:4s; top:100%; }
.p3 { width:4px;height:4px; left:50%; animation-duration:20s; animation-delay:8s; top:100%; }
.p4 { width:2px;height:2px; left:70%; animation-duration:16s; animation-delay:2s; top:100%; }
.p5 { width:3px;height:3px; left:85%; animation-duration:22s; animation-delay:12s; top:100%; }
.p6 { width:2px;height:2px; left:40%; animation-duration:26s; animation-delay:6s; top:100%; }
@keyframes particleFloat {
0% { transform: translateY(0) translateX(0); opacity: 0; }
5% { opacity: 0.6; }
90% { opacity: 0.3; }
100% { transform: translateY(-100vh) translateX(40px); opacity: 0; }
} }
/* --- Typography & Helpers --- */ /* ===========================
.text-primary-light { color: var(--accent) !important; } Navbar
.tracking-wide { letter-spacing: 0.05em; } =========================== */
.max-w-lg { max-width: 600px; } .navbar {
background: rgba(6,4,15,0.75);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border-bottom: 1px solid var(--border);
z-index: 100;
}
.brand {
font-size: 1.15rem;
font-weight: 600;
letter-spacing: -0.02em;
color: rgba(255,255,255,0.9);
}
.brand-accent { color: rgba(255,255,255,0.6); font-weight: 300; }
.brand-dot { color: var(--primary-light); }
/* --- Hero Section --- */ .btn-glass-icon {
width: 40px; height: 40px; border-radius: 50%;
background: rgba(255,255,255,0.06);
border: 1px solid var(--border);
color: rgba(255,255,255,0.6);
display: flex; align-items: center; justify-content: center;
transition: all 0.2s var(--ease);
padding: 0; cursor: pointer;
}
.btn-glass-icon:hover {
background: rgba(139,92,246,0.15);
border-color: rgba(139,92,246,0.3);
color: white;
transform: translateY(-1px);
}
/* ===========================
Page Layout
=========================== */
.page-center {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 100px 1.5rem 3rem;
position: relative; z-index: 1;
}
.hero-wrapper { .hero-wrapper {
max-width: 900px; width: 100%; max-width: 840px;
width: 100%; text-align: center;
padding: 0 1rem; }
/* ===========================
Hero Typography
=========================== */
.eyebrow {
display: inline-flex; align-items: center; gap: 8px;
font-size: 0.75rem; font-weight: 500;
letter-spacing: 0.18em; text-transform: uppercase;
color: var(--primary-light);
margin-bottom: 1.25rem;
}
.eyebrow-dot {
width: 6px; height: 6px; border-radius: 50%;
background: var(--primary-light);
box-shadow: 0 0 8px var(--primary-light);
animation: dotPulse 2s ease-in-out infinite;
}
@keyframes dotPulse {
0%,100% { opacity:1; transform:scale(1); }
50% { opacity:0.5; transform:scale(0.7); }
} }
.hero-title { .hero-title {
font-size: 4rem; font-size: clamp(2.8rem, 7vw, 5rem);
font-weight: 700; font-weight: 700;
background: linear-gradient(to right, #fff, var(--accent)); line-height: 1.05;
letter-spacing: -0.04em;
background: linear-gradient(135deg, #fff 20%, var(--accent) 80%, var(--primary-light) 100%);
-webkit-background-clip: text; -webkit-background-clip: text;
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
text-shadow: 0 0 30px rgba(139, 92, 246, 0.3); background-clip: text;
margin-bottom: 1rem;
}
.hero-sub {
font-size: 1.05rem;
font-weight: 300;
color: rgba(255,255,255,0.4);
margin-bottom: 2.5rem;
letter-spacing: 0.01em;
} }
@media (max-width: 768px) { /* ===========================
.hero-title { font-size: 2.5rem; } Form / Input
} =========================== */
.form-container { width: 100%; }
.input-row { position: relative; }
/* --- Glass Input --- */
.glass-input-wrapper { .glass-input-wrapper {
background: rgba(0, 0, 0, 0.3); display: flex; align-items: stretch;
backdrop-filter: blur(10px); background: rgba(0,0,0,0.45);
border: 1px solid var(--glass-border); backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--border);
border-radius: 16px; border-radius: 16px;
transition: transform 0.3s, box-shadow 0.3s, border-color 0.3s;
overflow: hidden; overflow: hidden;
transition: transform 0.3s var(--ease), box-shadow 0.3s var(--ease), border-color 0.3s;
position: relative;
}
.glass-input-wrapper::before {
content: '';
position: absolute; inset: 0;
background: linear-gradient(135deg, rgba(255,255,255,0.04) 0%, transparent 60%);
pointer-events: none; border-radius: inherit;
} }
.glass-input-wrapper:focus-within { .glass-input-wrapper:focus-within {
transform: translateY(-2px); transform: translateY(-3px);
box-shadow: 0 15px 40px rgba(139, 92, 246, 0.2); box-shadow: 0 20px 60px rgba(124,58,237,0.18), 0 0 0 1px rgba(139,92,246,0.25);
border-color: var(--primary); border-color: rgba(139,92,246,0.35);
} }
.form-control::placeholder { color: rgba(255, 255, 255, 0.3); } .input-icon {
.form-control:focus { box-shadow: none; } display: flex; align-items: center;
padding: 0 1rem 0 1.4rem;
color: rgba(255,255,255,0.3);
font-size: 1rem;
flex-shrink: 0;
transition: color 0.3s;
}
.btn-action { .url-field {
background: var(--primary); flex: 1;
background: transparent !important;
border: none !important;
color: white !important;
font-family: var(--font);
font-size: 1rem;
padding: 1.15rem 0.5rem;
outline: none !important;
box-shadow: none !important;
min-width: 0;
}
.url-field::placeholder { color: rgba(255,255,255,0.22); }
.start-btn {
display: flex; align-items: center; gap: 8px;
background: var(--primary-mid);
color: white; color: white;
border: none; border: none;
border-radius: 0; padding: 0 1.6rem;
transition: filter 0.2s; font-family: var(--font);
min-width: 120px; font-size: 0.88rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
cursor: pointer;
position: relative;
overflow: hidden;
flex-shrink: 0;
transition: filter 0.15s, background 0.2s;
white-space: nowrap;
} }
.btn-action:hover { .start-btn::before {
filter: brightness(1.2); content: '';
color: white; position: absolute; inset: 0;
background: linear-gradient(to bottom, rgba(255,255,255,0.1), transparent);
pointer-events: none;
}
.start-btn:hover { filter: brightness(1.18); }
.start-btn:active { filter: brightness(0.9); }
.start-arrow { transition: transform 0.2s var(--ease); }
.start-btn:hover .start-arrow { transform: translateX(3px); }
/* Ripple */
.btn-ripple {
position: absolute; inset: 0;
background: radial-gradient(circle at center, rgba(255,255,255,0.15) 0%, transparent 70%);
opacity: 0; pointer-events: none;
transform: scale(0);
transition: transform 0.4s, opacity 0.4s;
}
.start-btn:active .btn-ripple { opacity: 1; transform: scale(1); transition: none; }
/* ===========================
Detection Area
=========================== */
.detection-area {
margin-top: 1.25rem;
opacity: 0;
transform: translateY(-8px);
transition: opacity 0.35s var(--ease), transform 0.35s var(--ease);
}
.detection-area.visible {
opacity: 1;
transform: translateY(0);
} }
/* --- Detection & Options --- */ .platform-badge {
.glass-badge { display: inline-flex; align-items: center; gap: 8px;
background: rgba(255, 255, 255, 0.1); padding: 6px 16px 6px 10px;
backdrop-filter: blur(5px); border-radius: 100px;
border: 1px solid var(--glass-border); background: rgba(139,92,246,0.1);
font-weight: 300; border: 1px solid rgba(139,92,246,0.2);
letter-spacing: 1px; font-size: 0.78rem;
font-weight: 500;
letter-spacing: 0.08em;
color: var(--accent);
text-transform: uppercase;
margin-bottom: 1rem;
transition: background 0.3s, border-color 0.3s, box-shadow 0.3s;
} }
.options-drawer { .platform-pulse {
max-width: 600px; width: 8px; height: 8px; border-radius: 50%;
background: var(--platform-color);
box-shadow: 0 0 8px var(--platform-color);
flex-shrink: 0;
animation: pulseDot 2s ease-in-out infinite;
}
@keyframes pulseDot {
0%,100% { transform:scale(1); opacity:1; }
50% { transform:scale(0.75); opacity:0.6; }
} }
.option-group { .options-container { max-width: 560px; margin: 0 auto; }
animation: slideDown 0.4s ease forwards;
.option-block {
animation: optSlide 0.35s var(--ease) forwards;
}
@keyframes optSlide {
from { opacity:0; transform:translateY(-6px); }
to { opacity:1; transform:translateY(0); }
} }
@keyframes slideDown { .option-col { display: flex; flex-direction: column; gap: 6px; }
from { opacity: 0; transform: translateY(-10px); } .option-label {
to { opacity: 1; transform: translateY(0); } font-size: 0.72rem; font-weight: 500;
letter-spacing: 0.12em; text-transform: uppercase;
color: rgba(255,255,255,0.4);
} }
/* Custom Switch Toggle (MP3/MP4) */ /* Switch toggle */
.switch-toggle { .switch-toggle {
display: flex; display: flex;
background: rgba(0,0,0,0.4); background: rgba(0,0,0,0.5);
border-radius: 8px; border: 1px solid var(--border);
position: relative; border-radius: 10px;
padding: 4px; padding: 4px;
position: relative;
gap: 0;
width: fit-content; width: fit-content;
border: 1px solid var(--glass-border);
} }
.switch-toggle input { display: none; } .switch-toggle input { display: none; }
.switch-toggle label { .switch-toggle label {
padding: 6px 16px; padding: 5px 16px;
font-size: 0.82rem;
font-weight: 500;
color: rgba(255,255,255,0.45);
cursor: pointer; cursor: pointer;
z-index: 2; z-index: 2;
font-size: 0.9rem; position: relative;
color: rgba(255,255,255,0.7); transition: color 0.2s;
transition: color 0.3s; user-select: none;
} }
.toggle-bg { #format-mp3:checked + label,
#format-mp4:checked + label { color: white; }
.toggle-pill {
position: absolute; position: absolute;
top: 4px; left: 4px; top: 4px; left: 4px;
height: calc(100% - 8px); height: calc(100% - 8px);
width: calc(50% - 4px); /* Approximation */ background: var(--primary-mid);
background: var(--primary); border-radius: 7px;
border-radius: 6px;
transition: transform 0.3s cubic-bezier(0.4, 0.0, 0.2, 1);
z-index: 1; z-index: 1;
box-shadow: 0 0 12px rgba(139,92,246,0.4);
transition: transform 0.3s var(--ease), width 0.3s var(--ease);
} }
#format-mp3:checked ~ .toggle-bg { transform: translateX(0); width: 63px; } #format-mp3:checked ~ .toggle-pill { transform: translateX(0); width: 58px; }
#format-mp4:checked ~ .toggle-bg { transform: translateX(100%) translateX(6px); width: 63px; } #format-mp4:checked ~ .toggle-pill { transform: translateX(63px); width: 58px; }
#format-mp3:checked + label { color: white; }
#format-mp4:checked + label { color: white; }
/* Glass select */
.glass-select { .glass-select {
background: rgba(0,0,0,0.4); background: rgba(0,0,0,0.5);
border: 1px solid var(--glass-border); border: 1px solid var(--border);
color: white; color: white;
font-family: var(--font);
font-size: 0.83rem;
padding: 6px 30px 6px 10px;
border-radius: 8px; border-radius: 8px;
font-size: 0.9rem;
cursor: pointer; cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='rgba(255,255,255,0.35)' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
transition: border-color 0.2s;
outline: none;
} }
.glass-select:focus { .glass-select:focus { border-color: var(--primary-mid); }
background: rgba(0,0,0,0.6); .glass-select option { background: #12091f; }
color: white;
box-shadow: none; /* Codec toggle */
border-color: var(--primary); .codec-row {
display: inline-flex; align-items: center; gap: 12px;
background: rgba(0,0,0,0.3);
border: 1px solid var(--border);
border-radius: 12px;
padding: 8px 16px;
}
.toggle-switch { display: inline-flex; align-items: center; cursor: pointer; position: relative; }
.ts-input { display: none; }
.ts-track {
width: 40px; height: 22px;
background: rgba(255,255,255,0.12);
border-radius: 11px;
position: relative;
transition: background 0.25s;
}
.ts-thumb {
position: absolute;
top: 3px; left: 3px;
width: 16px; height: 16px;
border-radius: 50%;
background: white;
box-shadow: 0 1px 4px rgba(0,0,0,0.4);
transition: transform 0.25s var(--spring);
}
.ts-input:checked ~ .ts-track { background: var(--primary-mid); }
.ts-input:checked ~ .ts-track .ts-thumb { transform: translateX(18px); }
/* ===========================
Error
=========================== */
.error-alert {
margin-top: 1rem;
padding: 0.75rem 1rem;
background: rgba(239,68,68,0.1);
border: 1px solid rgba(239,68,68,0.25);
border-radius: 10px;
color: #fca5a5;
font-size: 0.88rem;
text-align: left;
animation: alertAnim 0.3s var(--ease);
}
@keyframes alertAnim {
0%,100% { transform: translateX(0); }
20% { transform: translateX(-5px); }
40% { transform: translateX(5px); }
60% { transform: translateX(-3px); }
80% { transform: translateX(3px); }
} }
.custom-switch:checked { /* ===========================
background-color: var(--primary); Processing View
border-color: var(--primary); =========================== */
.process-view {
width: 100%; max-width: 640px;
margin: 2rem auto 0;
text-align: left;
} }
.process-header {
/* --- Processing & Terminal --- */
.glass-progress {
height: 6px;
background: rgba(255,255,255,0.1);
border-radius: 3px;
overflow: hidden;
}
.bg-primary-gradient {
background: linear-gradient(90deg, var(--primary), var(--accent));
box-shadow: 0 0 10px var(--primary);
transition: width 0.3s ease;
}
.terminal-window {
background: rgba(10, 10, 15, 0.85); /* Very dark */
border: 1px solid rgba(255,255,255,0.1);
border-radius: 8px;
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
height: 200px;
display: flex; display: flex;
flex-direction: column; justify-content: space-between;
align-items: center;
margin-bottom: 0.6rem;
}
.status-msg {
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.18em;
color: var(--accent);
text-transform: uppercase;
}
.progress-pct {
font-family: var(--mono);
font-size: 0.78rem;
color: rgba(255,255,255,0.4);
letter-spacing: 0.05em;
}
/* Progress track */
.progress-track {
height: 3px;
background: rgba(255,255,255,0.07);
border-radius: 2px;
overflow: hidden;
margin-bottom: 1rem;
}
.progress-fill {
height: 100%;
border-radius: 2px;
background: linear-gradient(90deg, var(--primary), var(--primary-light), var(--accent));
background-size: 200% 100%;
animation: shimmerBar 2s linear infinite;
transition: width 0.4s var(--ease);
position: relative;
}
.progress-fill::after {
content: '';
position: absolute; top: 0; right: 0; bottom: 0;
width: 30px;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.5));
}
@keyframes shimmerBar {
from { background-position: 0% 0%; }
to { background-position: 200% 0%; }
}
/* Terminal */
.terminal-window {
background: rgba(3,2,10,0.92);
border: 1px solid rgba(139,92,246,0.12);
border-radius: 10px;
overflow: hidden;
height: 220px;
display: flex; flex-direction: column;
box-shadow: 0 20px 60px rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.03);
position: relative;
}
.terminal-window::before {
content: '';
position: absolute; top: 0; left: 0; right: 0;
height: 1px;
background: linear-gradient(90deg, transparent 0%, rgba(139,92,246,0.4) 50%, transparent 100%);
}
.terminal-bar {
display: flex; align-items: center; gap: 6px;
padding: 9px 14px;
border-bottom: 1px solid rgba(255,255,255,0.04);
flex-shrink: 0;
}
.tdot {
width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0;
}
.tdot-r { background: #ff5f57; }
.tdot-y { background: #febc2e; }
.tdot-g { background: #28c840; }
.tbar-label {
font-family: var(--mono);
font-size: 0.66rem;
color: rgba(255,255,255,0.25);
margin-left: 6px;
letter-spacing: 0.05em;
} }
.dot { width: 10px; height: 10px; border-radius: 50%; }
.terminal-body { .terminal-body {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
font-family: var(--font-mono); padding: 10px 14px;
opacity: 0.9; font-family: var(--mono);
padding-top: 0.5rem; font-size: 0.7rem;
color: rgba(196,181,253,0.8);
scrollbar-width: thin;
scrollbar-color: rgba(139,92,246,0.2) transparent;
} }
.terminal-body::-webkit-scrollbar { width: 3px; }
.terminal-body::-webkit-scrollbar-thumb { background: rgba(139,92,246,0.25); border-radius: 3px; }
.terminal-line { .terminal-line {
margin-bottom: 4px; line-height: 1.7;
animation: typeIn 0.2s ease forwards; animation: termIn 0.12s var(--ease) both;
opacity: 0;
}
.terminal-line.is-real { color: rgba(196,181,253,0.9); }
.terminal-line.is-success { color: rgba(134,239,172,0.9); }
.terminal-line.is-error { color: rgba(248,113,113,0.9); }
@keyframes termIn {
from { opacity:0; transform:translateX(-4px); }
to { opacity:1; transform:translateX(0); }
}
.term-cursor {
display: inline-block;
width: 6px; height: 12px;
background: var(--accent);
vertical-align: middle;
margin-left: 2px;
animation: cursorBlink 1s step-end infinite;
}
@keyframes cursorBlink {
0%,100% { opacity:1; }
50% { opacity:0; }
} }
@keyframes typeIn { from { opacity: 0; transform: translateX(-5px); } to { opacity: 1; transform: translateX(0); } }
/* --- Result View --- */ /* ===========================
Result View
=========================== */
.result-view {
margin-top: 2rem;
display: flex; justify-content: center;
}
.result-card { .result-card {
background: rgba(255,255,255,0.05); background: rgba(8,6,20,0.85);
backdrop-filter: blur(15px); backdrop-filter: blur(24px);
border: 1px solid rgba(255,255,255,0.1); border: 1px solid rgba(139,92,246,0.18);
box-shadow: 0 20px 50px rgba(0,0,0,0.5); border-radius: 24px;
padding: 2.5rem 3rem;
text-align: center;
max-width: 380px; width: 100%;
box-shadow:
0 30px 80px rgba(0,0,0,0.5),
0 0 60px rgba(124,58,237,0.08);
animation: cardIn 0.55s var(--spring) forwards;
} }
.btn-primary-glow { @keyframes cardIn {
background: var(--primary); from { opacity:0; transform:scale(0.88) translateY(16px); }
color: white; to { opacity:1; transform:scale(1) translateY(0); }
border: none;
box-shadow: 0 0 20px rgba(139, 92, 246, 0.4);
transition: transform 0.2s, box-shadow 0.2s;
}
.btn-primary-glow:hover {
transform: translateY(-2px);
box-shadow: 0 0 30px rgba(139, 92, 246, 0.6);
color: white;
} }
/* --- History Modal --- */ /* Ripple rings */
.glass-modal { .success-ripple { position: relative; display: inline-block; margin-bottom: 1.25rem; }
background: rgba(20, 20, 30, 0.85); .success-ripple::before,
backdrop-filter: blur(15px); .success-ripple::after {
border: 1px solid var(--glass-border); content: '';
box-shadow: 0 0 50px rgba(0,0,0,0.5); position: absolute;
} top: 50%; left: 50%;
.border-bottom-white-10 { border-bottom: 1px solid rgba(255,255,255,0.1); } transform: translate(-50%,-50%) scale(1);
.border-top-white-10 { border-top: 1px solid rgba(255,255,255,0.1); }
#history-table a { color: var(--accent); text-decoration: none; }
#history-table a:hover { text-decoration: underline; }
/* --- Utility Classes --- */
.btn-glass-icon {
background: rgba(255,255,255,0.1);
color: white;
border: 1px solid transparent;
border-radius: 50%; border-radius: 50%;
width: 45px; height: 45px; border: 1px solid rgba(74,222,128,0.3);
transition: all 0.2s; width: 100%; height: 100%;
animation: ripple 2.5s ease-out infinite;
} }
.btn-glass-icon:hover { .success-ripple::after { animation-delay: 1.25s; }
background: rgba(255,255,255,0.2); @keyframes ripple {
0% { transform:translate(-50%,-50%) scale(1); opacity:0.7; }
100% { transform:translate(-50%,-50%) scale(2.8); opacity:0; }
}
.result-icon {
width: 64px; height: 64px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
background: rgba(74,222,128,0.1);
border: 1px solid rgba(74,222,128,0.2);
box-shadow: 0 0 30px rgba(74,222,128,0.12);
font-size: 1.5rem;
color: #4ade80;
animation: iconPop 0.45s var(--spring) 0.2s both;
}
@keyframes iconPop {
from { opacity:0; transform:scale(0.4); }
to { opacity:1; transform:scale(1); }
}
.result-title {
font-size: 1.8rem; font-weight: 700; letter-spacing: -0.03em;
margin-bottom: 0.3rem;
}
.result-sub {
color: rgba(255,255,255,0.4); font-size: 0.9rem;
margin-bottom: 1.75rem;
}
.btn-download {
display: inline-flex; align-items: center; justify-content: center;
gap: 6px;
background: linear-gradient(135deg, var(--primary), var(--primary-mid));
color: white; color: white;
border-color: rgba(255,255,255,0.3); text-decoration: none;
padding: 0.7rem 2.5rem;
border-radius: 100px;
font-weight: 600; font-size: 0.95rem;
box-shadow: 0 0 30px rgba(124,58,237,0.3), 0 4px 12px rgba(0,0,0,0.3);
transition: transform 0.2s var(--ease), box-shadow 0.2s;
border: none; cursor: pointer;
}
.btn-download:hover {
color: white; text-decoration: none;
transform: translateY(-2px);
box-shadow: 0 0 50px rgba(124,58,237,0.45), 0 8px 20px rgba(0,0,0,0.35);
}
.result-retry { margin-top: 1.25rem; }
.btn-retry {
background: none; border: none; cursor: pointer;
color: rgba(255,255,255,0.35); font-size: 0.82rem;
font-family: var(--font);
transition: color 0.2s;
}
.btn-retry:hover { color: rgba(255,255,255,0.65); }
/* ===========================
Animations (fade-in)
=========================== */
.fade-in { animation: fadeUp 0.75s var(--ease) both; opacity: 0; }
.delay-1 { animation-delay: 0.1s; }
.delay-2 { animation-delay: 0.22s; }
.delay-3 { animation-delay: 0.34s; }
@keyframes fadeUp {
from { opacity:0; transform:translateY(22px); }
to { opacity:1; transform:translateY(0); }
} }
.fade-in { animation: fadeIn 0.8s ease forwards; opacity: 0; } /* ===========================
.delay-1 { animation-delay: 0.2s; } History Modal
.delay-2 { animation-delay: 0.4s; } =========================== */
.glass-modal {
background: rgba(8,5,20,0.94);
backdrop-filter: blur(28px);
-webkit-backdrop-filter: blur(28px);
border: 1px solid var(--border);
border-radius: 18px;
overflow: hidden;
box-shadow: 0 40px 100px rgba(0,0,0,0.6);
}
.modal-header {
padding: 1.25rem 1.5rem;
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.modal-title { font-size: 1.05rem; font-weight: 600; color: white; margin: 0; }
.modal-hint { font-size: 0.72rem; color: rgba(255,255,255,0.3); margin: 2px 0 0; }
.modal-footer {
padding: 0.9rem 1.5rem;
border-top: 1px solid rgba(255,255,255,0.06);
display: flex; justify-content: space-between; align-items: center;
}
.modal-footer-hint { font-size: 0.72rem; color: rgba(255,255,255,0.25); }
@keyframes fadeIn { .history-table {
from { opacity: 0; transform: translateY(20px); } width: 100%;
to { opacity: 1; transform: translateY(0); } border-collapse: collapse;
font-size: 0.82rem;
}
.history-table thead tr {
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.history-table th {
padding: 10px 12px;
font-size: 0.68rem; font-weight: 500;
letter-spacing: 0.1em; text-transform: uppercase;
color: rgba(255,255,255,0.3);
}
.history-table tbody tr {
border-bottom: 1px solid rgba(255,255,255,0.04);
transition: background 0.15s;
}
.history-table tbody tr:hover { background: rgba(139,92,246,0.06); }
.history-table td { padding: 10px 12px; color: rgba(255,255,255,0.75); }
.history-table a { color: var(--accent); text-decoration: none; transition: color 0.15s; }
.history-table a:hover { color: white; }
.history-row-enter { animation: rowIn 0.3s var(--ease) both; }
@keyframes rowIn {
from { opacity:0; transform:translateX(-8px); }
to { opacity:1; transform:translateX(0); }
} }
.glass-alert { .btn-clear-history {
background: rgba(220, 38, 38, 0.2); background: rgba(239,68,68,0.08);
border: 1px solid rgba(220, 38, 38, 0.5); border: 1px solid rgba(239,68,68,0.2);
backdrop-filter: blur(5px); color: rgba(248,113,113,0.8);
padding: 5px 14px;
border-radius: 100px;
font-size: 0.78rem;
cursor: pointer;
transition: all 0.2s;
font-family: var(--font);
}
.btn-clear-history:hover {
background: rgba(239,68,68,0.15);
border-color: rgba(239,68,68,0.4);
color: #fca5a5;
}
/* ===========================
Scrollbar
=========================== */
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(139,92,246,0.25); border-radius: 4px; }
/* ===========================
Responsive
=========================== */
@media (max-width: 640px) {
.hero-title { font-size: 2.4rem; }
.start-btn { padding: 0 1rem; }
.start-label { display: none; }
.result-card { padding: 2rem 1.5rem; }
.page-center { padding-top: 80px; }
} }
+130 -113
View File
@@ -3,158 +3,172 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>unknownMedien.dl</title> <title>unknownMedien.dl</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" /> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" />
<!-- Google Fonts --> <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;700&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
<!-- Custom CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head> </head>
<body> <body>
<!-- Animated Background --> <!-- Background layers -->
<div class="bg-gradient-animate"></div> <div class="bg-base"></div>
<div class="bg-mesh"></div>
<div class="bg-glow bg-glow-1"></div> <div class="bg-glow bg-glow-1"></div>
<div class="bg-glow bg-glow-2"></div> <div class="bg-glow bg-glow-2"></div>
<div class="bg-glow bg-glow-3"></div>
<div class="bg-noise"></div>
<!-- Header / Nav --> <!-- Floating particles (CSS-animated) -->
<nav class="navbar fixed-top w-100 p-4 d-flex justify-content-between align-items-center z-3"> <div class="particles" aria-hidden="true">
<div class="brand fw-bold text-white fs-4 tracking-wide"> <span class="particle p1"></span>
unknownMedien<span class="text-primary-light">.dl</span> <span class="particle p2"></span>
<span class="particle p3"></span>
<span class="particle p4"></span>
<span class="particle p5"></span>
<span class="particle p6"></span>
</div>
<!-- Navbar -->
<nav class="navbar fixed-top w-100 px-4 py-3 d-flex justify-content-between align-items-center">
<div class="brand">
unknown<span class="brand-accent">Medien</span><span class="brand-dot">.dl</span>
</div> </div>
<button class="btn btn-glass-icon" data-bs-toggle="modal" data-bs-target="#historyModal" title="History"> <button class="btn btn-glass-icon" data-bs-toggle="modal" data-bs-target="#historyModal" title="History">
<i class="fas fa-history"></i> <i class="fas fa-clock-rotate-left"></i>
</button> </button>
</nav> </nav>
<!-- Main Content (Hero) --> <!-- Main -->
<main class="container-fluid min-vh-100 d-flex flex-column justify-content-center align-items-center position-relative z-2"> <main class="page-center">
<div class="hero-wrapper">
<div class="hero-wrapper w-100 text-center"> <!-- Eyebrow -->
<div class="eyebrow fade-in">
<span class="eyebrow-dot"></span>
<span>Media Downloader</span>
</div>
<!-- Headline --> <!-- Title -->
<h1 class="hero-title mb-3 fade-in">Media Downloader</h1> <h1 class="hero-title fade-in delay-1">
<p class="hero-subtitle text-white-50 mb-5 fade-in delay-1"> Download<br>anything.
Simply paste your link. We handle the rest. </h1>
<p class="hero-sub fade-in delay-2">
Paste your link — we'll handle the rest.
</p> </p>
<!-- Form Area --> <!-- Form -->
<div class="form-container fade-in delay-2"> <div class="form-container fade-in delay-3">
<form id="upload-form"> <form id="upload-form" autocomplete="off">
<input type="hidden" name="platform" id="input-platform" value="SoundCloud"> <input type="hidden" name="platform" id="input-platform" value="SoundCloud">
<!-- Main Input --> <!-- URL Input Row -->
<div class="glass-input-wrapper d-flex align-items-center shadow-lg"> <div class="input-row">
<div class="icon-zone ps-4 text-white-50"> <div class="glass-input-wrapper" id="input-wrapper">
<i class="fas fa-link" id="url-icon"></i> <span class="input-icon" id="url-icon"><i class="fas fa-link"></i></span>
</div> <input type="url" class="url-field" id="url" name="url" required
<input type="url" class="form-control bg-transparent border-0 text-white p-4 fs-5" placeholder="https://soundcloud.com/..." />
id="url" name="url" required placeholder="https://..." autocomplete="off"> <button class="start-btn" type="submit" id="submit-button">
<button class="btn btn-action p-4 fw-bold text-uppercase" type="submit" id="submit-button"> <span class="start-label">Start</span>
Start <i class="fas fa-arrow-right ms-2"></i> <i class="fas fa-arrow-right start-arrow"></i>
<span class="btn-ripple"></span>
</button> </button>
</div> </div>
</div>
<!-- Detected Platform & Options Slide-Down --> <!-- Platform detection -->
<div id="detection-area" class="mt-4 transition-all" style="opacity: 0; transform: translateY(-10px);"> <div id="detection-area" class="detection-area">
<div class="platform-badge" id="platform-badge">
<!-- Badge --> <span class="platform-pulse" id="platform-pulse"></span>
<span class="badge glass-badge px-3 py-2 rounded-pill mb-3"> <i id="detected-icon" class="fab fa-soundcloud"></i>
<i id="detected-icon" class="fas fa-check me-2"></i>
<span id="detected-text">SoundCloud detected</span> <span id="detected-text">SoundCloud detected</span>
</span> </div>
<!-- Dynamic Options --> <!-- Options drawer -->
<div id="options-container" class="options-drawer mx-auto text-start text-white"> <div id="options-container" class="options-container">
<!-- YouTube -->
<!-- YouTube Options --> <div id="youtube-options" class="d-none option-block">
<div id="youtube-options" class="d-none option-group"> <div class="d-flex gap-4 justify-content-center flex-wrap">
<div class="row g-4 justify-content-center"> <div class="option-col">
<div class="col-auto"> <div class="option-label">Format</div>
<label class="text-white-50 small text-uppercase fw-bold mb-2 d-block">Format</label>
<div class="switch-toggle"> <div class="switch-toggle">
<input type="radio" name="yt_format" id="format-mp3" value="mp3" checked> <input type="radio" name="yt_format" id="format-mp3" value="mp3" checked>
<label for="format-mp3">MP3</label> <label for="format-mp3">MP3</label>
<input type="radio" name="yt_format" id="format-mp4" value="mp4"> <input type="radio" name="yt_format" id="format-mp4" value="mp4">
<label for="format-mp4">MP4</label> <label for="format-mp4">MP4</label>
<div class="toggle-bg"></div> <div class="toggle-pill"></div>
</div> </div>
</div> </div>
<div class="col-auto" id="quality-wrapper"> <div class="option-col" id="quality-wrapper">
<label class="text-white-50 small text-uppercase fw-bold mb-2 d-block">Quality</label> <div class="option-label">Quality</div>
<select class="form-select glass-select" id="mp3_bitrate" name="mp3_bitrate"> <select class="glass-select" id="mp3_bitrate" name="mp3_bitrate">
<option>Best</option><option selected>192k</option><option>128k</option> <option>Best</option>
<option selected>192k</option>
<option>128k</option>
<option>64k</option>
</select> </select>
<select class="form-select glass-select d-none" id="mp4_quality" name="mp4_quality"> <select class="glass-select d-none" id="mp4_quality" name="mp4_quality">
<option selected>Best</option><option>Medium</option><option>Low</option> <option selected>Best</option>
<option>Medium (~720p)</option>
<option>Low (~480p)</option>
</select> </select>
</div> </div>
</div> </div>
</div> </div>
<!-- Codec -->
<!-- Codec Options --> <div id="codec-options-section" class="d-none option-block text-center">
<div id="codec-options-section" class="d-none option-group mt-3 text-center"> <div class="codec-row">
<div class="d-inline-flex align-items-center glass-panel px-3 py-2 rounded"> <span class="option-label me-3">H.264 Compatibility</span>
<span class="text-white-50 small me-3">Compatibility (H.264)</span> <label class="toggle-switch">
<div class="form-check form-switch m-0"> <input class="ts-input" type="checkbox" role="switch" id="codec-switch">
<input class="form-check-input custom-switch" type="checkbox" role="switch" id="codec-switch"> <span class="ts-track"><span class="ts-thumb"></span></span>
<input type="hidden" name="codec_preference" id="codec_preference" value="original"> <input type="hidden" name="codec_preference" id="codec_preference" value="original">
</label>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> <!-- Error -->
</div> <div id="error-message" class="error-alert d-none" role="alert"></div>
<!-- Error Alert -->
<div id="error-message" class="alert glass-alert mt-4 d-none text-danger fw-bold" role="alert"></div>
</form> </form>
</div> </div>
<!-- Processing / Terminal View (Hidden initially) --> <!-- Processing view -->
<div id="process-view" class="d-none w-100 max-w-lg mx-auto mt-5"> <div id="process-view" class="d-none process-view">
<div class="process-header">
<!-- Main Status --> <h3 id="status-message" class="status-msg">INITIALIZING...</h3>
<h3 id="status-message" class="text-white fw-bold mb-3 tracking-wide">INITIALIZING...</h3> <span id="progress-pct" class="progress-pct">0%</span>
<!-- Progress Bar -->
<div class="progress glass-progress mb-4">
<div id="progress-bar" class="progress-bar bg-primary-gradient" style="width: 0%"></div>
</div> </div>
<div class="progress-track">
<!-- CLI Log Window --> <div id="progress-bar" class="progress-fill" style="width:0%"></div>
<div class="terminal-window glass-panel text-start p-3 font-monospace small">
<div class="terminal-header d-flex gap-2 mb-2 opacity-50">
<div class="dot bg-danger"></div>
<div class="dot bg-warning"></div>
<div class="dot bg-success"></div>
<span class="ms-2">worker@unknown-dl:~# process_task</span>
</div> </div>
<div id="pseudo-log-content" class="terminal-body text-primary-light"> <div class="terminal-window">
<!-- Logs typing here --> <div class="terminal-bar">
<span class="tdot tdot-r"></span>
<span class="tdot tdot-y"></span>
<span class="tdot tdot-g"></span>
<span class="tbar-label">worker@unknown-dl ~ process</span>
</div> </div>
<div id="pseudo-log-content" class="terminal-body"></div>
</div> </div>
</div> </div>
<!-- Result View --> <!-- Result view -->
<div id="result-view" class="d-none mt-5"> <div id="result-view" class="d-none result-view">
<div class="result-card glass-panel p-5 d-inline-block rounded-4"> <div class="result-card">
<div class="icon-circle bg-success-soft mb-3 mx-auto"> <div class="result-icon-wrap success-ripple">
<i class="fas fa-check text-success fs-2"></i> <div class="result-icon">
<i class="fas fa-check"></i>
</div> </div>
<h2 class="text-white fw-bold mb-1">Done!</h2> </div>
<p class="text-white-50 mb-4">Your file is ready.</p> <h2 class="result-title">Done!</h2>
<p class="result-sub">Your file is ready to download.</p>
<a id="result-url" href="#" target="_blank" class="btn btn-primary-glow btn-lg px-5 rounded-pill fw-bold"> <a id="result-url" href="#" target="_blank" class="btn-download">
<i class="fas fa-download me-2"></i> Download <i class="fas fa-download me-2"></i>Download
</a> </a>
<div class="result-retry">
<div class="mt-4"> <button class="btn-retry" onclick="location.reload()">
<button class="btn btn-link text-white-50 text-decoration-none btn-sm" onclick="location.reload()"> <i class="fas fa-rotate-left me-1"></i>Convert another
<i class="fas fa-redo me-1"></i> Convert another
</button> </button>
</div> </div>
</div> </div>
@@ -166,32 +180,35 @@
<!-- History Modal --> <!-- History Modal -->
<div class="modal fade" id="historyModal" tabindex="-1" aria-hidden="true"> <div class="modal fade" id="historyModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered"> <div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content glass-modal border-0"> <div class="modal-content glass-modal">
<div class="modal-header border-bottom-white-10"> <div class="modal-header">
<h5 class="modal-title text-white fw-bold">History (Local)</h5> <div>
<h5 class="modal-title">History</h5>
<p class="modal-hint">Stored locally in your browser · 7 days</p>
</div>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button> <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div> </div>
<div class="modal-body p-0"> <div class="modal-body p-0">
<div class="table-responsive" style="max-height: 50vh;"> <div class="table-responsive" style="max-height: 55vh;">
<table class="table table-dark table-hover mb-0 bg-transparent align-middle" id="history-table"> <table class="history-table" id="history-table">
<thead> <thead>
<tr class="text-white-50 small text-uppercase"> <tr>
<th class="ps-4">Time</th> <th class="ps-4">Time</th>
<th class="text-center">Type</th> <th>Platform</th>
<th>Title</th> <th>Title</th>
<th class="text-center">Source</th> <th class="text-center">Source</th>
<th class="text-end pe-4">Download</th> <th class="text-end pe-4">File</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody></tbody>
<!-- JS Fills this -->
</tbody>
</table> </table>
</div> </div>
</div> </div>
<div class="modal-footer border-top-white-10 justify-content-between"> <div class="modal-footer">
<button id="clear-history-button" class="btn btn-outline-danger btn-sm rounded-pill">Clear all</button> <button id="clear-history-button" class="btn-clear-history">
<small class="text-white-50">Saved in browser for 7 days</small> <i class="fas fa-trash-can me-1"></i>Clear all
</button>
<span class="modal-footer-hint">Your data never leaves this browser</span>
</div> </div>
</div> </div>
</div> </div>