feedback page
This commit is contained in:
@@ -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 |
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user