feedback page

This commit is contained in:
Davide Scaini
2026-04-10 13:58:12 +02:00
parent 6d3673b2f7
commit 4593478863
3 changed files with 191 additions and 1 deletions
+25
View File
@@ -277,6 +277,31 @@ incremental sync from the same modal.
--- ---
## Reading user feedback
Users can submit feedback from the **Feedback** link in the nav (visible when logged in).
Submissions are stored as JSON on the server:
```
/var/bincio/data/_feedback/
{handle}.json ← one file per user, array of submissions
{handle}/ ← attached images
```
To read all feedback:
```bash
cat /var/bincio/data/_feedback/*.json | python3 -m json.tool
```
Per-user only:
```bash
cat /var/bincio/data/_feedback/pres.json | python3 -m json.tool
```
---
## Day-to-day operations ## Day-to-day operations
| Task | Command | | Task | Command |
+4 -1
View File
@@ -176,6 +176,7 @@ try {
<a href={`${baseUrl}convert/`} class="text-sm text-zinc-400 hover:text-white transition-colors">Convert</a> <a href={`${baseUrl}convert/`} class="text-sm text-zinc-400 hover:text-white transition-colors">Convert</a>
)} )}
<a href={`${baseUrl}about/`} class="text-sm text-zinc-400 hover:text-white transition-colors">About</a> <a href={`${baseUrl}about/`} class="text-sm text-zinc-400 hover:text-white transition-colors">About</a>
<a id="nav-feedback" href={`${baseUrl}feedback/`} style="display:none" class="text-sm text-zinc-400 hover:text-white transition-colors">Feedback</a>
</> </>
)} )}
@@ -375,9 +376,11 @@ try {
el.href = baseUrl + 'u/' + user.handle + '/' + el.getAttribute('data-user-path'); el.href = baseUrl + 'u/' + user.handle + '/' + el.getAttribute('data-user-path');
}); });
// Show logout button // Show logout button and feedback link
const logoutEl = document.getElementById('nav-logout'); const logoutEl = document.getElementById('nav-logout');
if (logoutEl) logoutEl.style.display = ''; if (logoutEl) logoutEl.style.display = '';
const feedbackEl = document.getElementById('nav-feedback');
if (feedbackEl) feedbackEl.style.display = '';
// Pre-populate the "keep original" checkbox from the instance default // Pre-populate the "keep original" checkbox from the instance default
const chk = document.getElementById('upload-keep-original'); const chk = document.getElementById('upload-keep-original');
+162
View File
@@ -0,0 +1,162 @@
---
import Base from '../../layouts/Base.astro';
---
<Base title="Feedback — BincioActivity">
<div class="max-w-lg mx-auto mt-12 px-4">
<h1 class="text-2xl font-bold text-white mb-2">Send feedback</h1>
<p class="text-sm text-zinc-500 mb-6">Report a bug, suggest a feature, or share anything useful. Plain text only — no account details needed.</p>
<form id="feedback-form" class="space-y-4">
<div>
<textarea
id="fb-text"
rows="6"
placeholder="What's on your mind?"
class="w-full px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700 text-white placeholder-zinc-500 text-sm focus:outline-none focus:border-[--accent] resize-none"
></textarea>
</div>
<!-- Image upload -->
<div>
<p class="text-xs text-zinc-500 mb-2">Attach up to 3 screenshots (max 2 MB each)</p>
<div
id="fb-drop"
class="border-2 border-dashed border-zinc-700 rounded-lg p-5 text-center text-zinc-500 text-sm cursor-pointer hover:border-zinc-500 hover:text-zinc-300 transition-colors"
>
<span id="fb-drop-label">Drop images or click to browse</span>
<input id="fb-input" type="file" accept="image/*" multiple class="hidden" />
</div>
<div id="fb-previews" class="flex gap-2 flex-wrap mt-2"></div>
</div>
<p id="fb-error" class="text-red-400 text-sm hidden"></p>
<button
type="submit"
class="w-full py-2 rounded-lg bg-[--accent] hover:opacity-90 text-white font-medium text-sm transition-opacity"
>Send feedback</button>
</form>
<div id="fb-success" class="hidden text-center mt-12">
<p class="text-2xl mb-2">Thanks!</p>
<p class="text-zinc-400 text-sm">Your feedback has been received.</p>
</div>
</div>
</Base>
<script>
const MAX_IMAGES = 3;
const MAX_BYTES = 2 * 1024 * 1024;
const form = document.getElementById('feedback-form') as HTMLFormElement;
const drop = document.getElementById('fb-drop')!;
const input = document.getElementById('fb-input') as HTMLInputElement;
const previews = document.getElementById('fb-previews')!;
const errEl = document.getElementById('fb-error')!;
const success = document.getElementById('fb-success')!;
let selectedFiles: File[] = [];
function showError(msg: string) {
errEl.textContent = msg;
errEl.classList.remove('hidden');
}
function clearError() {
errEl.classList.add('hidden');
}
function renderPreviews() {
previews.innerHTML = '';
for (let i = 0; i < selectedFiles.length; i++) {
const f = selectedFiles[i];
const url = URL.createObjectURL(f);
const wrap = document.createElement('div');
wrap.className = 'relative';
wrap.innerHTML = `
<img src="${url}" class="w-20 h-20 object-cover rounded-lg border border-zinc-700" />
<button type="button" data-i="${i}"
class="remove-btn absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-zinc-800 border border-zinc-600 text-zinc-300 hover:text-white text-xs flex items-center justify-center leading-none">
×
</button>`;
previews.appendChild(wrap);
}
previews.querySelectorAll('.remove-btn').forEach(btn => {
btn.addEventListener('click', () => {
const i = parseInt((btn as HTMLElement).dataset.i ?? '0');
selectedFiles.splice(i, 1);
renderPreviews();
clearError();
});
});
}
function addFiles(newFiles: FileList | File[]) {
clearError();
for (const f of Array.from(newFiles)) {
if (selectedFiles.length >= MAX_IMAGES) {
showError(`Maximum ${MAX_IMAGES} images.`);
break;
}
if (f.size > MAX_BYTES) {
showError(`"${f.name}" exceeds 2 MB.`);
continue;
}
selectedFiles.push(f);
}
renderPreviews();
input.value = '';
}
drop.addEventListener('click', () => input.click());
drop.addEventListener('dragover', e => { e.preventDefault(); drop.style.borderColor = 'var(--accent)'; });
drop.addEventListener('dragleave', () => { drop.style.borderColor = ''; });
drop.addEventListener('drop', e => {
e.preventDefault();
drop.style.borderColor = '';
if (e.dataTransfer?.files.length) addFiles(e.dataTransfer.files);
});
input.addEventListener('change', () => { if (input.files?.length) addFiles(input.files); });
form.addEventListener('submit', async e => {
e.preventDefault();
clearError();
const text = (document.getElementById('fb-text') as HTMLTextAreaElement).value.trim();
if (!text && selectedFiles.length === 0) {
showError('Please write something or attach an image.');
return;
}
const fd = new FormData();
fd.append('text', text);
for (const f of selectedFiles) fd.append('images', f);
const btn = form.querySelector('button[type=submit]') as HTMLButtonElement;
btn.disabled = true;
btn.textContent = 'Sending…';
try {
const r = await fetch('/api/feedback', { method: 'POST', credentials: 'include', body: fd });
if (!r.ok) {
const d = await r.json().catch(() => ({}));
throw new Error(d.detail ?? `Server error ${r.status}`);
}
form.classList.add('hidden');
success.classList.remove('hidden');
} catch (err: any) {
showError(err.message);
btn.disabled = false;
btn.textContent = 'Send feedback';
}
});
// Redirect to login if not authenticated
(async () => {
try {
const r = await fetch('/api/me', { credentials: 'include' });
if (r.status === 401) window.location.href = `/login/?next=${encodeURIComponent(window.location.pathname)}`;
} catch (_) {}
})();
</script>