Update 2025-04-24_11:44:19

This commit is contained in:
oib
2025-04-24 11:44:23 +02:00
commit e748c737f4
3408 changed files with 717481 additions and 0 deletions

429
static/app.js Normal file
View 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() dont 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
View 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
View 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
View 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>Youll 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 &amp; 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 | 48kHz | 60kbps</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
View File

@ -0,0 +1,18 @@
// static/magic-login.js — handles magiclink 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
View File

@ -0,0 +1,147 @@
// nav.js — lightweight navigation & magiclink 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 magiclink 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 magiclogin 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
View 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
View 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
View 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
View 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);
}
}