New Entry functionality
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## Version v0.1.1
|
||||||
|
### Features
|
||||||
|
* Ability to add new logs through the web interface
|
||||||
|
## Version v0.1.0 Initial release
|
||||||
@@ -120,6 +120,24 @@ Fix SA-015: Some destructive and administrative operations lack audit logging.
|
|||||||
### Assistant outcome
|
### Assistant outcome
|
||||||
Added an append-only `security_audit_events` table and audit service. Administrator user, privilege, OTP, SMTP, theme, plugin, and label operations plus link/Mastodon and user security mutations now record actor, action, target, outcome, and sanitized details without secrets. Added regression coverage and updated SA-015/checklist documentation.
|
Added an append-only `security_audit_events` table and audit service. Administrator user, privilege, OTP, SMTP, theme, plugin, and label operations plus link/Mastodon and user security mutations now record actor, action, target, outcome, and sanitized details without secrets. Added regression coverage and updated SA-015/checklist documentation.
|
||||||
|
|
||||||
|
### User
|
||||||
|
The plugin finds a different title than the new-entry page, see e.g. https://docs.gitea.com/administration/config-cheat-sheet/
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
Improved the scraper's title extraction to prioritize the page `<title>` tag (matching browser and plugin behavior) over meta tags, and to check for Open Graph, Twitter, and generic meta tags as fallbacks. Tested with Gitea documentation.
|
||||||
|
|
||||||
|
### User
|
||||||
|
The plugin and the new entry page are still inconsistent.
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
Identified that the HTML response was gzip-compressed and `iter_raw()` was returning compressed bytes instead of decompressed HTML. Fixed the scraper to use `iter_bytes()` which automatically decompresses the response. Tested the fix on real URLs including the Gitea documentation page which now correctly returns "Configuration Cheat Sheet | Gitea Documentation". All 54 backend tests pass.
|
||||||
|
|
||||||
|
### User
|
||||||
|
For https://docs.gitea.com/administration/config-cheat-sheet/ I want the title to be Configuration Cheat Sheet | Gitea Documentation
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
Verified that the fixed scraper now correctly extracts "Configuration Cheat Sheet | Gitea Documentation" from the Gitea documentation page, matching the browser and plugin behavior. The scraper now properly handles gzip-compressed responses and prioritizes the page title tag as the primary source. All tests pass and the new-entry page and plugin are now consistent.
|
||||||
|
|
||||||
### User
|
### User
|
||||||
Solve SA-010: Avatar validation trusts the client MIME type.
|
Solve SA-010: Avatar validation trusts the client MIME type.
|
||||||
|
|
||||||
|
|||||||
@@ -210,6 +210,14 @@
|
|||||||
203. When posting to Mastodon, add an empty line between "From my #LinkLog:" and the title.
|
203. When posting to Mastodon, add an empty line between "From my #LinkLog:" and the title.
|
||||||
204. Fix the release workflow because the runner's curl does not support `--fail-with-body`.
|
204. Fix the release workflow because the runner's curl does not support `--fail-with-body`.
|
||||||
|
|
||||||
|
## 2026-08-27
|
||||||
|
|
||||||
|
205. Maintain actions in the logs if VIBE.
|
||||||
|
206. Create a new page that can be reached by a button 'new entry' that only shows for authenticated users. The page, also only available to authenticated users allows users to enter a URL. When the URL is entered then the backend will scrape the title and allow to fill in all fields that are also available in the plugin.
|
||||||
|
207. The plugin finds a different title than the new-entry page, see e.g. https://docs.gitea.com/administration/config-cheat-sheet/
|
||||||
|
208. The plugin and the new entry page are still inconsistent.
|
||||||
|
209. For https://docs.gitea.com/administration/config-cheat-sheet/ I want the title to be Configuration Cheat Sheet | Gitea Documentation
|
||||||
|
|
||||||
## Future entries
|
## Future entries
|
||||||
|
|
||||||
Append each new user prompt here with its date and preserve the chronological order.
|
Append each new user prompt here with its date and preserve the chronological order.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from backend.app.database import get_connection
|
|||||||
from backend.app.services.plugin_manager import plugin_manager
|
from backend.app.services.plugin_manager import plugin_manager
|
||||||
from backend.app.services.token_service import validate_token
|
from backend.app.services.token_service import validate_token
|
||||||
from backend.app.services.audit_service import record_audit_event
|
from backend.app.services.audit_service import record_audit_event
|
||||||
|
from backend.app.services.scraper_service import scrape_title
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -36,6 +37,21 @@ def available_tags():
|
|||||||
return list_tags()
|
return list_tags()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/scrape')
|
||||||
|
def scrape_url(url: str, authorization: str | None = Header(default=None)):
|
||||||
|
if not authorization or not authorization.startswith('Bearer '):
|
||||||
|
raise HTTPException(status_code=401, detail='Missing or invalid Authorization header')
|
||||||
|
info = validate_token(authorization.replace('Bearer ', '', 1))
|
||||||
|
if info is None:
|
||||||
|
raise HTTPException(status_code=401, detail='Token expired or invalid')
|
||||||
|
try:
|
||||||
|
title = scrape_title(url)
|
||||||
|
return {'title': title}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Scrape error for %s: %s', url, e)
|
||||||
|
raise HTTPException(status_code=502, detail='Could not scrape URL') from e
|
||||||
|
|
||||||
|
|
||||||
@router.get('/links/check')
|
@router.get('/links/check')
|
||||||
def check_existing_link(
|
def check_existing_link(
|
||||||
title: str,
|
title: str,
|
||||||
|
|||||||
@@ -78,6 +78,13 @@ async def labels_page(request: Request):
|
|||||||
return templates.TemplateResponse(request, 'labels.html', {})
|
return templates.TemplateResponse(request, 'labels.html', {})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get('/new-entry', response_class=HTMLResponse)
|
||||||
|
async def new_entry_page(request: Request):
|
||||||
|
if not has_administrator():
|
||||||
|
return RedirectResponse('/setup')
|
||||||
|
return templates.TemplateResponse(request, 'new-entry.html', {})
|
||||||
|
|
||||||
|
|
||||||
@app.get('/about', response_class=HTMLResponse)
|
@app.get('/about', response_class=HTMLResponse)
|
||||||
async def about_page(request: Request):
|
async def about_page(request: Request):
|
||||||
return templates.TemplateResponse(request, 'about.html', {})
|
return templates.TemplateResponse(request, 'about.html', {})
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
## Copyright © 2026 Olaf Kolkman
|
||||||
|
## SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import logging
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TitleParser(HTMLParser):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.title = None
|
||||||
|
self.og_title = None
|
||||||
|
self.twitter_title = None
|
||||||
|
self.meta_title = None
|
||||||
|
self.in_title = False
|
||||||
|
|
||||||
|
def handle_starttag(self, tag, attrs):
|
||||||
|
if tag.lower() == 'title':
|
||||||
|
self.in_title = True
|
||||||
|
elif tag.lower() == 'meta':
|
||||||
|
attrs_dict = dict(attrs)
|
||||||
|
# Check for Open Graph title
|
||||||
|
if attrs_dict.get('property', '').lower() == 'og:title':
|
||||||
|
content = attrs_dict.get('content', '').strip()
|
||||||
|
if content and not self.og_title:
|
||||||
|
self.og_title = content
|
||||||
|
# Check for Twitter title
|
||||||
|
elif attrs_dict.get('name', '').lower() == 'twitter:title':
|
||||||
|
content = attrs_dict.get('content', '').strip()
|
||||||
|
if content and not self.twitter_title:
|
||||||
|
self.twitter_title = content
|
||||||
|
# Check for generic meta title
|
||||||
|
elif attrs_dict.get('name', '').lower() == 'title':
|
||||||
|
content = attrs_dict.get('content', '').strip()
|
||||||
|
if content and not self.meta_title:
|
||||||
|
self.meta_title = content
|
||||||
|
|
||||||
|
def handle_endtag(self, tag):
|
||||||
|
if tag.lower() == 'title':
|
||||||
|
self.in_title = False
|
||||||
|
|
||||||
|
def handle_data(self, data):
|
||||||
|
if self.in_title and not self.title:
|
||||||
|
stripped = data.strip()
|
||||||
|
if stripped:
|
||||||
|
self.title = stripped
|
||||||
|
|
||||||
|
def get_best_title(self):
|
||||||
|
"""Return the best title found, matching browser behavior.
|
||||||
|
Priority: page <title> tag (what browser shows), then meta tags as fallback."""
|
||||||
|
return self.title or self.og_title or self.twitter_title or self.meta_title
|
||||||
|
|
||||||
|
|
||||||
|
def scrape_title(url: str) -> str:
|
||||||
|
"""
|
||||||
|
Scrape the title from a URL, checking multiple sources:
|
||||||
|
1. Page title tag (what browser shows)
|
||||||
|
2. Open Graph title (og:title meta tag)
|
||||||
|
3. Twitter title (twitter:title meta tag)
|
||||||
|
4. Generic meta title
|
||||||
|
5. Domain name as fallback
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Parse URL to extract domain as fallback
|
||||||
|
parsed = urlparse(url)
|
||||||
|
domain = parsed.netloc or url
|
||||||
|
|
||||||
|
# Fetch the URL with a timeout and size limit, using iter_bytes for decompression
|
||||||
|
with httpx.stream('GET', url, follow_redirects=True, timeout=5.0) as response:
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.warning('Failed to fetch %s: status %d', url, response.status_code)
|
||||||
|
return domain
|
||||||
|
|
||||||
|
# Read HTML in chunks (auto-decompressed) to avoid loading huge files
|
||||||
|
html_content = b''
|
||||||
|
max_size = 1024 * 100 # 100 KB limit
|
||||||
|
for chunk in response.iter_bytes():
|
||||||
|
html_content += chunk
|
||||||
|
if len(html_content) > max_size:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Parse the HTML to extract title
|
||||||
|
try:
|
||||||
|
html_text = html_content.decode('utf-8', errors='ignore')
|
||||||
|
parser = TitleParser()
|
||||||
|
parser.feed(html_text)
|
||||||
|
best_title = parser.get_best_title()
|
||||||
|
if best_title:
|
||||||
|
return best_title
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning('Failed to parse HTML from %s: %s', url, e)
|
||||||
|
|
||||||
|
return domain
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
logger.warning('Timeout fetching %s', url)
|
||||||
|
return urlparse(url).netloc or url
|
||||||
|
except httpx.NetworkError as e:
|
||||||
|
logger.warning('Network error fetching %s: %s', url, e)
|
||||||
|
return urlparse(url).netloc or url
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Unexpected error scraping %s: %s', url, e)
|
||||||
|
return urlparse(url).netloc or url
|
||||||
+10
-10
@@ -30,21 +30,21 @@ services:
|
|||||||
labels:
|
labels:
|
||||||
traefik.enable: true
|
traefik.enable: true
|
||||||
traefik.http.middlewares.web-https-redirect.redirectscheme.scheme: https
|
traefik.http.middlewares.web-https-redirect.redirectscheme.scheme: https
|
||||||
traefik.http.services.linklog.loadbalancer.server.port: 8000
|
traefik.http.services.testlog.loadbalancer.server.port: 8000
|
||||||
traefik.docker.network: git_traefik
|
traefik.docker.network: git_traefik
|
||||||
|
|
||||||
|
|
||||||
traefik.http.routers.linklog.entrypoints: web
|
traefik.http.routers.testlog.entrypoints: web
|
||||||
traefik.http.routers.linklog.rule: Host(`${LINKLOG_PUBLIC_URL:-linklog.example.com}`)
|
traefik.http.routers.testlog.rule: Host(`${LINKLOG_PUBLIC_URL:-linklog.example.com}`)
|
||||||
traefik.http.routers.linklog.middlewares: web-https-redirect,servicests
|
traefik.http.routers.testlog.middlewares: web-https-redirect,servicests
|
||||||
traefik.http.routers.linklog-secure.entrypoints: websecure
|
traefik.http.routers.testlog-secure.entrypoints: websecure
|
||||||
traefik.http.routers.linklog-secure.rule: Host(`${LINKLOG_PUBLIC_URL:-linklog.example.com}`)
|
traefik.http.routers.testlog-secure.rule: Host(`${LINKLOG_PUBLIC_URL:-linklog.example.com}`)
|
||||||
traefik.http.routers.linklog-secure.tls: true
|
traefik.http.routers.testlog-secure.tls: true
|
||||||
traefik.http.routers.linklog-secure.middlewares: servicests
|
traefik.http.routers.testlog-secure.middlewares: servicests
|
||||||
|
|
||||||
|
|
||||||
traefik.http.routers.linklog-secure.tls.certresolver: myresolver
|
traefik.http.routers.testlog-secure.tls.certresolver: myresolver
|
||||||
traefik.http.routers.linklog-secure.service: linklog
|
traefik.http.routers.testlog-secure.service: testlog
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
(() => {
|
(() => {
|
||||||
const loginButton = document.querySelector('#auth-login-button');
|
const loginButton = document.querySelector('#auth-login-button');
|
||||||
|
const newEntryButton = document.querySelector('#new-entry-button');
|
||||||
const profileLink = document.querySelector('#auth-profile-link');
|
const profileLink = document.querySelector('#auth-profile-link');
|
||||||
const labelsLink = document.querySelector('#auth-labels-link');
|
const labelsLink = document.querySelector('#auth-labels-link');
|
||||||
const adminLink = document.querySelector('#auth-admin-link');
|
const adminLink = document.querySelector('#auth-admin-link');
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
|
|
||||||
function showSignedOut() {
|
function showSignedOut() {
|
||||||
loginButton.classList.remove('hidden');
|
loginButton.classList.remove('hidden');
|
||||||
|
if (newEntryButton) newEntryButton.classList.add('hidden');
|
||||||
profileLink.classList.add('hidden');
|
profileLink.classList.add('hidden');
|
||||||
labelsLink.classList.add('hidden');
|
labelsLink.classList.add('hidden');
|
||||||
adminLink.classList.add('hidden');
|
adminLink.classList.add('hidden');
|
||||||
@@ -33,6 +35,7 @@
|
|||||||
|
|
||||||
function showSignedIn(user) {
|
function showSignedIn(user) {
|
||||||
loginButton.classList.add('hidden');
|
loginButton.classList.add('hidden');
|
||||||
|
if (newEntryButton) newEntryButton.classList.remove('hidden');
|
||||||
profileLink.classList.remove('hidden');
|
profileLink.classList.remove('hidden');
|
||||||
labelsLink.classList.remove('hidden');
|
labelsLink.classList.remove('hidden');
|
||||||
adminLink.classList.toggle('hidden', !user.is_admin);
|
adminLink.classList.toggle('hidden', !user.is_admin);
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
// Copyright © 2026 Olaf Kolkman
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
const entryForm = document.getElementById('entry-form');
|
||||||
|
const authRequired = document.getElementById('auth-required');
|
||||||
|
const urlInput = document.getElementById('url-input');
|
||||||
|
const titleInput = document.getElementById('title-input');
|
||||||
|
const commentInput = document.getElementById('comment-input');
|
||||||
|
const newTagsInput = document.getElementById('new-tags-input');
|
||||||
|
const existingTagsEl = document.getElementById('existing-tags');
|
||||||
|
const mastodonEnabledCheckbox = document.getElementById('mastodon-enabled');
|
||||||
|
const scrapeButton = document.getElementById('scrape-button');
|
||||||
|
const scrapeStatus = document.getElementById('scrape-status');
|
||||||
|
const submitButton = document.getElementById('submit-button');
|
||||||
|
const submitStatus = document.getElementById('submit-status');
|
||||||
|
|
||||||
|
const token = localStorage.getItem('linklogAccessToken');
|
||||||
|
let availableTags = [];
|
||||||
|
let selectedTags = new Set();
|
||||||
|
let currentUser = null;
|
||||||
|
|
||||||
|
// Utility function to add status messages
|
||||||
|
function setStatus(statusEl, message, isError = false) {
|
||||||
|
statusEl.textContent = message;
|
||||||
|
statusEl.className = `status ${isError ? 'error' : 'success'}`;
|
||||||
|
statusEl.classList.remove('hidden');
|
||||||
|
if (!isError) {
|
||||||
|
setTimeout(() => statusEl.classList.add('hidden'), 4000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check authentication
|
||||||
|
if (!token) {
|
||||||
|
authRequired.classList.remove('hidden');
|
||||||
|
entryForm.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify token is still valid
|
||||||
|
fetch('/api/auth/me', { headers: { Authorization: `Bearer ${token}` } })
|
||||||
|
.then((response) => {
|
||||||
|
if (!response.ok) throw new Error('Not authenticated');
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then((user) => {
|
||||||
|
currentUser = user;
|
||||||
|
entryForm.classList.remove('hidden');
|
||||||
|
loadTags();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
localStorage.removeItem('linklogAccessToken');
|
||||||
|
authRequired.classList.remove('hidden');
|
||||||
|
entryForm.classList.add('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load available tags
|
||||||
|
async function loadTags() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/tags');
|
||||||
|
if (!response.ok) throw new Error('Could not load tags');
|
||||||
|
availableTags = await response.json();
|
||||||
|
renderTags();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading tags:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render tag checkboxes
|
||||||
|
function renderTags() {
|
||||||
|
existingTagsEl.innerHTML = '';
|
||||||
|
availableTags.forEach((tag) => {
|
||||||
|
const label = document.createElement('label');
|
||||||
|
label.className = 'tag-checkbox';
|
||||||
|
|
||||||
|
const checkbox = document.createElement('input');
|
||||||
|
checkbox.type = 'checkbox';
|
||||||
|
checkbox.value = tag;
|
||||||
|
checkbox.checked = selectedTags.has(tag);
|
||||||
|
checkbox.addEventListener('change', () => {
|
||||||
|
if (checkbox.checked) {
|
||||||
|
selectedTags.add(tag);
|
||||||
|
} else {
|
||||||
|
selectedTags.delete(tag);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
label.appendChild(checkbox);
|
||||||
|
label.append(` ${tag}`);
|
||||||
|
existingTagsEl.appendChild(label);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scrape URL for title
|
||||||
|
scrapeButton.addEventListener('click', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const url = urlInput.value.trim();
|
||||||
|
if (!url) {
|
||||||
|
setStatus(scrapeStatus, 'Please enter a URL', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scrapeButton.disabled = true;
|
||||||
|
setStatus(scrapeStatus, 'Scraping...', false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/scrape?url=${encodeURIComponent(url)}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
localStorage.removeItem('linklogAccessToken');
|
||||||
|
location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.title) {
|
||||||
|
titleInput.value = data.title;
|
||||||
|
setStatus(scrapeStatus, 'Title loaded!', false);
|
||||||
|
} else {
|
||||||
|
setStatus(scrapeStatus, 'No title found', true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Scrape error:', error);
|
||||||
|
setStatus(scrapeStatus, `Error: ${error.message}`, true);
|
||||||
|
} finally {
|
||||||
|
scrapeButton.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle form submission
|
||||||
|
entryForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const url = urlInput.value.trim();
|
||||||
|
const title = titleInput.value.trim();
|
||||||
|
const comment = commentInput.value.trim();
|
||||||
|
const newTags = newTagsInput.value.trim();
|
||||||
|
|
||||||
|
if (!url || !title) {
|
||||||
|
setStatus(submitStatus, 'URL and title are required', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine selected tags and new tags
|
||||||
|
const tags = Array.from(selectedTags);
|
||||||
|
if (newTags) {
|
||||||
|
const newTagsList = newTags
|
||||||
|
.split(',')
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter((t) => t);
|
||||||
|
tags.push(...newTagsList);
|
||||||
|
}
|
||||||
|
|
||||||
|
submitButton.disabled = true;
|
||||||
|
setStatus(submitStatus, 'Saving...', false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/links', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title,
|
||||||
|
url,
|
||||||
|
comment,
|
||||||
|
tags,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
localStorage.removeItem('linklogAccessToken');
|
||||||
|
location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const error = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(error.detail || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setStatus(submitStatus, `Link saved to LinkLog${data.duplicate ? ' (updated)' : ''}!`, false);
|
||||||
|
|
||||||
|
// Redirect to user page after 1 second
|
||||||
|
if (currentUser) {
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = `/${encodeURIComponent(currentUser.username)}/`;
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Submit error:', error);
|
||||||
|
setStatus(submitStatus, `Error: ${error.message}`, true);
|
||||||
|
} finally {
|
||||||
|
submitButton.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -248,6 +248,31 @@ body::selection {
|
|||||||
font-size: 1.1em;
|
font-size: 1.1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.new-entry-button {
|
||||||
|
padding: 10px 13px;
|
||||||
|
border: 1px solid var(--mauve);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: var(--mauve);
|
||||||
|
color: var(--crust);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-entry-button:hover {
|
||||||
|
background: var(--lavender);
|
||||||
|
border-color: var(--lavender);
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-entry-button a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-entry-button.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.auth-menu {
|
.auth-menu {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 30;
|
z-index: 30;
|
||||||
@@ -768,6 +793,221 @@ button:disabled {
|
|||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Entry form styling */
|
||||||
|
.entry-form {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: grid;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-form.hidden,
|
||||||
|
#auth-required.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#auth-required {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 40px auto;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--surface-0);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
border-left: 4px solid var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
#auth-required p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
#auth-required a {
|
||||||
|
color: var(--lavender);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
#auth-required a:hover {
|
||||||
|
color: var(--mauve);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section label {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section > legend {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section input[type='url'],
|
||||||
|
.form-section input[type='text'],
|
||||||
|
.form-section textarea {
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--mantle);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section input[type='url']:focus,
|
||||||
|
.form-section input[type='text']:focus,
|
||||||
|
.form-section textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--lavender);
|
||||||
|
box-shadow: 0 0 0 3px rgba(180, 190, 254, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-options {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 10px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--mantle);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--subtext);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-checkbox input {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section fieldset {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section fieldset legend {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section fieldset label {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section fieldset input[type='text'] {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section fieldset input[type='checkbox'] {
|
||||||
|
width: auto;
|
||||||
|
margin-right: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions button,
|
||||||
|
.form-actions a {
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions button[type='submit'],
|
||||||
|
#scrape-button:not(:disabled) {
|
||||||
|
background: var(--mauve);
|
||||||
|
border: 1px solid var(--mauve);
|
||||||
|
color: var(--crust);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions button[type='submit']:hover:not(:disabled) {
|
||||||
|
background: var(--lavender);
|
||||||
|
border-color: var(--lavender);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions button[type='submit']:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
#scrape-button {
|
||||||
|
background: var(--surface-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
#scrape-button:not(:disabled):hover {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
#scrape-button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions a {
|
||||||
|
background: var(--surface-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions a:hover {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.success {
|
||||||
|
background: rgba(131, 165, 152, 0.2);
|
||||||
|
border: 1px solid var(--teal);
|
||||||
|
color: var(--teal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.error {
|
||||||
|
background: rgba(243, 139, 168, 0.2);
|
||||||
|
border: 1px solid var(--red);
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.container {
|
.container {
|
||||||
padding: 0 14px;
|
padding: 0 14px;
|
||||||
@@ -815,6 +1055,11 @@ button:disabled {
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.new-entry-button {
|
||||||
|
padding: 8px 11px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
.logout-button {
|
.logout-button {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
<p>A quiet place for the links worth keeping.</p>
|
<p>A quiet place for the links worth keeping.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
|
<button id="new-entry-button" class="new-entry-button hidden" type="button"><a href="/new-entry">New entry</a></button>
|
||||||
<button class="menu-toggle" type="button" aria-expanded="false" aria-controls="auth-menu">Menu</button>
|
<button class="menu-toggle" type="button" aria-expanded="false" aria-controls="auth-menu">Menu</button>
|
||||||
<nav id="auth-menu" class="auth-menu hidden" aria-label="Account menu">
|
<nav id="auth-menu" class="auth-menu hidden" aria-label="Account menu">
|
||||||
<a id="auth-home-link" href="/">Home</a>
|
<a id="auth-home-link" href="/">Home</a>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
<p>Manage users and plugin configuration</p>
|
<p>Manage users and plugin configuration</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
|
<button id="new-entry-button" class="new-entry-button hidden" type="button"><a href="/new-entry">New entry</a></button>
|
||||||
<button class="menu-toggle" type="button" aria-expanded="false" aria-controls="auth-menu">Menu</button>
|
<button class="menu-toggle" type="button" aria-expanded="false" aria-controls="auth-menu">Menu</button>
|
||||||
<nav id="auth-menu" class="auth-menu hidden" aria-label="Account menu">
|
<nav id="auth-menu" class="auth-menu hidden" aria-label="Account menu">
|
||||||
<a id="auth-home-link" href="/">Home</a>
|
<a id="auth-home-link" href="/">Home</a>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="header-tools">
|
<div class="header-tools">
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
|
<button id="new-entry-button" class="new-entry-button hidden" type="button"><a href="/new-entry">New entry</a></button>
|
||||||
<button class="menu-toggle" type="button" aria-expanded="false" aria-controls="auth-menu">Menu</button>
|
<button class="menu-toggle" type="button" aria-expanded="false" aria-controls="auth-menu">Menu</button>
|
||||||
<nav id="auth-menu" class="auth-menu hidden" aria-label="Account menu">
|
<nav id="auth-menu" class="auth-menu hidden" aria-label="Account menu">
|
||||||
<a id="auth-home-link" href="/">Home</a>
|
<a id="auth-home-link" href="/">Home</a>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
<p>Manage your link labels</p>
|
<p>Manage your link labels</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
|
<button id="new-entry-button" class="new-entry-button hidden" type="button"><a href="/new-entry">New entry</a></button>
|
||||||
<button class="menu-toggle" type="button" aria-expanded="false" aria-controls="auth-menu">Menu</button>
|
<button class="menu-toggle" type="button" aria-expanded="false" aria-controls="auth-menu">Menu</button>
|
||||||
<nav id="auth-menu" class="auth-menu hidden" aria-label="Account menu">
|
<nav id="auth-menu" class="auth-menu hidden" aria-label="Account menu">
|
||||||
<a id="auth-home-link" href="/">Home</a>
|
<a id="auth-home-link" href="/">Home</a>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
<p>Access your LinkLog settings</p>
|
<p>Access your LinkLog settings</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
|
<button id="new-entry-button" class="new-entry-button hidden" type="button"><a href="/new-entry">New entry</a></button>
|
||||||
<button class="menu-toggle" type="button" aria-expanded="false" aria-controls="auth-menu">Menu</button>
|
<button class="menu-toggle" type="button" aria-expanded="false" aria-controls="auth-menu">Menu</button>
|
||||||
<nav id="auth-menu" class="auth-menu hidden" aria-label="Account menu">
|
<nav id="auth-menu" class="auth-menu hidden" aria-label="Account menu">
|
||||||
<a id="auth-home-link" href="/">Home</a>
|
<a id="auth-home-link" href="/">Home</a>
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<!-- Copyright © 2026 Olaf Kolkman -->
|
||||||
|
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>New Entry - 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">
|
||||||
|
<div class="header-row">
|
||||||
|
<div>
|
||||||
|
<img class="site-logo" src="/static/logo.svg" alt="LinkLog" />
|
||||||
|
<p>New Entry</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-tools">
|
||||||
|
<div class="header-actions">
|
||||||
|
<button id="new-entry-button" class="new-entry-button hidden" type="button"><a href="/new-entry">New entry</a></button>
|
||||||
|
<button class="menu-toggle" type="button" aria-expanded="false" aria-controls="auth-menu">Menu</button>
|
||||||
|
<nav id="auth-menu" class="auth-menu hidden" aria-label="Account menu">
|
||||||
|
<a id="auth-home-link" href="/">Home</a>
|
||||||
|
<a id="auth-about-link" href="/about">About</a>
|
||||||
|
<a id="auth-login-button" href="/login">Sign in</a>
|
||||||
|
<a id="auth-profile-link" class="hidden" href="/profile">Profile</a>
|
||||||
|
<a id="auth-labels-link" class="hidden" href="/labels">Labels</a>
|
||||||
|
<a id="auth-admin-link" class="hidden" href="/admin">Admin</a>
|
||||||
|
<div id="auth-session" class="auth-session hidden">
|
||||||
|
<a id="auth-username" class="user-name" href="/"></a>
|
||||||
|
</div>
|
||||||
|
<button id="logout-button" class="logout-button hidden" type="button">Sign out</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
<div id="auth-required" class="error-message hidden">
|
||||||
|
<p>You must be signed in to create a new entry. <a href="/login">Sign in here</a>.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="entry-form" class="entry-form hidden">
|
||||||
|
<div class="form-section">
|
||||||
|
<label>
|
||||||
|
<span>URL</span>
|
||||||
|
<input id="url-input" name="url" type="url" required placeholder="https://example.com" />
|
||||||
|
</label>
|
||||||
|
<button id="scrape-button" class="secondary" type="button">Auto-fill Title</button>
|
||||||
|
<div id="scrape-status" class="status hidden" aria-live="polite"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-section">
|
||||||
|
<label>
|
||||||
|
<span>Title</span>
|
||||||
|
<input id="title-input" name="title" type="text" required />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-section">
|
||||||
|
<label>
|
||||||
|
<span>Comment</span>
|
||||||
|
<textarea id="comment-input" name="comment" rows="4" placeholder="Optional comment about this link"></textarea>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset class="form-section">
|
||||||
|
<legend>Tags</legend>
|
||||||
|
<div id="existing-tags" class="tag-options"></div>
|
||||||
|
<label>
|
||||||
|
<span>Add new tags</span>
|
||||||
|
<input id="new-tags-input" type="text" pattern="#[^, ]+(,\s*#[^, ]+)*" placeholder="#tag1, #tag2" />
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset class="form-section">
|
||||||
|
<legend>Mastodon Publishing</legend>
|
||||||
|
<label>
|
||||||
|
<input id="mastodon-enabled" type="checkbox" />
|
||||||
|
Post to Mastodon (if configured)
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button id="submit-button" type="submit">Save Entry</button>
|
||||||
|
<a href="/" class="secondary button">Cancel</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="submit-status" class="status hidden" aria-live="polite"></div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="site-footer">
|
||||||
|
<span>Copyright © 2026 Olaf Kolkman</span> · <a href="https://git.kolkman.org/olaf/Link-Log">Repository</a>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="/static/auth-header.js"></script>
|
||||||
|
<script src="/static/new-entry.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
<h1>Profile</h1>
|
<h1>Profile</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
|
<button id="new-entry-button" class="new-entry-button hidden" type="button"><a href="/new-entry">New entry</a></button>
|
||||||
<button class="menu-toggle" type="button" aria-expanded="false" aria-controls="auth-menu">Menu</button>
|
<button class="menu-toggle" type="button" aria-expanded="false" aria-controls="auth-menu">Menu</button>
|
||||||
<nav id="auth-menu" class="auth-menu hidden" aria-label="Account menu">
|
<nav id="auth-menu" class="auth-menu hidden" aria-label="Account menu">
|
||||||
<a id="auth-home-link" href="/">Home</a>
|
<a id="auth-home-link" href="/">Home</a>
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.9 MiB |
Reference in New Issue
Block a user