From 16571a96451f3cbedd5622b0a419741d4090c825 Mon Sep 17 00:00:00 2001 From: Kolkman Date: Thu, 27 Aug 2026 17:33:43 +0200 Subject: [PATCH] New Entry functionality --- CHANGELOG.MD | 6 + VIBE/CHAT_LOG.md | 18 ++ VIBE/PROMPTS.md | 8 + backend/app/api/links.py | 16 ++ backend/app/main.py | 7 + backend/app/services/scraper_service.py | 106 ++++++++ docker-compose.yml | 20 +- frontend/static/auth-header.js | 3 + frontend/static/new-entry.js | 203 +++++++++++++++ frontend/static/style.css | 245 ++++++++++++++++++ frontend/templates/about.html | 1 + frontend/templates/admin.html | 1 + frontend/templates/feed.html | 1 + frontend/templates/labels.html | 1 + frontend/templates/login.html | 1 + frontend/templates/new-entry.html | 103 ++++++++ frontend/templates/user_profile.html | 1 + .../4f2eb936-8d54-4026-8a68-b3bfdfb5a8f9.png | Bin 1977271 -> 0 bytes 18 files changed, 731 insertions(+), 10 deletions(-) create mode 100644 CHANGELOG.MD create mode 100644 backend/app/services/scraper_service.py create mode 100644 frontend/static/new-entry.js create mode 100644 frontend/templates/new-entry.html delete mode 100644 linklog_data/avatars/4f2eb936-8d54-4026-8a68-b3bfdfb5a8f9.png diff --git a/CHANGELOG.MD b/CHANGELOG.MD new file mode 100644 index 0000000..6795526 --- /dev/null +++ b/CHANGELOG.MD @@ -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 diff --git a/VIBE/CHAT_LOG.md b/VIBE/CHAT_LOG.md index 5bd34b3..bdc730f 100644 --- a/VIBE/CHAT_LOG.md +++ b/VIBE/CHAT_LOG.md @@ -120,6 +120,24 @@ Fix SA-015: Some destructive and administrative operations lack audit logging. ### 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. +### 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 `` 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 Solve SA-010: Avatar validation trusts the client MIME type. diff --git a/VIBE/PROMPTS.md b/VIBE/PROMPTS.md index aa62ef5..97feb3a 100644 --- a/VIBE/PROMPTS.md +++ b/VIBE/PROMPTS.md @@ -210,6 +210,14 @@ 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`. +## 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 Append each new user prompt here with its date and preserve the chronological order. diff --git a/backend/app/api/links.py b/backend/app/api/links.py index 1fe0b19..e416af1 100644 --- a/backend/app/api/links.py +++ b/backend/app/api/links.py @@ -11,6 +11,7 @@ from backend.app.database import get_connection from backend.app.services.plugin_manager import plugin_manager from backend.app.services.token_service import validate_token from backend.app.services.audit_service import record_audit_event +from backend.app.services.scraper_service import scrape_title router = APIRouter() logger = logging.getLogger(__name__) @@ -36,6 +37,21 @@ def available_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') def check_existing_link( title: str, diff --git a/backend/app/main.py b/backend/app/main.py index f4a89bd..42a2fdd 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -78,6 +78,13 @@ async def labels_page(request: Request): 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) async def about_page(request: Request): return templates.TemplateResponse(request, 'about.html', {}) diff --git a/backend/app/services/scraper_service.py b/backend/app/services/scraper_service.py new file mode 100644 index 0000000..a4ee074 --- /dev/null +++ b/backend/app/services/scraper_service.py @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 3ad0b91..06bf248 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,21 +30,21 @@ services: labels: traefik.enable: true 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.http.routers.linklog.entrypoints: web - traefik.http.routers.linklog.rule: Host(`${LINKLOG_PUBLIC_URL:-linklog.example.com}`) - traefik.http.routers.linklog.middlewares: web-https-redirect,servicests - traefik.http.routers.linklog-secure.entrypoints: websecure - traefik.http.routers.linklog-secure.rule: Host(`${LINKLOG_PUBLIC_URL:-linklog.example.com}`) - traefik.http.routers.linklog-secure.tls: true - traefik.http.routers.linklog-secure.middlewares: servicests + traefik.http.routers.testlog.entrypoints: web + traefik.http.routers.testlog.rule: Host(`${LINKLOG_PUBLIC_URL:-linklog.example.com}`) + traefik.http.routers.testlog.middlewares: web-https-redirect,servicests + traefik.http.routers.testlog-secure.entrypoints: websecure + traefik.http.routers.testlog-secure.rule: Host(`${LINKLOG_PUBLIC_URL:-linklog.example.com}`) + traefik.http.routers.testlog-secure.tls: true + traefik.http.routers.testlog-secure.middlewares: servicests - traefik.http.routers.linklog-secure.tls.certresolver: myresolver - traefik.http.routers.linklog-secure.service: linklog + traefik.http.routers.testlog-secure.tls.certresolver: myresolver + traefik.http.routers.testlog-secure.service: testlog diff --git a/frontend/static/auth-header.js b/frontend/static/auth-header.js index 227cde8..5f2d634 100644 --- a/frontend/static/auth-header.js +++ b/frontend/static/auth-header.js @@ -3,6 +3,7 @@ (() => { const loginButton = document.querySelector('#auth-login-button'); + const newEntryButton = document.querySelector('#new-entry-button'); const profileLink = document.querySelector('#auth-profile-link'); const labelsLink = document.querySelector('#auth-labels-link'); const adminLink = document.querySelector('#auth-admin-link'); @@ -24,6 +25,7 @@ function showSignedOut() { loginButton.classList.remove('hidden'); + if (newEntryButton) newEntryButton.classList.add('hidden'); profileLink.classList.add('hidden'); labelsLink.classList.add('hidden'); adminLink.classList.add('hidden'); @@ -33,6 +35,7 @@ function showSignedIn(user) { loginButton.classList.add('hidden'); + if (newEntryButton) newEntryButton.classList.remove('hidden'); profileLink.classList.remove('hidden'); labelsLink.classList.remove('hidden'); adminLink.classList.toggle('hidden', !user.is_admin); diff --git a/frontend/static/new-entry.js b/frontend/static/new-entry.js new file mode 100644 index 0000000..77ca954 --- /dev/null +++ b/frontend/static/new-entry.js @@ -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; + } + }); +})(); diff --git a/frontend/static/style.css b/frontend/static/style.css index 1fb58fa..19d9481 100644 --- a/frontend/static/style.css +++ b/frontend/static/style.css @@ -248,6 +248,31 @@ body::selection { 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 { position: absolute; z-index: 30; @@ -768,6 +793,221 @@ button:disabled { 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) { .container { padding: 0 14px; @@ -815,6 +1055,11 @@ button:disabled { font-size: 0.9rem; } + .new-entry-button { + padding: 8px 11px; + font-size: 0.9rem; + } + .logout-button { font-size: 0.9rem; } diff --git a/frontend/templates/about.html b/frontend/templates/about.html index f8b6dd3..8afdcaf 100644 --- a/frontend/templates/about.html +++ b/frontend/templates/about.html @@ -18,6 +18,7 @@ <p>A quiet place for the links worth keeping.</p> </div> <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> diff --git a/frontend/templates/admin.html b/frontend/templates/admin.html index b8bacd5..b41429d 100644 --- a/frontend/templates/admin.html +++ b/frontend/templates/admin.html @@ -18,6 +18,7 @@ <p>Manage users and plugin configuration</p> </div> <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> diff --git a/frontend/templates/feed.html b/frontend/templates/feed.html index eebdd5b..f82b6ba 100644 --- a/frontend/templates/feed.html +++ b/frontend/templates/feed.html @@ -18,6 +18,7 @@ </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> diff --git a/frontend/templates/labels.html b/frontend/templates/labels.html index 3b4b7b8..81efa43 100644 --- a/frontend/templates/labels.html +++ b/frontend/templates/labels.html @@ -18,6 +18,7 @@ <p>Manage your link labels</p> </div> <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> diff --git a/frontend/templates/login.html b/frontend/templates/login.html index b98846a..23b94ce 100644 --- a/frontend/templates/login.html +++ b/frontend/templates/login.html @@ -17,6 +17,7 @@ <p>Access your LinkLog settings</p> </div> <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> diff --git a/frontend/templates/new-entry.html b/frontend/templates/new-entry.html new file mode 100644 index 0000000..cee675c --- /dev/null +++ b/frontend/templates/new-entry.html @@ -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 + + + + + + +
+ + + +
+ + + + + + + diff --git a/frontend/templates/user_profile.html b/frontend/templates/user_profile.html index 6b63090..a284f7d 100644 --- a/frontend/templates/user_profile.html +++ b/frontend/templates/user_profile.html @@ -19,6 +19,7 @@

Profile

+