407cae2040
Build and Deploy / build-and-push (push) Successful in 5m7s
- Add migration 005: nullable avatar_url column on users table - Add GET /api/me: returns id, username, avatar_url from DB (fixes UUID-on-profile bug) - Add PUT /api/me/avatar: accepts raw image bytes (≤1 MB, jpeg/png/webp/gif), writes to avatars/ dir, updates avatar_url in DB - Serve /avatars via ServeDir so uploaded images are publicly accessible - Update account.html: fetch username from /api/me instead of parsing JWT; add circular avatar display with initials fallback and click-to-upload - Add SolitaireServerClient::fetch_me() for desktop/Android profile display - Add avatar_url field to SyncBackend::SolitaireServer settings (serde default None) - Update sqlx offline query cache for new avatar_url queries Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
391 lines
17 KiB
HTML
391 lines
17 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Ferrous Solitaire — Account</title>
|
|
<style>
|
|
@font-face {
|
|
font-family: "FiraMono";
|
|
src: url("/assets/fonts/main.ttf") format("truetype");
|
|
}
|
|
:root {
|
|
--bg: #151515; --panel: #202020; --panel-hi: #2a2a2a;
|
|
--border: #353535; --text: #d0d0d0; --text-muted: #a0a0a0;
|
|
--accent: #a54242; --accent-hi: #c25e5e; --success: #acc267;
|
|
}
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body {
|
|
font-family: "FiraMono", "Fira Mono", monospace;
|
|
background: var(--bg); color: var(--text);
|
|
min-height: 100vh; display: flex; flex-direction: column;
|
|
}
|
|
header {
|
|
display: flex; align-items: center; gap: 12px;
|
|
padding: 12px 20px;
|
|
border-bottom: 1px solid var(--border);
|
|
position: sticky; top: 0; background: var(--bg); z-index: 10;
|
|
}
|
|
.home-link {
|
|
color: var(--text-muted); text-decoration: none;
|
|
font-size: 18px; padding: 2px 4px; border-radius: 4px;
|
|
transition: color 120ms, background 120ms;
|
|
}
|
|
.home-link:hover { color: var(--text); background: var(--panel-hi); }
|
|
h1 { font-size: 16px; font-weight: 700; }
|
|
main {
|
|
flex: 1; display: flex; align-items: flex-start;
|
|
justify-content: center; padding: 40px 20px;
|
|
}
|
|
.card {
|
|
background: var(--panel); border: 1px solid var(--border);
|
|
border-radius: 10px; padding: 28px; width: 100%; max-width: 380px;
|
|
display: flex; flex-direction: column; gap: 20px;
|
|
}
|
|
|
|
/* ── Tabs ── */
|
|
.tabs {
|
|
display: flex; border-bottom: 1px solid var(--border);
|
|
margin-bottom: -4px;
|
|
}
|
|
.tab {
|
|
flex: 1; padding: 8px 0; text-align: center;
|
|
font-size: 13px; font-weight: 600; cursor: pointer;
|
|
color: var(--text-muted); border-bottom: 2px solid transparent;
|
|
transition: color 120ms, border-color 120ms;
|
|
}
|
|
.tab.active { color: var(--text); border-bottom-color: var(--accent); }
|
|
.tab:hover:not(.active) { color: var(--text); }
|
|
|
|
/* ── Form ── */
|
|
.form { display: flex; flex-direction: column; gap: 12px; }
|
|
label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.06em; }
|
|
input {
|
|
background: var(--panel-hi); border: 1px solid var(--border);
|
|
border-radius: 6px; padding: 9px 12px; color: var(--text);
|
|
font-family: inherit; font-size: 14px; width: 100%;
|
|
transition: border-color 120ms;
|
|
}
|
|
input:focus { outline: none; border-color: var(--accent); }
|
|
.hint { font-size: 11px; color: var(--text-muted); }
|
|
.error-msg { color: var(--accent-hi); font-size: 12px; display: none; }
|
|
.success-msg { color: var(--success); font-size: 12px; display: none; }
|
|
button[type="submit"] {
|
|
background: var(--accent); color: var(--text); border: none;
|
|
border-radius: 6px; padding: 10px 16px; font-family: inherit;
|
|
font-size: 14px; font-weight: 700; cursor: pointer;
|
|
transition: background 120ms; margin-top: 4px;
|
|
}
|
|
button[type="submit"]:hover { background: var(--accent-hi); }
|
|
button[type="submit"]:disabled { opacity: 0.4; cursor: default; }
|
|
|
|
/* ── Signed-in state ── */
|
|
#signed-in { display: none; flex-direction: column; gap: 16px; align-items: center; }
|
|
.username-display {
|
|
font-size: 20px; font-weight: 700; text-align: center;
|
|
}
|
|
.signed-in-detail {
|
|
font-size: 13px; color: var(--text-muted); text-align: center;
|
|
}
|
|
.signed-in-actions { display: flex; flex-direction: column; gap: 8px; width: 100%; }
|
|
.btn-secondary {
|
|
background: var(--panel-hi); color: var(--text);
|
|
border: 1px solid var(--border); border-radius: 6px;
|
|
padding: 9px 16px; font-family: inherit; font-size: 13px;
|
|
font-weight: 600; cursor: pointer; transition: background 120ms;
|
|
text-align: center; text-decoration: none; display: block;
|
|
}
|
|
.btn-secondary:hover { background: var(--border); }
|
|
.btn-danger {
|
|
background: transparent; color: var(--accent-hi);
|
|
border: 1px solid var(--accent); border-radius: 6px;
|
|
padding: 9px 16px; font-family: inherit; font-size: 13px;
|
|
cursor: pointer; transition: background 120ms;
|
|
}
|
|
.btn-danger:hover { background: rgba(165, 66, 66, 0.15); }
|
|
|
|
/* ── Avatar ── */
|
|
.avatar-wrap {
|
|
position: relative; width: 96px; height: 96px; cursor: pointer;
|
|
border-radius: 50%; overflow: hidden;
|
|
border: 2px solid var(--border);
|
|
flex-shrink: 0;
|
|
}
|
|
.avatar-img {
|
|
width: 100%; height: 100%; object-fit: cover;
|
|
border-radius: 50%;
|
|
}
|
|
.avatar-initials {
|
|
width: 100%; height: 100%; border-radius: 50%;
|
|
background: var(--panel-hi); display: flex;
|
|
align-items: center; justify-content: center;
|
|
font-size: 32px; font-weight: 700; color: var(--text-muted);
|
|
user-select: none;
|
|
}
|
|
.avatar-overlay {
|
|
position: absolute; inset: 0; border-radius: 50%;
|
|
background: rgba(0,0,0,0.55); display: flex;
|
|
align-items: center; justify-content: center;
|
|
font-size: 11px; font-weight: 700; color: #fff;
|
|
letter-spacing: 0.06em; opacity: 0;
|
|
transition: opacity 120ms;
|
|
}
|
|
.avatar-wrap:hover .avatar-overlay { opacity: 1; }
|
|
.avatar-status {
|
|
font-size: 11px; color: var(--text-muted); text-align: center;
|
|
min-height: 16px;
|
|
}
|
|
.avatar-status.ok { color: var(--success); }
|
|
.avatar-status.err { color: var(--accent-hi); }
|
|
</style>
|
|
<!-- Matomo -->
|
|
<script>
|
|
var _paq = window._paq = window._paq || [];
|
|
_paq.push(['trackPageView']);
|
|
_paq.push(['enableLinkTracking']);
|
|
(function() {
|
|
var u = "https://analytics.aleshym.co/";
|
|
_paq.push(['setTrackerUrl', u + 'matomo.php']);
|
|
_paq.push(['setSiteId', '1']);
|
|
var d = document, g = d.createElement('script'), s = d.getElementsByTagName('script')[0];
|
|
g.async = true; g.src = u + 'matomo.js'; s.parentNode.insertBefore(g, s);
|
|
})();
|
|
</script>
|
|
<!-- End Matomo -->
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<a href="/" class="home-link">←</a>
|
|
<h1>Account</h1>
|
|
</header>
|
|
<main>
|
|
<div class="card">
|
|
<!-- Signed-in view -->
|
|
<div id="signed-in">
|
|
<div class="avatar-wrap" id="avatar-wrap" title="Click to change avatar">
|
|
<img id="avatar-img" class="avatar-img" src="" alt="" style="display:none">
|
|
<div id="avatar-initials" class="avatar-initials"></div>
|
|
<div class="avatar-overlay">Upload</div>
|
|
<input type="file" id="avatar-file" accept="image/jpeg,image/png,image/webp,image/gif" style="display:none">
|
|
</div>
|
|
<div class="avatar-status" id="avatar-status"></div>
|
|
<div class="signed-in-detail">Signed in as</div>
|
|
<div class="username-display" id="display-username"></div>
|
|
<div class="signed-in-actions">
|
|
<a class="btn-secondary" href="/leaderboard">View Leaderboard</a>
|
|
<a class="btn-secondary" href="/replays">Recent Replays</a>
|
|
<button class="btn-danger" id="btn-signout">Sign Out</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Auth forms -->
|
|
<div id="auth-section">
|
|
<div class="tabs">
|
|
<div class="tab active" data-tab="signin">Sign In</div>
|
|
<div class="tab" data-tab="signup">Create Account</div>
|
|
</div>
|
|
|
|
<!-- Sign In -->
|
|
<form class="form" id="form-signin" style="margin-top:20px">
|
|
<div>
|
|
<label for="si-user">Username</label>
|
|
<input type="text" id="si-user" placeholder="your_username" autocomplete="username">
|
|
</div>
|
|
<div>
|
|
<label for="si-pass">Password</label>
|
|
<input type="password" id="si-pass" placeholder="••••••••" autocomplete="current-password">
|
|
</div>
|
|
<div class="error-msg" id="si-error"></div>
|
|
<button type="submit">Sign In</button>
|
|
</form>
|
|
|
|
<!-- Sign Up -->
|
|
<form class="form" id="form-signup" style="display:none; margin-top:20px">
|
|
<div>
|
|
<label for="su-user">Username</label>
|
|
<input type="text" id="su-user" placeholder="your_username" autocomplete="username"
|
|
minlength="3" maxlength="32">
|
|
<div class="hint" style="margin-top:4px">3–32 characters, letters, digits, underscores</div>
|
|
</div>
|
|
<div>
|
|
<label for="su-pass">Password</label>
|
|
<input type="password" id="su-pass" placeholder="••••••••" autocomplete="new-password"
|
|
minlength="8">
|
|
<div class="hint" style="margin-top:4px">Minimum 8 characters</div>
|
|
</div>
|
|
<div>
|
|
<label for="su-pass2">Confirm Password</label>
|
|
<input type="password" id="su-pass2" placeholder="••••••••" autocomplete="new-password">
|
|
</div>
|
|
<div class="error-msg" id="su-error"></div>
|
|
<div class="success-msg" id="su-success"></div>
|
|
<button type="submit" id="btn-signup">Create Account</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
<script>
|
|
const TOKEN_KEY = 'fs_token';
|
|
|
|
async function fetchMe(token) {
|
|
const res = await fetch('/api/me', {
|
|
headers: { 'Authorization': 'Bearer ' + token },
|
|
});
|
|
if (!res.ok) return null;
|
|
return res.json();
|
|
}
|
|
|
|
function applyProfile(me) {
|
|
document.getElementById('display-username').textContent = me.username || 'Player';
|
|
const img = document.getElementById('avatar-img');
|
|
const initials = document.getElementById('avatar-initials');
|
|
if (me.avatar_url) {
|
|
img.src = me.avatar_url;
|
|
img.style.display = 'block';
|
|
initials.style.display = 'none';
|
|
} else {
|
|
img.style.display = 'none';
|
|
initials.style.display = 'flex';
|
|
initials.textContent = (me.username || 'P')[0].toUpperCase();
|
|
}
|
|
}
|
|
|
|
async function showSignedIn(token) {
|
|
document.getElementById('signed-in').style.display = 'flex';
|
|
document.getElementById('auth-section').style.display = 'none';
|
|
const me = await fetchMe(token);
|
|
if (me) {
|
|
applyProfile(me);
|
|
} else {
|
|
// Token is stale — kick back to auth
|
|
localStorage.removeItem(TOKEN_KEY);
|
|
showAuth();
|
|
}
|
|
}
|
|
|
|
function showAuth() {
|
|
document.getElementById('signed-in').style.display = 'none';
|
|
document.getElementById('auth-section').style.display = 'block';
|
|
}
|
|
|
|
// Tab switching
|
|
document.querySelectorAll('.tab').forEach(tab => {
|
|
tab.addEventListener('click', () => {
|
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
tab.classList.add('active');
|
|
const which = tab.dataset.tab;
|
|
document.getElementById('form-signin').style.display = which === 'signin' ? 'flex' : 'none';
|
|
document.getElementById('form-signup').style.display = which === 'signup' ? 'flex' : 'none';
|
|
});
|
|
});
|
|
|
|
// Sign In
|
|
document.getElementById('form-signin').addEventListener('submit', async e => {
|
|
e.preventDefault();
|
|
const user = document.getElementById('si-user').value.trim();
|
|
const pass = document.getElementById('si-pass').value;
|
|
const err = document.getElementById('si-error');
|
|
err.style.display = 'none';
|
|
const res = await fetch('/api/auth/login', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ username: user, password: pass }),
|
|
});
|
|
if (!res.ok) {
|
|
err.textContent = 'Invalid username or password.';
|
|
err.style.display = 'block';
|
|
return;
|
|
}
|
|
const { access_token } = await res.json();
|
|
localStorage.setItem(TOKEN_KEY, access_token);
|
|
showSignedIn(access_token);
|
|
});
|
|
|
|
// Sign Up
|
|
document.getElementById('form-signup').addEventListener('submit', async e => {
|
|
e.preventDefault();
|
|
const user = document.getElementById('su-user').value.trim();
|
|
const pass = document.getElementById('su-pass').value;
|
|
const pass2 = document.getElementById('su-pass2').value;
|
|
const err = document.getElementById('su-error');
|
|
const ok = document.getElementById('su-success');
|
|
err.style.display = 'none';
|
|
ok.style.display = 'none';
|
|
|
|
if (pass !== pass2) {
|
|
err.textContent = 'Passwords do not match.';
|
|
err.style.display = 'block';
|
|
return;
|
|
}
|
|
const btn = document.getElementById('btn-signup');
|
|
btn.disabled = true;
|
|
const res = await fetch('/api/auth/register', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ username: user, password: pass }),
|
|
});
|
|
btn.disabled = false;
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({}));
|
|
err.textContent = body.message ?? 'Registration failed. Username may already be taken.';
|
|
err.style.display = 'block';
|
|
return;
|
|
}
|
|
const { access_token } = await res.json();
|
|
localStorage.setItem(TOKEN_KEY, access_token);
|
|
showSignedIn(access_token);
|
|
});
|
|
|
|
// Sign Out
|
|
document.getElementById('btn-signout').addEventListener('click', () => {
|
|
localStorage.removeItem(TOKEN_KEY);
|
|
showAuth();
|
|
});
|
|
|
|
// Avatar: click wrap → trigger file picker
|
|
document.getElementById('avatar-wrap').addEventListener('click', () => {
|
|
document.getElementById('avatar-file').click();
|
|
});
|
|
|
|
document.getElementById('avatar-file').addEventListener('change', async e => {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
const status = document.getElementById('avatar-status');
|
|
status.className = 'avatar-status';
|
|
status.textContent = 'Uploading...';
|
|
const token = localStorage.getItem(TOKEN_KEY);
|
|
try {
|
|
const res = await fetch('/api/me/avatar', {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Authorization': 'Bearer ' + token,
|
|
'Content-Type': file.type,
|
|
},
|
|
body: file,
|
|
});
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({}));
|
|
status.className = 'avatar-status err';
|
|
status.textContent = body.message ?? 'Upload failed.';
|
|
return;
|
|
}
|
|
const me = await res.json();
|
|
applyProfile(me);
|
|
status.className = 'avatar-status ok';
|
|
status.textContent = 'Avatar updated.';
|
|
setTimeout(() => { status.textContent = ''; }, 3000);
|
|
} catch {
|
|
status.className = 'avatar-status err';
|
|
status.textContent = 'Network error.';
|
|
}
|
|
// Reset so re-selecting the same file triggers change again
|
|
e.target.value = '';
|
|
});
|
|
|
|
// Initial state
|
|
const token = localStorage.getItem(TOKEN_KEY);
|
|
if (token) { showSignedIn(token); } else { showAuth(); }
|
|
</script>
|
|
</body>
|
|
</html>
|