Initial LinkLog implementation

This commit is contained in:
Olaf
2026-08-24 14:30:30 +02:00
commit 1c827956c4
50 changed files with 3436 additions and 0 deletions
+186
View File
@@ -0,0 +1,186 @@
(() => {
const pluginList = document.querySelector('#plugin-list');
const userList = document.querySelector('#user-list');
const userForm = document.querySelector('#user-form');
const adminControls = document.querySelector('#admin-controls');
const adminAuthNotice = document.querySelector('#admin-auth-notice');
const adminLoginButton = document.querySelector('#admin-login-button');
const adminLogoutButton = document.querySelector('#logout-button');
const accessToken = localStorage.getItem('linklogAccessToken');
function authHeaders(includeJson = false) {
return {
...(includeJson ? {'Content-Type': 'application/json'} : {}),
...(accessToken ? {Authorization: `Bearer ${accessToken}`} : {}),
};
}
function renderPlugins(plugins) {
pluginList.replaceChildren(...plugins.map((plugin) => {
const row = document.createElement('div');
row.className = 'plugin-row';
const label = document.createElement('span');
label.textContent = `${plugin.name} (${plugin.version})`;
const button = document.createElement('button');
button.type = 'button';
button.textContent = plugin.enabled ? 'Disable' : 'Enable';
button.addEventListener('click', () => updatePlugin(plugin, button));
row.append(label, button);
return row;
}));
}
function renderUsers(users) {
userList.replaceChildren(...users.map((user) => {
const row = document.createElement('div');
row.className = 'plugin-row';
const label = document.createElement('span');
label.textContent = `${user.username} (${user.email})${user.is_admin ? ' - admin' : ''}`;
const privilegeLabel = document.createElement('label');
privilegeLabel.className = 'user-admin-toggle';
const privilegeCheckbox = document.createElement('input');
privilegeCheckbox.type = 'checkbox';
privilegeCheckbox.checked = user.is_admin;
privilegeCheckbox.setAttribute('aria-label', `Administrator rights for ${user.username}`);
privilegeCheckbox.addEventListener('change', () => updateUserPrivilege(user, privilegeCheckbox));
privilegeLabel.append(privilegeCheckbox, document.createTextNode(' Administrator'));
const button = document.createElement('button');
button.type = 'button';
button.className = 'danger-button';
button.textContent = 'Remove';
button.addEventListener('click', () => removeUser(user, button));
row.append(label, privilegeLabel, button);
return row;
}));
}
async function loadUsers() {
const response = await fetch('/api/admin/users', {headers: authHeaders()});
if (!response.ok) throw new Error('Could not load users');
renderUsers(await response.json());
}
function showAdminState(isAdmin) {
adminControls.classList.toggle('hidden', !isAdmin);
adminAuthNotice.classList.toggle('hidden', isAdmin);
adminLoginButton.classList.toggle('hidden', isAdmin);
adminLogoutButton.classList.toggle('hidden', !accessToken);
}
function showSignedOutState() {
showAdminState(false);
adminAuthNotice.innerHTML = 'Administrator sign-in required. <a href="/login">Sign in</a>';
adminLoginButton.classList.remove('hidden');
adminLogoutButton.classList.add('hidden');
}
function showUnauthorizedState() {
showAdminState(false);
adminAuthNotice.textContent = 'You are signed in, but you are not authorized to access this page.';
adminLoginButton.classList.add('hidden');
adminLogoutButton.classList.remove('hidden');
}
async function loadAdminState() {
if (!accessToken) {
showSignedOutState();
return;
}
const sessionResponse = await fetch(`/api/auth/me?token=${encodeURIComponent(accessToken)}`);
if (!sessionResponse.ok) {
localStorage.removeItem('linklogAccessToken');
showSignedOutState();
return;
}
const user = await sessionResponse.json();
if (!user.is_admin) {
showUnauthorizedState();
return;
}
await Promise.all([loadUsers(), loadPlugins()]);
showAdminState(true);
}
async function loadPlugins() {
const response = await fetch('/api/admin/plugins', {headers: authHeaders()});
if (!response.ok) throw new Error('Could not load plugins');
renderPlugins(await response.json());
}
async function updatePlugin(plugin, button) {
button.disabled = true;
const response = await fetch(`/api/admin/plugins/${encodeURIComponent(plugin.name)}`, {
method: 'PUT',
headers: authHeaders(true),
body: JSON.stringify({enabled: !plugin.enabled}),
});
if (response.ok) {
await loadPlugins();
} else {
button.disabled = false;
}
}
async function removeUser(user, button) {
if (!window.confirm(`Remove ${user.username}?`)) return;
button.disabled = true;
const response = await fetch(`/api/admin/users/${encodeURIComponent(user.id)}`, {
method: 'DELETE',
headers: authHeaders(),
});
if (response.ok) {
await loadUsers();
} else {
button.disabled = false;
}
}
async function updateUserPrivilege(user, checkbox) {
checkbox.disabled = true;
const response = await fetch(`/api/admin/users/${encodeURIComponent(user.id)}`, {
method: 'PUT',
headers: authHeaders(true),
body: JSON.stringify({is_admin: checkbox.checked}),
});
if (response.ok) {
await loadUsers();
} else {
checkbox.checked = user.is_admin;
checkbox.disabled = false;
}
}
userForm.addEventListener('submit', async (event) => {
event.preventDefault();
const values = Object.fromEntries(new FormData(userForm));
values.is_admin = userForm.elements.is_admin.checked;
const response = await fetch('/api/admin/users', {
method: 'POST',
headers: authHeaders(true),
body: JSON.stringify(values),
});
const status = document.querySelector('#user-status');
status.textContent = response.ok ? 'User added.' : 'Could not add user.';
status.style.color = response.ok ? '#94e2d5' : '#f38ba8';
if (response.ok) {
userForm.reset();
await loadUsers();
}
});
loadAdminState().catch((error) => {
showAdminState(false);
adminAuthNotice.textContent = accessToken
? `Could not verify administrator access: ${error.message}`
: 'Administrator sign-in required.';
adminAuthNotice.classList.remove('hidden');
userList.textContent = '';
pluginList.textContent = '';
});
})();
+119
View File
@@ -0,0 +1,119 @@
const feedEl = document.getElementById('feed');
const sortSelect = document.getElementById('sort-select');
const userFilter = document.getElementById('user-filter');
const cookieName = 'linklog-feed-preferences';
function readPreferences() {
const cookie = document.cookie
.split('; ')
.find((row) => row.startsWith(`${cookieName}=`));
if (!cookie) {
return { sort: 'newest', user: '' };
}
try {
return JSON.parse(decodeURIComponent(cookie.split('=')[1]));
} catch (error) {
return { sort: 'newest', user: '' };
}
}
function writePreferences(pref) {
const value = encodeURIComponent(JSON.stringify(pref));
document.cookie = `${cookieName}=${value}; path=/; max-age=31536000`;
}
function renderFeed(items) {
feedEl.innerHTML = '';
if (!items.length) {
feedEl.innerHTML = '<div class="link-item"><p>No links yet.</p></div>';
return;
}
items.forEach((item) => {
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;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.textContent = item.title || item.url;
title.appendChild(link);
const comment = document.createElement('div');
comment.className = 'comment';
comment.textContent = item.comment || 'No comment provided';
const meta = document.createElement('div');
meta.className = 'meta';
meta.textContent = item.created_at || 'updated recently';
article.appendChild(header);
article.appendChild(title);
article.appendChild(comment);
article.appendChild(meta);
feedEl.appendChild(article);
});
}
async function loadFeed() {
const routeUser = document.body.dataset.userFilter;
const endpoint = routeUser
? `/api/public/feed/${encodeURIComponent(routeUser)}`
: '/api/public/feed';
const response = await fetch(endpoint);
const data = await response.json();
let items = data || [];
const pref = readPreferences();
if (pref.user && !routeUser) {
items = items.filter((item) => (item.user?.username || '').toLowerCase() === pref.user.toLowerCase());
}
if (pref.sort === 'oldest') {
items = [...items].reverse();
}
renderFeed(items);
}
function syncPreferences() {
const pref = readPreferences();
sortSelect.value = pref.sort;
userFilter.value = pref.user;
sortSelect.addEventListener('change', (event) => {
const next = { ...readPreferences(), sort: event.target.value };
writePreferences(next);
loadFeed();
});
userFilter.addEventListener('input', (event) => {
const next = { ...readPreferences(), user: event.target.value.trim() };
writePreferences(next);
loadFeed();
});
}
syncPreferences();
loadFeed();
+24
View File
@@ -0,0 +1,24 @@
const form = document.querySelector('#login-form');
const status = document.querySelector('#login-status');
form.addEventListener('submit', async (event) => {
event.preventDefault();
status.textContent = 'Signing in...';
status.style.color = '#334155';
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(Object.fromEntries(new FormData(form))),
});
if (!response.ok) {
status.textContent = 'Sign-in failed.';
status.style.color = '#b91c1c';
return;
}
const data = await response.json();
localStorage.setItem('linklogAccessToken', data.access_token);
window.location.assign('/profile');
});
+20
View File
@@ -0,0 +1,20 @@
(() => {
const logoutButton = document.querySelector('#logout-button');
logoutButton.addEventListener('click', async () => {
const token = localStorage.getItem('linklogAccessToken');
logoutButton.disabled = true;
logoutButton.textContent = 'Signing out...';
if (token) {
await fetch('/api/auth/logout', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({token}),
}).catch(() => undefined);
}
localStorage.removeItem('linklogAccessToken');
window.location.assign('/login');
});
})();
+71
View File
@@ -0,0 +1,71 @@
(() => {
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: "';
function authHeaders(includeJson = false) {
return {
...(includeJson ? {'Content-Type': 'application/json'} : {}),
...(accessToken ? {Authorization: `Bearer ${accessToken}`} : {}),
};
}
function setStatus(selector, message, isError = false) {
const status = document.querySelector(selector);
status.textContent = message;
status.style.color = isError ? '#b91c1c' : '#166534';
}
async function loadProfile() {
const response = await fetch('/api/user/me', {headers: authHeaders()});
if (!response.ok) throw new Error('Could not load profile');
const profile = await response.json();
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;
if (profile.is_admin) {
document.querySelector('#admin-link').classList.remove('hidden');
}
profileLogoutButton.classList.remove('hidden');
}
async function loadMastodonConfig() {
const response = await fetch('/api/user/plugins/mastodon', {headers: authHeaders()});
if (!response.ok) throw new Error('Could not load Mastodon settings');
const config = await response.json();
document.querySelector('#mastodon-instance').value = config.instance || defaultMastodonInstance;
document.querySelector('#mastodon-access-token').value = config.access_token || '';
document.querySelector('#mastodon-post-prefix').value = config.post_prefix || defaultPostPrefix;
}
profileForm.addEventListener('submit', async (event) => {
event.preventDefault();
const formData = new FormData(profileForm);
const response = await fetch('/api/user/me', {
method: 'PUT',
headers: authHeaders(true),
body: JSON.stringify(Object.fromEntries(formData)),
});
setStatus('#profile-status', response.ok ? 'Profile saved.' : 'Could not save profile.', !response.ok);
});
mastodonForm.addEventListener('submit', async (event) => {
event.preventDefault();
const formData = new FormData(mastodonForm);
const response = await fetch('/api/user/plugins/mastodon', {
method: 'PUT',
headers: authHeaders(true),
body: JSON.stringify(Object.fromEntries(formData)),
});
setStatus('#mastodon-status', response.ok ? 'Mastodon settings saved.' : 'Could not save Mastodon settings.', !response.ok);
});
Promise.all([loadProfile(), loadMastodonConfig()]).catch((error) => {
setStatus('#profile-status', accessToken ? error.message : 'Please sign in first.', true);
});
})();
+464
View File
@@ -0,0 +1,464 @@
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&display=swap');
:root {
--base: #1e1e2e;
--mantle: #181825;
--crust: #11111b;
--surface-0: #313244;
--surface-1: #45475a;
--surface-2: #585b70;
--text: #cdd6f4;
--subtext: #a6adc8;
--muted: #7f849c;
--mauve: #cba6f7;
--lavender: #b4befe;
--blue: #89b4fa;
--teal: #94e2d5;
--peach: #fab387;
--red: #f38ba8;
--border: rgba(205, 214, 244, 0.12);
--shadow: 0 18px 50px rgba(17, 17, 27, 0.28);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
min-height: 100vh;
margin: 0;
background:
linear-gradient(145deg, rgba(203, 166, 247, 0.06), transparent 36%),
var(--base);
color: var(--text);
font-family: 'DM Sans', 'Avenir Next', sans-serif;
line-height: 1.5;
}
body::selection {
background: var(--mauve);
color: var(--crust);
}
.container {
width: 100%;
max-width: 960px;
margin: 0 auto;
padding: 0 24px;
}
.site-header {
position: relative;
overflow: hidden;
padding: 56px 0 48px;
background:
linear-gradient(115deg, rgba(203, 166, 247, 0.18), transparent 45%),
var(--mantle);
border-bottom: 1px solid var(--border);
}
.site-header::after {
position: absolute;
right: 12%;
bottom: -52px;
width: 180px;
height: 100px;
border-top: 1px solid rgba(180, 190, 254, 0.28);
border-radius: 50%;
content: '';
transform: rotate(-12deg);
}
.site-header h1,
.link-item h2 {
font-family: 'Space Grotesk', 'Avenir Next', sans-serif;
letter-spacing: 0;
}
.site-header h1 {
margin: 0;
color: var(--text);
font-size: clamp(2.2rem, 5vw, 4rem);
line-height: 1;
}
.site-header p {
max-width: 34rem;
margin: 14px 0 0;
color: var(--subtext);
font-size: 1.05rem;
}
.header-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
}
.header-actions {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 8px;
}
.login-button {
flex: 0 0 auto;
margin-top: 2px;
padding: 10px 16px;
border: 1px solid var(--surface-2);
border-radius: 8px;
background: var(--surface-0);
color: var(--text);
font-weight: 700;
text-decoration: none;
transition: background 160ms ease, border-color 160ms ease;
}
.login-button:hover {
border-color: var(--lavender);
background: var(--surface-1);
color: var(--text);
}
.logout-button {
min-width: 0;
padding: 10px 13px;
border-color: rgba(243, 139, 168, 0.45);
background: transparent;
color: var(--red);
}
.logout-button:hover {
border-color: var(--red);
background: rgba(243, 139, 168, 0.12);
color: var(--red);
}
main.container {
padding-top: 28px;
padding-bottom: 64px;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 14px;
margin: 0 0 24px;
padding: 16px;
background: rgba(24, 24, 37, 0.72);
border: 1px solid var(--border);
border-radius: 12px;
}
.toolbar label,
.settings-panel label {
display: grid;
gap: 7px;
color: var(--subtext);
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.checkbox-label {
display: flex !important;
align-items: center;
gap: 9px !important;
text-transform: none !important;
}
.checkbox-label input {
width: auto;
min-width: 0;
}
.toolbar label {
flex: 1 1 220px;
}
select,
input,
textarea,
button {
max-width: 100%;
min-width: 180px;
padding: 11px 13px;
border: 1px solid var(--surface-1);
border-radius: 8px;
background: var(--surface-0);
color: var(--text);
font: inherit;
}
select:focus,
input:focus,
textarea:focus,
button:focus-visible {
outline: 2px solid var(--lavender);
outline-offset: 2px;
border-color: var(--lavender);
}
::placeholder {
color: var(--muted);
}
.settings-panel form {
display: grid;
gap: 16px;
max-width: 560px;
}
.settings-panel textarea {
min-height: 110px;
resize: vertical;
}
.readonly-value {
display: block;
padding: 11px 13px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--mantle);
color: var(--lavender);
font-weight: 600;
}
button {
width: fit-content;
border-color: var(--mauve);
background: var(--mauve);
color: var(--crust);
cursor: pointer;
font-weight: 700;
transition: filter 160ms ease, transform 160ms ease;
}
button:hover {
filter: brightness(1.08);
transform: translateY(-1px);
}
button:disabled {
cursor: wait;
opacity: 0.6;
transform: none;
}
.status {
min-height: 1.25em;
margin: 0;
color: var(--teal);
}
.hidden {
display: none;
}
.auth-notice {
margin: 0 0 20px;
color: var(--subtext);
}
.auth-notice a,
.link-item a {
color: var(--lavender);
}
.auth-notice a:hover,
.link-item a:hover {
color: var(--mauve);
}
.plugin-list,
.feed {
display: grid;
gap: 14px;
}
.plugin-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 0;
border-bottom: 1px solid var(--border);
}
.plugin-row:last-child {
border-bottom: 0;
}
.plugin-row button {
min-width: 0;
padding: 8px 12px;
background: var(--surface-1);
border-color: var(--surface-2);
color: var(--text);
}
.user-admin-toggle {
display: flex;
align-items: center;
gap: 7px;
color: var(--subtext);
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
}
.user-admin-toggle input {
width: auto;
min-width: 0;
}
.danger-button {
border-color: rgba(243, 139, 168, 0.45) !important;
background: transparent !important;
color: var(--red) !important;
}
.feed {
padding-bottom: 40px;
}
.link-item {
padding: 22px;
background: rgba(49, 50, 68, 0.84);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: var(--shadow);
}
.link-item h2 {
margin: 0 0 8px;
color: var(--text);
font-size: 1.2rem;
line-height: 1.25;
}
.link-header {
display: inline-flex;
align-items: center;
float: right;
gap: 12px;
margin: 0 0 12px 18px;
}
.avatar {
display: grid;
width: 38px;
height: 38px;
flex: 0 0 38px;
place-items: center;
border: 2px solid rgba(203, 166, 247, 0.5);
border-radius: 50%;
background: var(--surface-1);
color: var(--mauve);
font-weight: 700;
}
.user-name {
color: var(--lavender);
font-weight: 700;
}
.link-item a {
text-decoration: none;
overflow-wrap: anywhere;
}
.link-item::after {
display: table;
clear: both;
content: '';
}
.comment,
.profile-summary p {
margin: 12px 0 0;
color: var(--subtext);
line-height: 1.65;
}
.meta {
margin-top: 14px;
color: var(--muted);
font-size: 0.82rem;
}
@media (max-width: 600px) {
.container {
padding: 0 14px;
}
.site-header {
padding: 38px 0 34px;
}
.site-header h1 {
font-size: 2.35rem;
}
.header-row {
align-items: center;
gap: 12px;
}
.header-actions {
align-items: stretch;
flex-direction: column;
}
.login-button {
padding: 8px 11px;
font-size: 0.9rem;
}
.logout-button {
padding: 8px 11px;
font-size: 0.9rem;
}
main.container {
padding-top: 18px;
}
.toolbar {
gap: 12px;
padding: 14px;
}
.toolbar label,
select,
input,
textarea,
.settings-panel button {
width: 100%;
min-width: 0;
}
.link-item {
padding: 16px;
border-radius: 10px;
}
.link-header {
margin-left: 14px;
}
.plugin-row {
align-items: stretch;
flex-direction: column;
gap: 9px;
}
.plugin-row button {
align-self: flex-start;
}
}
+62
View File
@@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>LinkLog Admin</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<header class="site-header">
<div class="container">
<div class="header-row">
<div>
<h1>LinkLog Admin</h1>
<p>Manage users and plugin configuration</p>
</div>
<div class="header-actions">
<a id="admin-login-button" class="login-button" href="/login">Sign in</a>
<button id="logout-button" class="logout-button hidden" type="button">Sign out</button>
</div>
</div>
</div>
</header>
<main class="container">
<p id="admin-auth-notice" class="auth-notice hidden"></p>
<div id="admin-controls" class="hidden">
<section class="link-item settings-panel">
<h2>Users</h2>
<form id="user-form">
<label>
Username
<input name="username" type="text" required />
</label>
<label>
Email
<input name="email" type="email" required />
</label>
<label>
Password
<input name="password" type="password" minlength="8" required />
</label>
<label class="checkbox-label">
<input name="is_admin" type="checkbox" />
Administrator
</label>
<button type="submit">Add user</button>
<p id="user-status" class="status" role="status"></p>
</form>
<div id="user-list" class="plugin-list" aria-live="polite">Loading users...</div>
</section>
<section class="link-item settings-panel">
<h2>Plugins</h2>
<div id="plugin-list" class="plugin-list" aria-live="polite">Loading plugins...</div>
</section>
</div>
</main>
<script src="/static/logout.js?v=2"></script>
<script src="/static/admin.js?v=2"></script>
</body>
</html>
+51
View File
@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>LinkLog Feed</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/static/style.css" />
</head>
<body data-user-filter="{{ user_filter or '' }}">
<header class="site-header">
<div class="container">
<div class="header-row">
<div>
<h1>LinkLog</h1>
<p>Public link feed</p>
</div>
<a class="login-button" href="/login">Sign in</a>
</div>
</div>
</header>
<main class="container">
{% if profile %}
<section class="link-item profile-summary">
<div class="link-header">
<div class="avatar">{{ profile.username[:1].upper() }}</div>
<div class="user-name">{{ profile.username }}</div>
</div>
<p>{{ profile.bio or 'No profile information provided.' }}</p>
</section>
{% endif %}
<section class="toolbar">
<label>
Sort
<select id="sort-select">
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
</select>
</label>
<label>
User filter
<input id="user-filter" type="text" placeholder="alice" />
</label>
</section>
<section id="feed" class="feed" aria-live="polite"></section>
</main>
<script src="/static/feed.js"></script>
</body>
</html>
+34
View File
@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Sign in - LinkLog</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<header class="site-header">
<div class="container">
<h1>Sign in</h1>
<p>Access your LinkLog settings</p>
</div>
</header>
<main class="container">
<section class="link-item settings-panel">
<form id="login-form">
<label>
Username
<input id="username" name="username" type="text" autocomplete="username" required />
</label>
<label>
Password
<input id="password" name="password" type="password" autocomplete="current-password" required />
</label>
<button type="submit">Sign in</button>
<p id="login-status" class="status" role="status"></p>
</form>
</section>
</main>
<script src="/static/login.js"></script>
</body>
</html>
+71
View File
@@ -0,0 +1,71 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>LinkLog Profile</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<header class="site-header">
<div class="container">
<div class="header-row">
<h1>Profile</h1>
<div class="header-actions">
<a id="admin-link" class="login-button hidden" href="/admin">Admin</a>
<button id="logout-button" class="logout-button hidden" type="button">Sign out</button>
</div>
</div>
</div>
</header>
<main class="container">
<p class="auth-notice">Sign in at <a href="/login">/login</a> to manage your settings.</p>
<section class="link-item settings-panel">
<h2>Profile Settings</h2>
<form id="profile-form">
<label>
Username
<output id="username" class="readonly-value">Loading...</output>
</label>
<label>
Email
<input id="email" name="email" type="email" required />
</label>
<label>
Bio
<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" />
</label>
<button type="submit">Save profile</button>
<p id="profile-status" class="status" role="status"></p>
</form>
</section>
<section class="link-item settings-panel">
<h2>Mastodon</h2>
<form id="mastodon-form">
<label>
Instance
<input id="mastodon-instance" name="instance" type="text" value="mastodon.social" placeholder="mastodon.social" />
</label>
<label>
Access token
<input id="mastodon-access-token" name="access_token" type="password" autocomplete="off" />
</label>
<label>
Post prefix
<input id="mastodon-post-prefix" name="post_prefix" type="text" value="From my #LinkLog: &quot;" />
</label>
<button type="submit">Save Mastodon settings</button>
<p id="mastodon-status" class="status" role="status"></p>
</form>
</section>
</main>
<script src="/static/logout.js?v=2"></script>
<script src="/static/profile.js?v=2"></script>
</body>
</html>