Update 2025-04-24_11:44:19
This commit is contained in:
429
static/app.js
Normal file
429
static/app.js
Normal file
@ -0,0 +1,429 @@
|
||||
// app.js — Frontend upload + minimal native player logic with slide-in and pulse effect
|
||||
|
||||
import { playBeep } from "./sound.js";
|
||||
|
||||
// 🔔 Minimal toast helper so calls to showToast() don’t fail
|
||||
function showToast(msg) {
|
||||
const toast = document.createElement("div");
|
||||
toast.className = "toast";
|
||||
toast.textContent = msg;
|
||||
toast.style.position = "fixed";
|
||||
toast.style.bottom = "1.5rem";
|
||||
toast.style.left = "50%";
|
||||
toast.style.transform = "translateX(-50%)";
|
||||
toast.style.background = "#333";
|
||||
toast.style.color = "#fff";
|
||||
toast.style.padding = "0.6em 1.2em";
|
||||
toast.style.borderRadius = "6px";
|
||||
toast.style.boxShadow = "0 2px 6px rgba(0,0,0,.2)";
|
||||
toast.style.zIndex = 9999;
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 4000);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
// Guest vs. logged-in toggling is now handled by dashboard.js
|
||||
// --- Public profile view logic ---
|
||||
function showProfilePlayerFromUrl() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const profileUid = params.get("profile");
|
||||
if (profileUid) {
|
||||
const mePage = document.getElementById("me-page");
|
||||
if (mePage) {
|
||||
document.querySelectorAll("main > section").forEach(sec => sec.hidden = sec.id !== "me-page");
|
||||
// Hide upload/delete/copy-url controls for guest view
|
||||
const uploadArea = document.getElementById("upload-area");
|
||||
if (uploadArea) uploadArea.hidden = true;
|
||||
const copyUrlBtn = document.getElementById("copy-url");
|
||||
if (copyUrlBtn) copyUrlBtn.style.display = "none";
|
||||
const deleteBtn = document.getElementById("delete-account");
|
||||
if (deleteBtn) deleteBtn.style.display = "none";
|
||||
// Update heading and description for guest view
|
||||
const meHeading = document.querySelector("#me-page h2");
|
||||
if (meHeading) meHeading.textContent = `${profileUid}'s Stream 🎙️`;
|
||||
const meDesc = document.querySelector("#me-page p");
|
||||
if (meDesc) meDesc.textContent = `This is ${profileUid}'s public stream.`;
|
||||
// Load playlist for the given profileUid
|
||||
loadProfilePlaylist(profileUid);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Run on popstate (SPA navigation and browser back/forward)
|
||||
window.addEventListener('popstate', showProfilePlayerFromUrl);
|
||||
|
||||
async function loadProfilePlaylist(uid) {
|
||||
const meAudio = document.getElementById("me-audio");
|
||||
if (!meAudio) return;
|
||||
const resp = await fetch(`/user-files/${encodeURIComponent(uid)}`);
|
||||
const data = await resp.json();
|
||||
if (!data.files || !Array.isArray(data.files) || data.files.length === 0) {
|
||||
meAudio.src = "";
|
||||
return;
|
||||
}
|
||||
// Shuffle playlist
|
||||
function shuffle(array) {
|
||||
for (let i = array.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[array[i], array[j]] = [array[j], array[i]];
|
||||
}
|
||||
return array;
|
||||
}
|
||||
window.mePlaylist = shuffle(data.files.map(f => `/audio/${uid}/${f}`));
|
||||
window.mePlaylistIdx = 0;
|
||||
const newSrc = window.mePlaylist[window.mePlaylistIdx];
|
||||
meAudio.src = newSrc;
|
||||
meAudio.load();
|
||||
meAudio.play().catch(() => {/* autoplay may be blocked, ignore */});
|
||||
}
|
||||
window.loadProfilePlaylist = loadProfilePlaylist;
|
||||
|
||||
// --- Playlist for #me-page ---
|
||||
const mePageLink = document.getElementById("show-me");
|
||||
const meAudio = document.getElementById("me-audio");
|
||||
const copyUrlBtn = document.getElementById("copy-url");
|
||||
if (copyUrlBtn) copyUrlBtn.onclick = () => {
|
||||
const uid = localStorage.getItem("uid");
|
||||
if (uid) {
|
||||
const streamUrl = `${window.location.origin}/stream/${encodeURIComponent(uid)}`;
|
||||
navigator.clipboard.writeText(streamUrl);
|
||||
showToast(`Copied your stream URL: ${streamUrl}`);
|
||||
} else {
|
||||
showToast("No user stream URL available");
|
||||
}
|
||||
};
|
||||
let mePlaylist = [];
|
||||
let mePlaylistIdx = 0;
|
||||
// Playlist UI is hidden, so do not render
|
||||
const mePrevBtn = document.getElementById("me-prev");
|
||||
if (mePrevBtn) mePrevBtn.style.display = "none";
|
||||
const meNextBtn = document.getElementById("me-next");
|
||||
if (meNextBtn) meNextBtn.style.display = "none";
|
||||
|
||||
async function loadUserPlaylist() {
|
||||
const uid = localStorage.getItem("uid");
|
||||
if (!uid) return;
|
||||
const resp = await fetch(`/user-files/${encodeURIComponent(uid)}`);
|
||||
const data = await resp.json();
|
||||
if (!data.files || !Array.isArray(data.files) || data.files.length === 0) {
|
||||
meAudio.src = "";
|
||||
return;
|
||||
}
|
||||
// Shuffle playlist
|
||||
function shuffle(array) {
|
||||
for (let i = array.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[array[i], array[j]] = [array[j], array[i]];
|
||||
}
|
||||
return array;
|
||||
}
|
||||
mePlaylist = shuffle(data.files.map(f => `/audio/${uid}/${f}`));
|
||||
mePlaylistIdx = 0;
|
||||
const newSrc = mePlaylist[mePlaylistIdx];
|
||||
const prevSrc = meAudio.src;
|
||||
const wasPlaying = !meAudio.paused && !meAudio.ended && meAudio.currentTime > 0;
|
||||
const fullNewSrc = window.location.origin + newSrc;
|
||||
if (prevSrc !== fullNewSrc) {
|
||||
meAudio.src = newSrc;
|
||||
meAudio.load();
|
||||
} // else: do nothing, already loaded
|
||||
// Don't call load() if already playing the correct file
|
||||
// Don't call load() redundantly
|
||||
// Don't set src redundantly
|
||||
// This prevents DOMException from fetch aborts
|
||||
}
|
||||
|
||||
if (mePageLink && meAudio) {
|
||||
mePageLink.addEventListener("click", async () => {
|
||||
await loadUserPlaylist();
|
||||
// Start playback from current index
|
||||
if (mePlaylist.length > 0) {
|
||||
const newSrc = mePlaylist[mePlaylistIdx];
|
||||
const prevSrc = meAudio.src;
|
||||
const fullNewSrc = window.location.origin + newSrc;
|
||||
if (prevSrc !== fullNewSrc) {
|
||||
meAudio.src = newSrc;
|
||||
meAudio.load();
|
||||
}
|
||||
meAudio.play();
|
||||
}
|
||||
});
|
||||
meAudio.addEventListener("ended", () => {
|
||||
if (mePlaylist.length > 1) {
|
||||
mePlaylistIdx = (mePlaylistIdx + 1) % mePlaylist.length;
|
||||
meAudio.src = mePlaylist[mePlaylistIdx];
|
||||
meAudio.load();
|
||||
meAudio.play();
|
||||
} else if (mePlaylist.length === 1) {
|
||||
// Only one file: restart
|
||||
meAudio.currentTime = 0;
|
||||
meAudio.load();
|
||||
meAudio.play();
|
||||
}
|
||||
});
|
||||
|
||||
// Detect player stop and random play a new track
|
||||
meAudio.addEventListener("pause", () => {
|
||||
// Only trigger if playback reached the end and playlist has more than 1 track
|
||||
if (meAudio.ended && mePlaylist.length > 1) {
|
||||
let nextIdx;
|
||||
do {
|
||||
nextIdx = Math.floor(Math.random() * mePlaylist.length);
|
||||
} while (nextIdx === mePlaylistIdx && mePlaylist.length > 1);
|
||||
mePlaylistIdx = nextIdx;
|
||||
meAudio.currentTime = 0;
|
||||
meAudio.src = mePlaylist[mePlaylistIdx];
|
||||
meAudio.load();
|
||||
meAudio.play();
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
|
||||
const deleteBtn = document.getElementById("delete-account");
|
||||
if (deleteBtn) deleteBtn.onclick = async () => {
|
||||
if (!confirm("Are you sure you want to delete your account and all uploaded audio?")) return;
|
||||
const res = await fetch("/delete-account", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ uid })
|
||||
});
|
||||
if (res.ok) {
|
||||
showToast("✅ Account deleted");
|
||||
localStorage.removeItem("uid");
|
||||
setTimeout(() => window.location.reload(), 2000);
|
||||
} else {
|
||||
const msg = (await res.json()).detail || res.status;
|
||||
showToast("❌ Delete failed: " + msg);
|
||||
}
|
||||
};
|
||||
|
||||
const fadeAllSections = () => {
|
||||
const uid = localStorage.getItem('uid');
|
||||
document.querySelectorAll("main > section").forEach(section => {
|
||||
// Always keep upload-area visible for logged-in users
|
||||
if (uid && section.id === 'upload-area') return;
|
||||
if (!section.hidden) {
|
||||
section.classList.add("fade-out");
|
||||
setTimeout(() => {
|
||||
section.classList.remove("fade-out");
|
||||
section.hidden = true;
|
||||
}, 300);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const dropzone = document.getElementById("upload-area");
|
||||
dropzone.setAttribute("aria-label", "Upload area. Click or drop an audio file to upload.");
|
||||
const fileInput = document.getElementById("fileInput");
|
||||
const fileInfo = document.createElement("div");
|
||||
fileInfo.id = "file-info";
|
||||
fileInfo.style.textAlign = "center";
|
||||
fileInput.parentNode.insertBefore(fileInfo, fileInput.nextSibling);
|
||||
const streamInfo = document.getElementById("stream-info");
|
||||
const streamUrlEl = document.getElementById("streamUrl");
|
||||
|
||||
const status = document.getElementById("status");
|
||||
const spinner = document.getElementById("spinner");
|
||||
|
||||
const uid = localStorage.getItem("uid");
|
||||
const uidTime = parseInt(localStorage.getItem("uid_time"), 10);
|
||||
const now = Date.now();
|
||||
// Hide register button if logged in
|
||||
const registerBtn = document.getElementById("show-register");
|
||||
if (uid && localStorage.getItem("confirmed_uid") === uid && uidTime && (now - uidTime) < 3600000) {
|
||||
if (registerBtn) registerBtn.style.display = "none";
|
||||
} else {
|
||||
if (registerBtn) registerBtn.style.display = "";
|
||||
}
|
||||
if (!uid || !uidTime || (now - uidTime) > 3600000) {
|
||||
localStorage.removeItem("uid");
|
||||
localStorage.removeItem("confirmed_uid");
|
||||
localStorage.removeItem("uid_time");
|
||||
status.className = "error-toast";
|
||||
status.innerText = "❌ Session expired. Please log in again.";
|
||||
// Add Login or Register button only for this error
|
||||
let loginBtn = document.createElement('button');
|
||||
loginBtn.textContent = 'Login or Register';
|
||||
loginBtn.className = 'login-register-btn';
|
||||
loginBtn.onclick = () => {
|
||||
document.querySelectorAll('main > section').forEach(sec => sec.hidden = sec.id !== 'register-page');
|
||||
};
|
||||
status.appendChild(document.createElement('br'));
|
||||
status.appendChild(loginBtn);
|
||||
// Remove the status div after a short delay so only toast remains
|
||||
setTimeout(() => {
|
||||
if (status.parentNode) status.parentNode.removeChild(status);
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
const confirmed = localStorage.getItem("confirmed_uid");
|
||||
if (!confirmed || uid !== confirmed) {
|
||||
status.className = "error-toast";
|
||||
status.innerText = "❌ Please confirm your account via email first.";
|
||||
showToast(status.innerText);
|
||||
return;
|
||||
}
|
||||
|
||||
let abortController;
|
||||
|
||||
const upload = async (file) => {
|
||||
if (abortController) abortController.abort();
|
||||
abortController = new AbortController();
|
||||
fileInfo.innerText = `📁 ${file.name} • ${(file.size / 1024 / 1024).toFixed(2)} MB`;
|
||||
if (file.size > 100 * 1024 * 1024) {
|
||||
status.className = "error-toast";
|
||||
status.innerText = "❌ Session expired. Please log in again.";
|
||||
// Add Login or Register button only for this error
|
||||
let loginBtn = document.createElement('button');
|
||||
loginBtn.textContent = 'Login or Register';
|
||||
loginBtn.className = 'login-register-btn';
|
||||
loginBtn.onclick = () => {
|
||||
document.querySelectorAll('main > section').forEach(sec => sec.hidden = sec.id !== 'register-page');
|
||||
};
|
||||
status.appendChild(document.createElement('br'));
|
||||
status.appendChild(loginBtn);
|
||||
showToast(status.innerText);
|
||||
return;
|
||||
}
|
||||
spinner.style.display = "block";
|
||||
status.innerHTML = '📡 Uploading…';
|
||||
status.className = "uploading-toast";
|
||||
fileInput.disabled = true;
|
||||
dropzone.classList.add("uploading");
|
||||
const formData = new FormData();
|
||||
formData.append("uid", uid);
|
||||
formData.append("file", file);
|
||||
|
||||
const res = await fetch("/upload", {
|
||||
signal: abortController.signal,
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
let data, parseError;
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch (e) {
|
||||
parseError = e;
|
||||
}
|
||||
if (!data) {
|
||||
status.className = "error-toast";
|
||||
status.innerText = "❌ Upload failed: " + (parseError && parseError.message ? parseError.message : "Unknown error");
|
||||
showToast(status.innerText);
|
||||
spinner.style.display = "none";
|
||||
fileInput.disabled = false;
|
||||
dropzone.classList.remove("uploading");
|
||||
return;
|
||||
}
|
||||
if (res.ok) {
|
||||
status.className = "success-toast";
|
||||
streamInfo.hidden = false;
|
||||
streamInfo.innerHTML = `
|
||||
<p>Your stream is now live:</p>
|
||||
<audio controls id="me-audio" aria-label="Stream audio player. Your uploaded voice loop plays here.">
|
||||
<p style='font-size: 0.9em; text-align: center;'>🔁 This stream loops forever</p>
|
||||
<source src="${data.stream_url}" type="audio/ogg">
|
||||
</audio>
|
||||
<p><a href="${data.stream_url}" target="_blank" class="button" aria-label="Open your stream in a new tab">Open in external player</a></p>
|
||||
`;
|
||||
const meAudio = document.getElementById("me-audio");
|
||||
meAudio.addEventListener("ended", () => {
|
||||
if (mePlaylist.length > 1) {
|
||||
mePlaylistIdx = (mePlaylistIdx + 1) % mePlaylist.length;
|
||||
meAudio.src = mePlaylist[mePlaylistIdx];
|
||||
meAudio.load();
|
||||
meAudio.play();
|
||||
meUrl.value = mePlaylist[mePlaylistIdx];
|
||||
} else if (mePlaylist.length === 1) {
|
||||
// Only one file: restart
|
||||
meAudio.currentTime = 0;
|
||||
meAudio.load();
|
||||
meAudio.play();
|
||||
}
|
||||
});
|
||||
if (data.quota && data.quota.used_mb !== undefined) {
|
||||
const bar = document.getElementById("quota-bar");
|
||||
const text = document.getElementById("quota-text");
|
||||
const quotaSec = document.getElementById("quota-meter");
|
||||
if (bar && text && quotaSec) {
|
||||
quotaSec.hidden = false;
|
||||
const used = parseFloat(data.quota.used_mb);
|
||||
bar.value = used;
|
||||
bar.max = 100;
|
||||
text.textContent = `${used.toFixed(1)} MB used`;
|
||||
}
|
||||
}
|
||||
spinner.style.display = "none";
|
||||
fileInput.disabled = false;
|
||||
dropzone.classList.remove("uploading");
|
||||
showToast(status.innerText);
|
||||
status.innerText = "✅ Upload successful.";
|
||||
|
||||
playBeep(432, 0.25, "sine");
|
||||
|
||||
setTimeout(() => status.innerText = "", 5000);
|
||||
streamInfo.classList.add("visible", "slide-in");
|
||||
} else {
|
||||
streamInfo.hidden = true;
|
||||
status.className = "error-toast";
|
||||
spinner.style.display = "none";
|
||||
if ((data.detail || data.error || "").includes("music")) {
|
||||
status.innerText = "🎵 Upload rejected: singing or music detected.";
|
||||
} else {
|
||||
status.innerText = `❌ Upload failed: ${data.detail || data.error}`;
|
||||
}
|
||||
showToast(status.innerText);
|
||||
fileInput.value = null;
|
||||
dropzone.classList.remove("uploading");
|
||||
fileInput.disabled = false;
|
||||
streamInfo.classList.remove("visible", "slide-in");
|
||||
}
|
||||
};
|
||||
|
||||
dropzone.addEventListener("click", () => {
|
||||
console.log("[DEBUG] Dropzone clicked");
|
||||
fileInput.click();
|
||||
console.log("[DEBUG] fileInput.click() called");
|
||||
});
|
||||
dropzone.addEventListener("dragover", (e) => {
|
||||
e.preventDefault();
|
||||
dropzone.classList.add("dragover");
|
||||
dropzone.style.transition = "background-color 0.3s ease";
|
||||
});
|
||||
dropzone.addEventListener("dragleave", () => {
|
||||
dropzone.classList.remove("dragover");
|
||||
});
|
||||
dropzone.addEventListener("drop", (e) => {
|
||||
dropzone.classList.add("pulse");
|
||||
setTimeout(() => dropzone.classList.remove("pulse"), 400);
|
||||
e.preventDefault();
|
||||
dropzone.classList.remove("dragover");
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) upload(file);
|
||||
});
|
||||
fileInput.addEventListener("change", (e) => {
|
||||
status.innerText = "";
|
||||
status.className = "";
|
||||
const file = e.target.files[0];
|
||||
if (file) upload(file);
|
||||
});
|
||||
|
||||
document.querySelectorAll('#links a[data-target]').forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const target = link.getAttribute('data-target');
|
||||
// Only hide other sections when not opening #me-page
|
||||
if (target !== 'me-page') fadeAllSections();
|
||||
const section = document.getElementById(target);
|
||||
if (section) {
|
||||
section.hidden = false;
|
||||
section.classList.add("slide-in");
|
||||
section.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
const burger = document.getElementById('burger-toggle');
|
||||
if (burger && burger.checked) burger.checked = false;
|
||||
});
|
||||
});
|
||||
});
|
28
static/auth-ui.js
Normal file
28
static/auth-ui.js
Normal file
@ -0,0 +1,28 @@
|
||||
// static/auth-ui.js — navigation link and back-button handlers
|
||||
import { showOnly } from './router.js';
|
||||
|
||||
// Data-target navigation (e.g., at #links)
|
||||
export function initNavLinks() {
|
||||
const linksContainer = document.getElementById('links');
|
||||
if (!linksContainer) return;
|
||||
linksContainer.addEventListener('click', e => {
|
||||
const a = e.target.closest('a[data-target]');
|
||||
if (!a || !linksContainer.contains(a)) return;
|
||||
e.preventDefault();
|
||||
const target = a.dataset.target;
|
||||
if (target) showOnly(target);
|
||||
const burger = document.getElementById('burger-toggle');
|
||||
if (burger && burger.checked) burger.checked = false;
|
||||
});
|
||||
}
|
||||
|
||||
// Back-button navigation
|
||||
export function initBackButtons() {
|
||||
document.querySelectorAll('a[data-back]').forEach(btn => {
|
||||
btn.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
const target = btn.dataset.back;
|
||||
if (target) showOnly(target);
|
||||
});
|
||||
});
|
||||
}
|
55
static/dashboard.js
Normal file
55
static/dashboard.js
Normal file
@ -0,0 +1,55 @@
|
||||
// dashboard.js — toggle guest vs. user dashboard and reposition streams link
|
||||
|
||||
async function initDashboard() {
|
||||
const uploadArea = document.querySelector('#upload-area');
|
||||
const userDashboard = document.querySelector('#me-page');
|
||||
const meAudio = document.querySelector('#me-audio');
|
||||
const quotaBar = document.querySelector('#quota-bar');
|
||||
const quotaText = document.querySelector('#quota-text');
|
||||
const streamsLink = document.querySelector('#show-streams');
|
||||
const registerLink = document.querySelector('#show-register');
|
||||
|
||||
// Default state: hide both
|
||||
uploadArea.hidden = true;
|
||||
userDashboard.hidden = true;
|
||||
|
||||
const uid = localStorage.getItem('uid');
|
||||
if (!uid) {
|
||||
// Guest: only upload area and move Streams next to Register
|
||||
uploadArea.hidden = false;
|
||||
userDashboard.hidden = true;
|
||||
if (registerLink && streamsLink) {
|
||||
registerLink.parentElement.insertAdjacentElement('afterend', streamsLink.parentElement);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/me/${uid}`);
|
||||
if (!res.ok) throw new Error('Not authorized');
|
||||
const data = await res.json();
|
||||
|
||||
// Logged-in view
|
||||
uploadArea.hidden = false;
|
||||
userDashboard.hidden = false;
|
||||
|
||||
// Set audio source
|
||||
meAudio.src = data.stream_url;
|
||||
// Update quota
|
||||
quotaBar.value = data.quota;
|
||||
quotaText.textContent = `${data.quota} MB used`;
|
||||
|
||||
// Ensure Streams link remains in nav, not moved
|
||||
// (No action needed if static)
|
||||
} catch (e) {
|
||||
console.warn('Dashboard init error, treating as guest:', e);
|
||||
localStorage.removeItem('uid');
|
||||
uploadArea.hidden = false;
|
||||
userDashboard.hidden = true;
|
||||
if (registerLink && streamsLink) {
|
||||
registerLink.parentElement.insertAdjacentElement('afterend', streamsLink.parentElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initDashboard);
|
188
static/index.html
Normal file
188
static/index.html
Normal file
@ -0,0 +1,188 @@
|
||||
<!-- index.html -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🎙️</text></svg>">
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="dicta2stream is a minimalist voice streaming platform for looping your spoken audio anonymously." />
|
||||
<title>dicta2stream</title>
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
<!-- Responsive burger menu display -->
|
||||
<style>
|
||||
#burger-label, #burger-toggle { display: none; }
|
||||
@media (max-width: 959px) {
|
||||
#burger-label { display: block; }
|
||||
section#links { display: none; }
|
||||
#burger-toggle:checked + #burger-label + section#links { display: block; }
|
||||
}
|
||||
@media (min-width: 960px) {
|
||||
section#links { display: block; }
|
||||
}
|
||||
</style>
|
||||
<link rel="modulepreload" href="/static/sound.js" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>dicta2stream 🎙️</h1>
|
||||
<p>Your voice. Your loop. One drop away.</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
|
||||
<section id="upload-area" class="dropzone">
|
||||
<p>🎙 Drag & drop your audio file here<br>or click to browse</p>
|
||||
<input type="file" id="fileInput" accept="audio/*" hidden />
|
||||
</section>
|
||||
|
||||
<div id="spinner" class="spinner">
|
||||
|
||||
</div>
|
||||
<div id="status"></div>
|
||||
|
||||
<section id="stream-info" hidden>
|
||||
<p>Your loop stream:</p>
|
||||
<code id="streamUrl">...</code>
|
||||
<audio controls id="player" loop></audio>
|
||||
<p><button id="delete-account" class="delete-account">🗑️ Delete My Account</button></p>
|
||||
</section>
|
||||
|
||||
<input type="checkbox" id="burger-toggle" hidden>
|
||||
<label for="burger-toggle" id="burger-label" aria-label="Menu">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</label>
|
||||
<section id="links">
|
||||
<p><a href="#" id="show-me" data-target="me-page">Your Stream</a></p>
|
||||
<p><a href="#" id="show-register" data-target="register-page">Login or Register</a></p>
|
||||
<p>
|
||||
<a href="#" id="show-terms" data-target="terms-page">Terms of Service</a> |
|
||||
<a href="#" id="show-imprint" data-target="imprint-page">Imprint</a> |
|
||||
<a href="#" id="show-privacy" data-target="privacy-page">Privacy Policy</a> |
|
||||
<a href="#" id="show-streams" data-target="stream-page">Streams</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="terms-page" hidden>
|
||||
<article>
|
||||
<h2>Terms of Service</h2>
|
||||
<p><em>Last updated: April 18, 2025</em></p>
|
||||
<p>By accessing or using dicta2stream.net (the “Service”), you agree to be bound by these Terms of Service (“Terms”). If you do not agree, do not use the Service.</p>
|
||||
<ul>
|
||||
<li>You must be at least 18 years old to register.</li>
|
||||
<li>UID in localStorage must be uniquely yours.</li>
|
||||
<li>One account per device/IP per 24 hours.</li>
|
||||
</ul>
|
||||
<p>Uploads are limited to <strong>100 MB</strong> and must be <strong>voice only</strong>. Music/singing will be rejected. Your stream will loop publicly and anonymously via Icecast.</p>
|
||||
<p>See full legal terms in the Git repository or request via support@dicta2stream.net.</p>
|
||||
<p><a href="#" data-back="upload-area">← Back</a></p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section id="privacy-page" hidden>
|
||||
<article>
|
||||
<h2>Privacy Policy</h2>
|
||||
<p><em>Last updated: April 18, 2025</em></p>
|
||||
<ul>
|
||||
<li>No cookies. UID is stored locally only.</li>
|
||||
<li>We log IP + UID only for abuse protection and quota enforcement.</li>
|
||||
<li>Uploads are scanned via Whisper+Ollama but not stored as transcripts.</li>
|
||||
<li>Data is never sold. Contact us for account deletion.</li>
|
||||
</ul>
|
||||
<p><a href="#" data-back="upload-area">← Back</a></p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section id="imprint-page" hidden>
|
||||
<article>
|
||||
<h2>Imprint</h2>
|
||||
<p><strong>Andreas Michael Fleckl</strong></p>
|
||||
<p>Johnstrassse 7/6<br>1140 Vienna<br>Austria / Europe</p>
|
||||
<p><strong>Contact:</strong><br>
|
||||
<a href="mailto:Andreas.Fleckl@dicta2stream.net">Andreas.Fleckl@dicta2stream.net</a></p>
|
||||
<p><a href="#" data-back="upload-area">← Back</a></p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section id="stream-page" hidden>
|
||||
<article>
|
||||
<h2>🎧 Public Streams</h2>
|
||||
<ul id="stream-list"><li>Loading...</li></ul>
|
||||
<p><a href="#" data-back="upload-area">← Back</a></p>
|
||||
<p style="margin-top:1.5em;font-size:0.98em;">
|
||||
<a href="#" id="show-terms" data-target="terms-page">Terms of Service</a> |
|
||||
<a href="#" id="show-imprint" data-target="imprint-page">Imprint</a> |
|
||||
<a href="#" id="show-privacy" data-target="privacy-page">Privacy Policy</a>
|
||||
</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section id="register-page" hidden>
|
||||
<article>
|
||||
<h2>Register</h2>
|
||||
<form id="register-form">
|
||||
<p><label>Email<br><input type="email" name="email" required /></label></p>
|
||||
<p><label>Username<br><input type="text" name="user" required /></label></p>
|
||||
<p><button type="submit">Create Account</button></p>
|
||||
</form>
|
||||
<p><small>You’ll receive a magic login link via email. No password required.</small></p>
|
||||
<p><a href="#" data-back="upload-area">← Back</a></p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section id="magic-login-page" hidden>
|
||||
<article>
|
||||
<h2>Magic Login</h2>
|
||||
<p>If you received a magic login link, you're almost in. Click below to confirm your account and activate streaming.</p>
|
||||
<form id="magic-login-form">
|
||||
<div id="magic-error" style="color: #b22222; font-size: 0.9em; display: none; margin-bottom: 1em;"></div>
|
||||
<input type="hidden" name="token" id="magic-token" />
|
||||
<button type="submit">Confirm & Activate</button>
|
||||
</form>
|
||||
<p><a href="#" data-back="upload-area">← Back</a></p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section id="quota-meter" hidden>
|
||||
<p class="quota-meter">Quota: <progress id="quota-bar" value="0" max="100"></progress> <span id="quota-text">0 MB used</span></p>
|
||||
</section>
|
||||
|
||||
<section id="me-page" hidden>
|
||||
<article>
|
||||
<h2>Your Stream 🎙️</h2>
|
||||
<p>This is your personal stream. Only you can upload to it.</p>
|
||||
<audio controls id="me-audio"></audio>
|
||||
<!-- Playlist and URL input hidden as per user request -->
|
||||
<div class="playlist-controls">
|
||||
<button id="me-prev" aria-label="Previous track">⏮️</button>
|
||||
<button id="me-next" aria-label="Next track">⏭️</button>
|
||||
</div>
|
||||
<!-- <ul id="me-playlist" class="playlist"></ul> -->
|
||||
<!-- <p><input id="me-url" readonly class="me-url" /></p> -->
|
||||
<p><button id="copy-url">📋 Copy URL to clipboard</button></p>
|
||||
<p><a href="#" data-back="upload-area">← Back</a></p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>Built for public voice streaming • Opus | Mono | 48 kHz | 60 kbps</p>
|
||||
<p class="footer-hint">Need more space? Contact <a href="mailto:Andreas.Fleckl@dicta2stream.net">Andreas.Fleckl@dicta2stream.net</a></p>
|
||||
<p style="font-size: 0.85em; opacity: 0.65;">Your session expires after 1 hour. Shareable links redirect to homepage.</p>
|
||||
</footer>
|
||||
|
||||
<script type="module" src="/static/dashboard.js"></script>
|
||||
<script type="module" src="/static/app.js"></script>
|
||||
<script type="module">
|
||||
import "/static/nav.js";
|
||||
window.addEventListener("pageshow", () => {
|
||||
const dz = document.querySelector("#upload-area");
|
||||
dz.classList.remove("uploading");
|
||||
const spinner = document.querySelector("#spinner");
|
||||
if (spinner) spinner.style.display = "none";
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
18
static/magic-login.js
Normal file
18
static/magic-login.js
Normal file
@ -0,0 +1,18 @@
|
||||
// static/magic-login.js — handles magic‑link token UI
|
||||
import { showOnly } from './router.js';
|
||||
|
||||
export function initMagicLogin() {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const token = params.get('token');
|
||||
if (token) {
|
||||
const tokenInput = document.getElementById('magic-token');
|
||||
if (tokenInput) tokenInput.value = token;
|
||||
showOnly('magic-login-page');
|
||||
const err = params.get('error');
|
||||
if (err) {
|
||||
const box = document.getElementById('magic-error');
|
||||
box.textContent = decodeURIComponent(err);
|
||||
box.style.display = 'block';
|
||||
}
|
||||
}
|
||||
}
|
147
static/nav.js
Normal file
147
static/nav.js
Normal file
@ -0,0 +1,147 @@
|
||||
// nav.js — lightweight navigation & magic‑link handling
|
||||
|
||||
// fallback toast if app.js not yet loaded
|
||||
if (typeof window.showToast !== "function") {
|
||||
window.showToast = (msg) => alert(msg);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const Router = {
|
||||
sections: Array.from(document.querySelectorAll("main > section")),
|
||||
showOnly(id) {
|
||||
this.sections.forEach(sec => {
|
||||
sec.hidden = sec.id !== id;
|
||||
sec.tabIndex = -1;
|
||||
});
|
||||
localStorage.setItem("last_page", id);
|
||||
const target = document.getElementById(id);
|
||||
if (target) target.focus();
|
||||
},
|
||||
init() {
|
||||
initNavLinks();
|
||||
initBackButtons();
|
||||
initStreamsLoader();
|
||||
initStreamLinks();
|
||||
}
|
||||
};
|
||||
const showOnly = Router.showOnly.bind(Router);
|
||||
|
||||
// Highlight active profile link on browser back/forward navigation
|
||||
function highlightActiveProfileLink() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const profileUid = params.get('profile');
|
||||
const ul = document.getElementById('stream-list');
|
||||
if (!ul) return;
|
||||
ul.querySelectorAll('a.profile-link').forEach(link => {
|
||||
const url = new URL(link.href, window.location.origin);
|
||||
const uidParam = url.searchParams.get('profile');
|
||||
link.classList.toggle('active', uidParam === profileUid);
|
||||
});
|
||||
}
|
||||
window.addEventListener('popstate', highlightActiveProfileLink);
|
||||
|
||||
/* restore last page (unless magic‑link token present) */
|
||||
const params = new URLSearchParams(location.search);
|
||||
const token = params.get("token");
|
||||
if (!token) {
|
||||
const last = localStorage.getItem("last_page");
|
||||
if (last && document.getElementById(last)) showOnly(last);
|
||||
// Highlight active link on initial load
|
||||
highlightActiveProfileLink();
|
||||
}
|
||||
|
||||
/* token → show magic‑login page */
|
||||
if (token) {
|
||||
document.getElementById("magic-token").value = token;
|
||||
showOnly("magic-login-page");
|
||||
const err = params.get("error");
|
||||
if (err) {
|
||||
const box = document.getElementById("magic-error");
|
||||
box.textContent = decodeURIComponent(err);
|
||||
box.style.display = "block";
|
||||
}
|
||||
}
|
||||
|
||||
// Debounce loading and helper for streams list
|
||||
let loadingStreams = false;
|
||||
function renderStreamList(streams) {
|
||||
const ul = document.getElementById("stream-list");
|
||||
if (!ul) return;
|
||||
if (streams.length) {
|
||||
streams.sort();
|
||||
ul.innerHTML = streams.map(uid => `
|
||||
<li><a href="/?profile=${encodeURIComponent(uid)}" class="profile-link">▶ ${uid}</a></li>
|
||||
`).join("");
|
||||
} else {
|
||||
ul.innerHTML = "<li>No active streams.</li>";
|
||||
}
|
||||
// Ensure correct link is active after rendering
|
||||
highlightActiveProfileLink();
|
||||
}
|
||||
|
||||
// Initialize navigation listeners
|
||||
function initNavLinks() {
|
||||
const linksContainer = document.getElementById("links");
|
||||
if (!linksContainer) return;
|
||||
linksContainer.addEventListener("click", e => {
|
||||
const a = e.target.closest("a[data-target]");
|
||||
if (!a || !linksContainer.contains(a)) return;
|
||||
e.preventDefault();
|
||||
const target = a.dataset.target;
|
||||
if (target) showOnly(target);
|
||||
const burger = document.getElementById("burger-toggle");
|
||||
if (burger && burger.checked) burger.checked = false;
|
||||
});
|
||||
}
|
||||
|
||||
function initBackButtons() {
|
||||
document.querySelectorAll('a[data-back]').forEach(btn => {
|
||||
btn.addEventListener("click", e => {
|
||||
e.preventDefault();
|
||||
const target = btn.dataset.back;
|
||||
if (target) showOnly(target);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function initStreamsLoader() {
|
||||
const streamsLink = document.getElementById("show-streams");
|
||||
streamsLink?.addEventListener("click", async e => {
|
||||
e.preventDefault();
|
||||
if (loadingStreams) return;
|
||||
loadingStreams = true;
|
||||
showOnly("stream-page");
|
||||
try {
|
||||
const res = await fetch("/streams");
|
||||
if (!res.ok) throw new Error(`HTTP error ${res.status}`);
|
||||
const data = await res.json();
|
||||
renderStreamList(data.streams || []);
|
||||
} catch {
|
||||
const ul = document.getElementById("stream-list");
|
||||
if (ul) ul.innerHTML = "<li>Error loading stream list</li>";
|
||||
} finally {
|
||||
loadingStreams = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initStreamLinks() {
|
||||
const ul = document.getElementById("stream-list");
|
||||
if (!ul) return;
|
||||
ul.addEventListener("click", e => {
|
||||
const a = e.target.closest("a.profile-link");
|
||||
if (!a || !ul.contains(a)) return;
|
||||
e.preventDefault();
|
||||
const url = new URL(a.href, window.location.origin);
|
||||
const profileUid = url.searchParams.get("profile");
|
||||
if (profileUid && window.location.search !== `?profile=${encodeURIComponent(profileUid)}`) {
|
||||
window.profileNavigationTriggered = true;
|
||||
window.history.pushState({}, '', `/?profile=${encodeURIComponent(profileUid)}`);
|
||||
window.dispatchEvent(new Event("popstate"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize Router
|
||||
Router.init();
|
||||
});
|
15
static/router.js
Normal file
15
static/router.js
Normal file
@ -0,0 +1,15 @@
|
||||
// static/router.js — core routing for SPA navigation
|
||||
export const Router = {
|
||||
sections: Array.from(document.querySelectorAll("main > section")),
|
||||
showOnly(id) {
|
||||
this.sections.forEach(sec => {
|
||||
sec.hidden = sec.id !== id;
|
||||
sec.tabIndex = -1;
|
||||
});
|
||||
localStorage.setItem("last_page", id);
|
||||
const target = document.getElementById(id);
|
||||
if (target) target.focus();
|
||||
}
|
||||
};
|
||||
|
||||
export const showOnly = Router.showOnly.bind(Router);
|
17
static/sound.js
Normal file
17
static/sound.js
Normal file
@ -0,0 +1,17 @@
|
||||
// sound.js — reusable Web Audio beep
|
||||
|
||||
export function playBeep(frequency = 432, duration = 0.2, type = 'sine') {
|
||||
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
|
||||
osc.type = type;
|
||||
osc.frequency.value = frequency;
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
|
||||
gain.gain.setValueAtTime(0.1, ctx.currentTime); // subtle volume
|
||||
osc.start();
|
||||
osc.stop(ctx.currentTime + duration);
|
||||
}
|
76
static/streams-ui.js
Normal file
76
static/streams-ui.js
Normal file
@ -0,0 +1,76 @@
|
||||
// static/streams-ui.js — public streams loader and profile-link handling
|
||||
import { showOnly } from './router.js';
|
||||
|
||||
let loadingStreams = false;
|
||||
|
||||
export function renderStreamList(streams) {
|
||||
const ul = document.getElementById('stream-list');
|
||||
if (!ul) return;
|
||||
if (streams.length) {
|
||||
streams.sort();
|
||||
ul.innerHTML = streams
|
||||
.map(
|
||||
uid => `<li><a href="/?profile=${encodeURIComponent(uid)}" class="profile-link">▶ ${uid}</a></li>`
|
||||
)
|
||||
.join('');
|
||||
} else {
|
||||
ul.innerHTML = '<li>No active streams.</li>';
|
||||
}
|
||||
highlightActiveProfileLink();
|
||||
}
|
||||
|
||||
export function highlightActiveProfileLink() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const profileUid = params.get('profile');
|
||||
const ul = document.getElementById('stream-list');
|
||||
if (!ul) return;
|
||||
ul.querySelectorAll('a.profile-link').forEach(link => {
|
||||
const url = new URL(link.href, window.location.origin);
|
||||
const uidParam = url.searchParams.get('profile');
|
||||
link.classList.toggle('active', uidParam === profileUid);
|
||||
});
|
||||
}
|
||||
|
||||
export function initStreamsLoader() {
|
||||
const streamsLink = document.getElementById('show-streams');
|
||||
streamsLink?.addEventListener('click', async e => {
|
||||
e.preventDefault();
|
||||
if (loadingStreams) return;
|
||||
loadingStreams = true;
|
||||
showOnly('stream-page');
|
||||
try {
|
||||
const res = await fetch('/streams');
|
||||
if (!res.ok) throw new Error(`HTTP error ${res.status}`);
|
||||
const data = await res.json();
|
||||
renderStreamList(data.streams || []);
|
||||
} catch {
|
||||
const ul = document.getElementById('stream-list');
|
||||
if (ul) ul.innerHTML = '<li>Error loading stream list</li>';
|
||||
} finally {
|
||||
loadingStreams = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function initStreamLinks() {
|
||||
const ul = document.getElementById('stream-list');
|
||||
if (!ul) return;
|
||||
ul.addEventListener('click', e => {
|
||||
const a = e.target.closest('a.profile-link');
|
||||
if (!a || !ul.contains(a)) return;
|
||||
e.preventDefault();
|
||||
const url = new URL(a.href, window.location.origin);
|
||||
const profileUid = url.searchParams.get('profile');
|
||||
if (profileUid && window.location.search !== `?profile=${encodeURIComponent(profileUid)}`) {
|
||||
window.profileNavigationTriggered = true;
|
||||
window.history.pushState({}, '', `/?profile=${encodeURIComponent(profileUid)}`);
|
||||
window.dispatchEvent(new Event('popstate'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function initStreamsUI() {
|
||||
initStreamsLoader();
|
||||
initStreamLinks();
|
||||
window.addEventListener('popstate', highlightActiveProfileLink);
|
||||
}
|
519
static/style.css
Normal file
519
static/style.css
Normal file
@ -0,0 +1,519 @@
|
||||
/* style.css — minimal UI styling for dicta2stream */
|
||||
|
||||
.spinner {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.2em;
|
||||
pointer-events: none; /* allow clicks through except for children */
|
||||
}
|
||||
.spinner > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.quota-meter {
|
||||
font-size: 0.9em;
|
||||
margin-top: 0.5em;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.footer-hint {
|
||||
font-size: 0.9em;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.cancel-upload {
|
||||
display: none;
|
||||
margin-top: 0.4em;
|
||||
font-size: 0.95em;
|
||||
background: #b22222;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5em 1.2em;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,.08);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.cancel-upload:hover {
|
||||
background: #e74c3c;
|
||||
}
|
||||
|
||||
.delete-account {
|
||||
margin-top: 1em;
|
||||
background: #ccc;
|
||||
color: black;
|
||||
padding: 0.4em 1em;
|
||||
border-radius: 5px;
|
||||
font-size: 0.9em;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.me-url {
|
||||
width: 100%;
|
||||
font-family: monospace;
|
||||
margin: 0.5em 0;
|
||||
padding: 0.4em;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
button.logout {
|
||||
display: block;
|
||||
margin: 1em auto;
|
||||
padding: 0.4em 1.2em;
|
||||
background: #eee;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
font-size: 0.95em;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
button.logout:hover {
|
||||
background: #ddd;
|
||||
}
|
||||
|
||||
audio {
|
||||
display: block;
|
||||
margin: 1em auto;
|
||||
max-width: 100%;
|
||||
outline: none;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
#me-wrap {
|
||||
background: #fdfdfd;
|
||||
padding: 1.5em;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
margin: 2em auto;
|
||||
max-width: 600px;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.03);
|
||||
transition: opacity 0.6s ease;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.button:focus {
|
||||
outline: 2px solid #00aaff;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
#quota-bar[value="100"] {
|
||||
accent-color: #b22222;
|
||||
}
|
||||
|
||||
#quota-bar[value="100"] + #quota-text::after {
|
||||
content: " (Full)";
|
||||
color: #b22222;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
input[disabled], button[disabled] {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.uploading-toast {
|
||||
color: #1e90ff;
|
||||
background: #eaf4ff;
|
||||
border: 1px solid #b3daff;
|
||||
padding: 0.5em 1em;
|
||||
border-radius: 6px;
|
||||
font-size: 0.95em;
|
||||
animation: fadeIn 0.3s ease;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.success-toast {
|
||||
color: #2e8b57;
|
||||
background: #e7f6ed;
|
||||
border: 1px solid #c2e3d3;
|
||||
padding: 0.5em 1em;
|
||||
border-radius: 6px;
|
||||
font-size: 0.95em;
|
||||
animation: fadeIn 0.3s ease;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
background: #fafafa;
|
||||
margin: 0;
|
||||
padding: 1em;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
animation: slideDown 0.6s ease-out;
|
||||
}
|
||||
|
||||
header h1::before {
|
||||
content: "🎙️ ";
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
header p {
|
||||
animation: fadeIn 0.8s ease-out 0.3s both;
|
||||
}
|
||||
|
||||
header, footer {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5em;
|
||||
}
|
||||
|
||||
footer p {
|
||||
margin: 0.4em 0;
|
||||
font-size: 0.9em;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.dropzone::before {
|
||||
animation: emojiBounce 0.6s ease-out;
|
||||
content: "📤 ";
|
||||
font-size: 1.2em;
|
||||
display: block;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.dropzone {
|
||||
transition: background 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease;
|
||||
border: 2px dashed #999;
|
||||
padding: 2em;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dropzone.dragover {
|
||||
background: #e0f7ff;
|
||||
border-color: #00aaff;
|
||||
box-shadow: 0 0 0.4em rgba(0, 170, 255, 0.4);
|
||||
background: #f0f8ff;
|
||||
border-color: #00aaff;
|
||||
}
|
||||
|
||||
.dropzone.pulse,
|
||||
.dropzone.pulse::before {
|
||||
box-shadow: 0 0 0.6em rgba(0, 170, 255, 0.6);
|
||||
animation: pulse 0.6s ease-in-out;
|
||||
}
|
||||
|
||||
/* Reusable glowing pulse */
|
||||
.pulse-glow {
|
||||
animation: pulse 0.4s ease-in-out;
|
||||
box-shadow: 0 0 0.6em rgba(0, 170, 255, 0.6);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(-5px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
#file-info {
|
||||
animation: fadeIn 0.4s ease;
|
||||
margin-top: 0.8em;
|
||||
font-size: 0.95em;
|
||||
text-align: center;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.emoji-bounce {
|
||||
display: inline-block;
|
||||
animation: emojiBounce 0.6s ease-out;
|
||||
}
|
||||
|
||||
@keyframes emojiBounce {
|
||||
0% { transform: scale(1); }
|
||||
30% { transform: scale(1.3); }
|
||||
60% { transform: scale(0.95); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
#spinner {
|
||||
border: 3px solid #eee;
|
||||
border-top: 3px solid #2e8b57;
|
||||
border-radius: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 1em;
|
||||
display: none;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
#status {
|
||||
animation: fadeIn 0.4s ease;
|
||||
margin: 1em auto;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#status:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#status.success::before {
|
||||
content: "✅ ";
|
||||
}
|
||||
|
||||
.error-toast {
|
||||
color: #b22222;
|
||||
background: #fcebea;
|
||||
border: 1px solid #f5c6cb;
|
||||
padding: 0.5em 1em;
|
||||
border-radius: 6px;
|
||||
font-size: 0.95em;
|
||||
animation: fadeIn 0.3s ease;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
#stream-info.fade-out {
|
||||
animation: fadeOut 0.3s ease forwards;
|
||||
}
|
||||
|
||||
#stream-info {
|
||||
text-align: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
#stream-info.visible {
|
||||
animation: fadeIn 0.4s ease forwards;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
a.button.pulse-glow { animation: pulse 0.4s ease-in-out; }
|
||||
|
||||
a.button::before {
|
||||
content: "🔗 ";
|
||||
margin-right: 0.3em;
|
||||
}
|
||||
|
||||
a.button[aria-label] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
a.button[aria-label]::after {
|
||||
content: attr(aria-label);
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 100%;
|
||||
transform: translateX(-50%);
|
||||
background: #333;
|
||||
color: #fff;
|
||||
font-size: 0.75em;
|
||||
padding: 0.3em 0.6em;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
margin-bottom: 0.4em;
|
||||
}
|
||||
|
||||
a.button[aria-label]:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
a.button {
|
||||
display: inline-block;
|
||||
background: #2e8b57;
|
||||
color: white;
|
||||
padding: 0.4em 1em;
|
||||
margin-top: 0.5em;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
a.button:hover {
|
||||
animation: pulse 0.4s ease-in-out;
|
||||
background: #256b45;
|
||||
}
|
||||
|
||||
section article {
|
||||
max-width: 600px;
|
||||
margin: 2em auto;
|
||||
padding: 1.5em;
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
ul#stream-list,
|
||||
ul#me-files {
|
||||
padding-left: 0;
|
||||
list-style: none;
|
||||
text-align: center;
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
ul#stream-list li a,
|
||||
ul#me-files li {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0.3em auto;
|
||||
padding: 0.4em 0.8em;
|
||||
border-radius: 6px;
|
||||
background: #f0f0f0;
|
||||
font-size: 0.95em;
|
||||
max-width: 90%;
|
||||
gap: 1em;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
ul#stream-list li a:hover,
|
||||
ul#me-files li:hover {
|
||||
background: #e5f5ec;
|
||||
}
|
||||
|
||||
section article h2 {
|
||||
text-align: center;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.6em;
|
||||
}
|
||||
|
||||
section article a[href^="mailto"]::before {
|
||||
content: "✉️ ";
|
||||
margin-right: 0.3em;
|
||||
}
|
||||
|
||||
section article a[href^="mailto"] {
|
||||
display: inline-block;
|
||||
background: #2e8b57;
|
||||
color: white;
|
||||
padding: 0.3em 0.9em;
|
||||
margin-top: 0.5em;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
section article a[href^="mailto"]:hover {
|
||||
background: #256b45;
|
||||
}
|
||||
|
||||
code {
|
||||
background: #eee;
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
section#links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background: #e0f7ff;
|
||||
padding: 1em;
|
||||
margin: 2em auto;
|
||||
border-radius: 6px;
|
||||
max-width: 600px;
|
||||
box-shadow: 0 2px 6px rgba(0, 170, 255, 0.1);
|
||||
}
|
||||
|
||||
section#links p:first-child a,
|
||||
section#links p:nth-child(2) a {
|
||||
display: inline-block;
|
||||
background: #2e8b57;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
padding: 0.4em 1em;
|
||||
border-radius: 5px;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s ease;
|
||||
margin-bottom: 0.8em;
|
||||
}
|
||||
|
||||
section#links p:first-child a:hover,
|
||||
section#links p:nth-child(2) a:hover {
|
||||
background: #256b45;
|
||||
}
|
||||
}
|
||||
|
||||
#burger-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#burger-label {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 1em;
|
||||
right: 1em;
|
||||
cursor: pointer;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
#burger-label span {
|
||||
display: block;
|
||||
width: 25px;
|
||||
height: 3px;
|
||||
margin: 5px;
|
||||
background-color: #333;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
@media (max-width: 959px) {
|
||||
#burger-label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
section#links {
|
||||
display: none;
|
||||
background: #fff;
|
||||
position: absolute;
|
||||
top: 3.2em;
|
||||
right: 1em;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.1);
|
||||
padding: 1em;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
#burger-toggle:checked + #burger-label + section#links {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideFadeIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideFadeOut {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user