Complete authentication and profile features

This commit is contained in:
Olaf
2026-08-24 14:50:05 +02:00
parent 1c827956c4
commit 01702fcadf
17 changed files with 227 additions and 40 deletions
+47 -21
View File
@@ -4,6 +4,26 @@ const userFilter = document.getElementById('user-filter');
const cookieName = 'linklog-feed-preferences';
function createAvatar(user) {
const avatar = document.createElement('div');
avatar.className = 'avatar';
const initial = (user?.username || 'U').slice(0, 1).toUpperCase();
avatar.textContent = initial;
if (user?.avatar_url) {
const image = document.createElement('img');
image.src = user.avatar_url;
image.alt = `${user.username || 'User'} avatar`;
image.addEventListener('error', () => {
image.remove();
avatar.textContent = initial;
});
avatar.textContent = '';
avatar.appendChild(image);
}
return avatar;
}
function readPreferences() {
const cookie = document.cookie
.split('; ')
@@ -25,7 +45,16 @@ function writePreferences(pref) {
document.cookie = `${cookieName}=${value}; path=/; max-age=31536000`;
}
function renderFeed(items) {
async function loadUsers() {
const response = await fetch('/api/public/users');
if (!response.ok) throw new Error('Could not load users');
const users = await response.json();
const selectedUser = readPreferences().user;
userFilter.replaceChildren(new Option('All users', ''), ...users.map((username) => new Option(username, username)));
userFilter.value = users.includes(selectedUser) ? selectedUser : '';
}
function renderFeed(items, showIdentity = true) {
feedEl.innerHTML = '';
if (!items.length) {
@@ -37,20 +66,6 @@ function renderFeed(items) {
const article = document.createElement('article');
article.className = 'link-item';
const header = document.createElement('div');
header.className = 'link-header';
const avatar = document.createElement('div');
avatar.className = 'avatar';
avatar.textContent = (item.user?.username || 'U').slice(0, 1).toUpperCase();
const userName = document.createElement('div');
userName.className = 'user-name';
userName.textContent = item.user?.username || 'unknown';
header.appendChild(avatar);
header.appendChild(userName);
const title = document.createElement('h2');
const link = document.createElement('a');
link.href = item.url;
@@ -67,7 +82,17 @@ function renderFeed(items) {
meta.className = 'meta';
meta.textContent = item.created_at || 'updated recently';
article.appendChild(header);
if (showIdentity) {
const header = document.createElement('div');
header.className = 'link-header';
header.appendChild(createAvatar(item.user));
const userName = document.createElement('div');
userName.className = 'user-name';
userName.textContent = item.user?.username || 'unknown';
header.appendChild(userName);
article.appendChild(header);
}
article.appendChild(title);
article.appendChild(comment);
article.appendChild(meta);
@@ -94,7 +119,7 @@ async function loadFeed() {
items = [...items].reverse();
}
renderFeed(items);
renderFeed(items, !routeUser);
}
function syncPreferences() {
@@ -108,12 +133,13 @@ function syncPreferences() {
loadFeed();
});
userFilter.addEventListener('input', (event) => {
const next = { ...readPreferences(), user: event.target.value.trim() };
userFilter.addEventListener('change', (event) => {
const selectedUser = event.target.value.trim();
const next = { ...readPreferences(), user: selectedUser };
writePreferences(next);
loadFeed();
window.location.assign(selectedUser ? `/${encodeURIComponent(selectedUser)}/` : '/');
});
}
syncPreferences();
loadFeed();
loadUsers().then(loadFeed).catch(() => loadFeed());
+27 -2
View File
@@ -3,7 +3,6 @@ const profileForm = document.querySelector('#profile-form');
const mastodonForm = document.querySelector('#mastodon-form');
const profileLogoutButton = document.querySelector('#logout-button');
const accessToken = localStorage.getItem('linklogAccessToken');
const defaultAvatarUrl = 'https://example.com/avatar.png';
const defaultMastodonInstance = 'mastodon.social';
const defaultPostPrefix = 'From my #LinkLog: "';
@@ -27,7 +26,11 @@ async function loadProfile() {
document.querySelector('#username').textContent = profile.username || '';
document.querySelector('#email').value = profile.email || '';
document.querySelector('#bio').value = profile.bio || '';
document.querySelector('#avatar-url').value = profile.avatar_url || defaultAvatarUrl;
const avatarPreview = document.querySelector('#avatar-preview');
if (profile.avatar_url) {
avatarPreview.src = profile.avatar_url;
avatarPreview.classList.remove('hidden');
}
if (profile.is_admin) {
document.querySelector('#admin-link').classList.remove('hidden');
}
@@ -46,6 +49,7 @@ async function loadMastodonConfig() {
profileForm.addEventListener('submit', async (event) => {
event.preventDefault();
const formData = new FormData(profileForm);
formData.delete('avatar');
const response = await fetch('/api/user/me', {
method: 'PUT',
headers: authHeaders(true),
@@ -54,6 +58,27 @@ profileForm.addEventListener('submit', async (event) => {
setStatus('#profile-status', response.ok ? 'Profile saved.' : 'Could not save profile.', !response.ok);
});
document.querySelector('#avatar-file').addEventListener('change', async (event) => {
const file = event.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('avatar', file);
const response = await fetch('/api/user/avatar', {
method: 'POST',
headers: authHeaders(),
body: formData,
});
if (!response.ok) {
setStatus('#profile-status', 'Could not upload avatar.', true);
return;
}
const data = await response.json();
const avatarPreview = document.querySelector('#avatar-preview');
avatarPreview.src = data.avatar_url;
avatarPreview.classList.remove('hidden');
setStatus('#profile-status', 'Avatar uploaded.');
});
mastodonForm.addEventListener('submit', async (event) => {
event.preventDefault();
const formData = new FormData(mastodonForm);
+15
View File
@@ -229,6 +229,14 @@ button:focus-visible {
font-weight: 600;
}
.profile-avatar-preview {
width: 96px;
height: 96px;
object-fit: cover;
border: 2px solid var(--mauve);
border-radius: 50%;
}
button {
width: fit-content;
border-color: var(--mauve);
@@ -363,6 +371,13 @@ button:disabled {
font-weight: 700;
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: inherit;
}
.user-name {
color: var(--lavender);
font-weight: 700;
+11 -3
View File
@@ -23,7 +23,13 @@
{% if profile %}
<section class="link-item profile-summary">
<div class="link-header">
<div class="avatar">{{ profile.username[:1].upper() }}</div>
<div class="avatar">
{% if profile.avatar_url %}
<img src="{{ profile.avatar_url }}" alt="{{ profile.username }} avatar" />
{% else %}
{{ profile.username[:1].upper() }}
{% endif %}
</div>
<div class="user-name">{{ profile.username }}</div>
</div>
<p>{{ profile.bio or 'No profile information provided.' }}</p>
@@ -39,13 +45,15 @@
</label>
<label>
User filter
<input id="user-filter" type="text" placeholder="alice" />
<select id="user-filter">
<option value="">All users</option>
</select>
</label>
</section>
<section id="feed" class="feed" aria-live="polite"></section>
</main>
<script src="/static/feed.js"></script>
<script src="/static/feed.js?v=4"></script>
</body>
</html>
+3 -2
View File
@@ -37,9 +37,10 @@
<textarea id="bio" name="bio" rows="4"></textarea>
</label>
<label>
Avatar URL
<input id="avatar-url" name="avatar_url" type="url" value="https://example.com/avatar.png" />
Avatar image
<input id="avatar-file" name="avatar" type="file" accept="image/png,image/jpeg,image/gif,image/webp" />
</label>
<img id="avatar-preview" class="profile-avatar-preview hidden" alt="Avatar preview" />
<button type="submit">Save profile</button>
<p id="profile-status" class="status" role="status"></p>
</form>