Initial LinkLog implementation
This commit is contained in:
@@ -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 = '';
|
||||
});
|
||||
})();
|
||||
@@ -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();
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
})();
|
||||
@@ -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);
|
||||
});
|
||||
})();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user