Add full SkinsPins application

- FastAPI + SQLite + Alpine.js + HTMX PWA for Bingo Bango Bongo golf scoring
- Passwordless magic link auth with email (aiosmtplib) and admin invite system
- Real-time score entry via WebSocket with drag-and-drop player ordering
- Course management with per-hole par and stroke index
- Scorecard with golf score markers (birdie, eagle, bogey, etc.)
- Two-step new game form with tee selection per player
- Dashboard with stats, live games social stream, and recent game history
- Game summary view (read-only scorecard with leaderboard)
- User profile pages with win stats
- PWA install support with generated PNG icons
- Admin panel for users, courses, games, and invitations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Rolf
2026-03-20 10:16:46 +01:00
parent 4b31b98f89
commit 3a1da1482c
51 changed files with 4964 additions and 1 deletions
+72
View File
@@ -0,0 +1,72 @@
function avatarUpload(formAction, redirectTo) {
return {
tab: 'emoji',
showModal: false,
previewUrl: null,
croppedBlob: null,
cropper: null,
openFile(e) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
this.previewUrl = ev.target.result;
this.showModal = true;
this.$nextTick(() => this._initCropper());
};
reader.readAsDataURL(file);
// Clear the input so re-selecting the same file triggers change again
e.target.value = '';
},
_initCropper() {
if (this.cropper) { this.cropper.destroy(); this.cropper = null; }
const img = this.$refs.cropImg;
this.cropper = new Cropper(img, {
aspectRatio: 1,
viewMode: 1,
dragMode: 'move',
autoCropArea: 1,
cropBoxMovable: false,
cropBoxResizable: false,
toggleDragModeOnDblclick: false,
background: false,
});
},
confirmCrop() {
const canvas = this.cropper.getCroppedCanvas({ width: 256, height: 256 });
canvas.toBlob((blob) => {
this.croppedBlob = blob;
this.previewUrl = canvas.toDataURL('image/jpeg');
this.showModal = false;
this.cropper.destroy();
this.cropper = null;
}, 'image/jpeg', 0.88);
},
cancelCrop() {
this.showModal = false;
if (this.cropper) { this.cropper.destroy(); this.cropper = null; }
if (!this.croppedBlob) this.previewUrl = null;
},
async submitForm(e) {
e.preventDefault();
const form = e.target;
const fd = new FormData(form);
if (this.tab === 'upload' && this.croppedBlob) {
fd.delete('avatar_file');
fd.set('avatar_file', this.croppedBlob, 'avatar.jpg');
fd.set('avatar_emoji', '');
} else if (this.tab === 'emoji') {
fd.delete('avatar_file');
}
const resp = await fetch(formAction, { method: 'POST', body: fd, redirect: 'follow' });
window.location.href = resp.url || redirectTo;
},
};
}