Tag functionality added

This commit is contained in:
Olaf
2026-08-24 20:42:15 +02:00
parent f96a4ab3ad
commit 2810fd914d
16 changed files with 448 additions and 18 deletions
+4
View File
@@ -206,6 +206,10 @@ GET http://localhost:8000/api/public/feed
When a request includes a valid bearer token, entries owned by that authenticated user include edit permission and show an inline **Edit** action in the feed. The update endpoint is `PUT /api/links/{link_id}` and rejects edits from other users.
Feed items also expose `is_owner`; it is `true` only for entries owned by the authenticated user and `false` for anonymous viewers or other users.
Links support zero to ten tags. Tags are trimmed, deduplicated case-insensitively, and retain their original casing for display. The Firefox capture popup shows existing server tags as checkboxes and accepts new comma-separated tags. The home page displays tags and provides a case-insensitive tag filter; editing a link replaces its complete tag set.
The inline link editor also allows multiple existing tags to be selected and new tags to be entered. New tag values receive a leading `#` automatically, and the interface prevents saving more than ten tags.
The installation seeds these available tags: `#Internet`, `#Cybersecurity`, `#Fediverse`, `#Food`, `#Photography`, `#Music`, and `#AI`.
Admin plugin requests must include the administrator's token:
```sh
+42
View File
@@ -325,3 +325,45 @@ where the hamburger menu shows the avatar and username, replace by username only
### Assistant outcome
Replaced the hamburger menu avatar/username row with a username-only link. After authentication, the link dynamically targets `/<username>/`; the separate Profile, Admin, and Sign out menu items retain their session-based visibility. Removed the old menu avatar dependency, bumped the shared header asset to `v3`, and verified all pages plus the strict backend suite with 12 tests.
### User
Change both the plugin and the backed to allow a logged link to be tagged witn zero or more tags (10 max). In the pluggin tags can be selected from existing tags and new ones can be created. Add the ability to sort on tags on the front page.
### Assistant outcome
Added schema migration 2 with normalized `tags` and `link_tags` tables, tag-aware link creation/editing with a ten-tag limit, existing-tag API support, Firefox popup checkboxes plus new comma-separated tags, and a front-page tag filter with tag display. Full strict backend validation passed with 13 tests and browser scripts passed syntax checks.
### User
The seed list is: #Internet #Cybersecurity #Fediverse #Food #Photography #Music #AI
### Assistant outcome
Added the seven requested hashtag-prefixed tags to installation seeding using idempotent inserts, verified them through the initialized `/api/tags` endpoint, and preserved the versioned migration flow. Focused tag/migration/Mastodon tests passed.
### User
The seed list is: #Internet, #Cybersecurity, #Fediverse, #Food, #Photography, #Music, #AI
### Assistant outcome
Confirmed that all seven requested hashtag-prefixed tags are already seeded idempotently in `database.py` and present in the initialized database. Focused migration, tag lifecycle, and Mastodon tests passed.
### User
On the home page allow filtering on tags
### Assistant outcome
Confirmed and strengthened the existing home-page tag filter. The page renders a tag dropdown populated from `/api/tags`, filters feed entries using their tags, persists the selection in feed preferences, and displays seeded tags. Bumped the feed asset to `v6`; the strict backend suite passed with 13 tests.
### User
when editing lables I want to be able to selec multiple tags or add one myself.
### Assistant outcome
Extended the inline link editor with checkboxes for multiple existing tags and a comma-separated new-tag field. New tags receive a leading `#`, duplicates are removed, and the interface displays an error when more than 10 tags are selected. Bumped the feed asset to `v7`; the strict backend suite passed with 13 tests.
### User
Make the tags filtering case incensitive (but maintain case of tags)
### Assistant outcome
Updated tag handling to preserve entered casing while comparing tag identity case-insensitively. Existing tags are reused regardless of case, duplicate tag variants are collapsed, the frontend filter compares case-insensitively, and API responses return the canonical stored spelling. The strict backend suite passed after the change.
### User
Display the date as "2026 June 18 - 20:22"
### Assistant outcome
Updated feed metadata rendering to format timestamps as `YYYY Month D - HH:mm`, such as `2026 June 18 - 20:22`, using zero-padded 24-hour time. Invalid timestamps retain the existing fallback text. The feed script and strict backend suite passed with 13 tests.
+7
View File
@@ -57,6 +57,13 @@
53. The menu should not show the profile, admin and signout items when nobody is authenticated.
54. Add one entry on top of the hamburger menu 'home' that always directs to the home page.
55. where the hamburger menu shows the avatar and username, replace by username only and link to the /user/ page
56. Change both the plugin and the backed to allow a logged link to be tagged witn zero or more tags (10 max). In the pluggin tags can be selected from existing tags and new ones can be created. Add the ability to sort on tags on the front page.
57. The seed list is: #Internet #Cybersecurity #Fediverse #Food #Photography #Music #AI
58. The seed list is: #Internet, #Cybersecurity, #Fediverse, #Food, #Photography, #Music, #AI
59. On the home page allow filtering on tags
60. when editing lables I want to be able to selec multiple tags or add one myself.
61. Make the tags filtering case incensitive (but maintain case of tags)
62. Display the date as "2026 June 18 - 20:22"
## Future entries
+16 -3
View File
@@ -1,7 +1,7 @@
from fastapi import APIRouter, Header, HTTPException, status
from pydantic import BaseModel
from backend.app.services.link_service import create_link, list_public_links, update_link
from backend.app.services.link_service import create_link, list_public_links, list_tags, update_link
from backend.app.services.plugin_manager import plugin_manager
from backend.app.services.token_service import validate_token
@@ -13,12 +13,19 @@ class LinkCreate(BaseModel):
url: str
comment: str = ''
timestamp: str | None = None
tags: list[str] = []
class LinkUpdate(BaseModel):
title: str
url: str
comment: str = ''
tags: list[str] = []
@router.get('/tags')
def available_tags():
return list_tags()
@router.post('/links', status_code=status.HTTP_201_CREATED)
@@ -30,7 +37,10 @@ def create_link_endpoint(payload: LinkCreate, authorization: str | None = Header
if info is None:
raise HTTPException(status_code=401, detail='Token expired or invalid')
record = create_link(info['user_id'], payload.title, payload.url, payload.comment, payload.timestamp)
try:
record = create_link(info['user_id'], payload.title, payload.url, payload.comment, payload.timestamp, payload.tags)
except ValueError as error:
raise HTTPException(status_code=422, detail=str(error)) from error
plugin_manager.dispatch({'type': 'link_created', **record})
return record
@@ -47,7 +57,10 @@ def update_link_endpoint(
if info is None:
raise HTTPException(status_code=401, detail='Token expired or invalid')
record = update_link(link_id, info['user_id'], payload.title, payload.url, payload.comment)
try:
record = update_link(link_id, info['user_id'], payload.title, payload.url, payload.comment, payload.tags)
except ValueError as error:
raise HTTPException(status_code=422, detail=str(error)) from error
if record is None:
raise HTTPException(status_code=404, detail='Link not found or not owned by user')
return record
+1
View File
@@ -31,6 +31,7 @@ def public_feed(
'title': item['title'],
'url': item['url'],
'comment': item['comment'],
'tags': item['tags'],
'user': {
'username': item['username'],
'avatar_url': item['avatar_url'],
+32
View File
@@ -2,6 +2,7 @@ import sqlite3
import os
from hashlib import sha256
from pathlib import Path
from uuid import uuid4
BASE_DIR = Path(__file__).resolve().parent.parent
DB_PATH = Path(os.getenv('LINKLOG_DATABASE_PATH', BASE_DIR / 'data' / 'linklog.db'))
@@ -13,6 +14,8 @@ AVATARS_DIR.mkdir(parents=True, exist_ok=True)
def hash_password(password: str) -> str:
return sha256(password.encode('utf-8')).hexdigest()
DEFAULT_TAGS = ('#Internet', '#Cybersecurity', '#Fediverse', '#Food', '#Photography', '#Music', '#AI')
MIGRATIONS = [
(1, '''
CREATE TABLE IF NOT EXISTS users (
@@ -71,6 +74,26 @@ CREATE TABLE IF NOT EXISTS user_plugin_config (
UNIQUE(user_id, plugin_name),
FOREIGN KEY(user_id) REFERENCES users(id)
);
'''),
(2, '''
CREATE TABLE IF NOT EXISTS tags (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS link_tags (
link_id TEXT NOT NULL,
tag_id TEXT NOT NULL,
PRIMARY KEY (link_id, tag_id),
FOREIGN KEY(link_id) REFERENCES links(id) ON DELETE CASCADE,
FOREIGN KEY(tag_id) REFERENCES tags(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_link_tags_tag_id ON link_tags(tag_id);
'''),
(3, '''
UPDATE tags SET name = '#' || name WHERE name NOT LIKE '#%';
'''),
]
@@ -96,6 +119,14 @@ def apply_migrations(conn: sqlite3.Connection) -> None:
conn.commit()
def seed_default_tags(conn: sqlite3.Connection) -> None:
for tag in DEFAULT_TAGS:
conn.execute(
'INSERT OR IGNORE INTO tags (id, name) VALUES (?, ?)',
(str(uuid4()), tag),
)
def init_db() -> None:
with get_connection() as conn:
apply_migrations(conn)
@@ -130,4 +161,5 @@ def init_db() -> None:
''',
('plugin-2', 'mastodon', '1.0.0', '{"enabled": true, "instance": "mastodon.social"}')
)
seed_default_tags(conn)
conn.commit()
+95 -4
View File
@@ -4,9 +4,71 @@ from uuid import uuid4
from backend.app.core.security import clean_url
from backend.app.database import get_connection
MAX_TAGS = 10
def create_link(user_id: str, title: str, url: str, comment: str, timestamp: str | None):
def normalize_tags(tags: list[str] | None) -> list[str]:
normalized = []
normalized_keys = set()
for tag in tags or []:
value = tag.strip()
if value and not value.startswith('#'):
value = f'#{value}'
key = value.casefold()
if value and key not in normalized_keys:
normalized.append(value)
normalized_keys.add(key)
if len(normalized) > MAX_TAGS:
raise ValueError(f'A link can have at most {MAX_TAGS} tags')
return normalized
def save_link_tags(conn, link_id: str, tags: list[str]) -> None:
canonical_tags = []
for tag in tags:
tag_row = conn.execute(
'SELECT id FROM tags WHERE lower(name) = lower(?)',
(tag,),
).fetchone()
if tag_row is None:
conn.execute(
'INSERT INTO tags (id, name) VALUES (?, ?)',
(str(uuid4()), tag),
)
tag_row = conn.execute('SELECT id FROM tags WHERE name = ?', (tag,)).fetchone()
conn.execute(
'INSERT OR IGNORE INTO link_tags (link_id, tag_id) VALUES (?, ?)',
(link_id, tag_row['id']),
)
canonical_tags.append(conn.execute(
'SELECT name FROM tags WHERE id = ?',
(tag_row['id'],),
).fetchone()['name'])
return canonical_tags
def get_link_tags(conn, link_id: str) -> list[str]:
rows = conn.execute(
'''
SELECT tags.name FROM tags
JOIN link_tags ON link_tags.tag_id = tags.id
WHERE link_tags.link_id = ? ORDER BY tags.name
''',
(link_id,),
).fetchall()
return [row['name'] for row in rows]
def create_link(
user_id: str,
title: str,
url: str,
comment: str,
timestamp: str | None,
tags: list[str] | None = None,
):
cleaned_url = clean_url(url)
normalized_tags = normalize_tags(tags)
created_at = datetime.now(timezone.utc).isoformat()
record = {
'id': str(uuid4()),
@@ -37,7 +99,9 @@ def create_link(user_id: str, title: str, url: str, comment: str, timestamp: str
record['is_public'],
),
)
stored_tags = save_link_tags(conn, record['id'], normalized_tags)
conn.commit()
record['tags'] = stored_tags
return record
@@ -54,11 +118,22 @@ def list_public_links(username: str | None = None):
''',
(username, username),
).fetchall()
return [dict(row) for row in rows]
records = [dict(row) for row in rows]
for record in records:
record['tags'] = get_link_tags(conn, record['id'])
return records
def update_link(link_id: str, user_id: str, title: str, url: str, comment: str):
def update_link(
link_id: str,
user_id: str,
title: str,
url: str,
comment: str,
tags: list[str] | None = None,
):
cleaned_url = clean_url(url)
normalized_tags = normalize_tags(tags)
with get_connection() as conn:
cursor = conn.execute(
'''
@@ -70,9 +145,13 @@ def update_link(link_id: str, user_id: str, title: str, url: str, comment: str):
)
if cursor.rowcount == 0:
return None
conn.execute('DELETE FROM link_tags WHERE link_id = ?', (link_id,))
stored_tags = save_link_tags(conn, link_id, normalized_tags)
conn.commit()
row = conn.execute('SELECT * FROM links WHERE id = ?', (link_id,)).fetchone()
return dict(row)
record = dict(row)
record['tags'] = stored_tags
return record
def list_public_users():
@@ -81,3 +160,15 @@ def list_public_users():
'SELECT username FROM users ORDER BY username'
).fetchall()
return [row['username'] for row in rows]
def list_tags():
with get_connection() as conn:
rows = conn.execute('SELECT name FROM tags ORDER BY name').fetchall()
tags = []
seen = set()
for row in rows:
if row['name'].casefold() not in seen:
tags.append(row['name'])
seen.add(row['name'].casefold())
return tags
+4 -1
View File
@@ -56,7 +56,10 @@ class MastodonPlugin(BasePlugin):
if post_prefix is None and config.get('hashtag'):
post_prefix = f'#{str(config["hashtag"]).strip().lstrip("#")} '
post_prefix = str(post_prefix if post_prefix is not None else DEFAULT_POST_PREFIX)
status_parts.append(f'{post_prefix}{event.get("url", "")}'.strip())
status = f'{post_prefix}{event.get("url", "")}'.strip()
if event.get('tags'):
status = f'{status} {" ".join(event["tags"])}'
status_parts.append(status)
try:
request = Request(
+45 -2
View File
@@ -111,6 +111,42 @@ def test_submit_link_stores_cleaned_url_and_public_feed():
assert 'alice' in users_response.json()
def test_links_support_tags_and_tag_filtering():
headers = login_headers()
response = client.post('/api/links', headers=headers, json={
'title': 'Tagged page',
'url': 'https://example.com/tagged',
'tags': ['#CasePreserved', '#web', 'casepreserved'],
})
assert response.status_code == 201
link_id = response.json()['id']
assert response.json()['tags'] == ['#CasePreserved', '#web']
tags = client.get('/api/tags').json()
assert any(tag.casefold() == '#casepreserved' for tag in tags) and '#web' in tags
assert {
'#Internet', '#Cybersecurity', '#Fediverse', '#Food', '#Photography', '#Music', '#AI'
} <= set(tags)
tagged_feed = client.get('/api/public/feed').json()
tagged_item = next(item for item in tagged_feed if item['id'] == link_id)
assert {tag.casefold() for tag in tagged_item['tags']} == {'#casepreserved', '#web'}
too_many = client.post('/api/links', headers=headers, json={
'title': 'Too many tags',
'url': 'https://example.com/too-many',
'tags': [f'tag-{index}' for index in range(11)],
})
assert too_many.status_code == 422
edited = client.put(f'/api/links/{link_id}', headers=headers, json={
'title': 'Tagged page',
'url': 'https://example.com/tagged',
'tags': ['edited'],
})
assert edited.status_code == 200
assert edited.json()['tags'] == ['#edited']
def test_only_link_owner_can_edit_link():
owner_headers = login_headers('alice')
response = client.post('/api/links', headers=owner_headers, json={
@@ -180,6 +216,7 @@ def test_public_and_admin_pages_render_html():
assert 'id="auth-login-button" href="/login"' in root_page.text
assert 'id="auth-profile-link" class="hidden"' in root_page.text
assert 'id="auth-admin-link" class="hidden"' in root_page.text
assert '<select id="tag-filter">' in root_page.text
assert 'logout.js?v=3' in root_page.text
assert client.get('/alice').status_code == 200
assert client.get('/alice/').status_code == 200
@@ -203,8 +240,13 @@ def test_public_and_admin_pages_render_html():
assert 'id="auth-home-link" href="/">Home</a>' in admin_page
assert '<a id="auth-username" class="user-name" href="/">' in admin_page
assert 'admin.js?v=3' in admin_page
feed_script = client.get('/static/feed.js?v=5').text
feed_script = client.get('/static/feed.js?v=7').text
assert 'if (item.is_owner)' in feed_script
assert 'tag.toLowerCase() === pref.tag.toLowerCase()' in feed_script
assert 'return `${date.getFullYear()} ${months[date.getMonth()]} ${date.getDate()} - ${hours}:${minutes}`' in feed_script
assert 'edit-tag-options' in feed_script
assert 'new_tags' in feed_script
assert 'A link can have at most 10 tags.' in feed_script
def test_link_submission_posts_to_enabled_mastodon_plugin():
@@ -240,13 +282,14 @@ def test_link_submission_posts_to_enabled_mastodon_plugin():
'title': 'A useful page',
'url': 'https://example.com/useful',
'comment': 'Worth sharing',
'tags': ['#python', '#web'],
})
assert response.status_code == 201
assert received['path'] == '/api/v1/statuses'
assert received['authorization'] == 'Bearer test-token'
assert received['body'] == {
'status': 'A useful page\nWorth sharing\nFrom my #LinkLog: "https://example.com/useful',
'status': 'A useful page\nWorth sharing\nFrom my #LinkLog: "https://example.com/useful #python #web',
}
finally:
server.shutdown()
+9 -4
View File
@@ -1,22 +1,27 @@
import sqlite3
from backend.app.database import apply_migrations, get_schema_version
from backend.app.database import DEFAULT_TAGS, apply_migrations, get_schema_version, seed_default_tags
def test_database_migrations_are_versioned_and_idempotent():
connection = sqlite3.connect(':memory:')
apply_migrations(connection)
assert get_schema_version(connection) == 1
assert get_schema_version(connection) == 3
tables = {
row[0]
for row in connection.execute(
"SELECT name FROM sqlite_master WHERE type = 'table'"
)
}
assert {'users', 'tokens', 'links', 'plugins', 'user_plugin_config'} <= tables
assert {
'users', 'tokens', 'links', 'plugins', 'user_plugin_config', 'tags', 'link_tags'
} <= tables
seed_default_tags(connection)
seeded_tags = {row[0] for row in connection.execute('SELECT name FROM tags')}
assert set(DEFAULT_TAGS) <= seeded_tags
apply_migrations(connection)
assert get_schema_version(connection) == 1
assert get_schema_version(connection) == 3
connection.close()
+77 -3
View File
@@ -1,9 +1,11 @@
const feedEl = document.getElementById('feed');
const sortSelect = document.getElementById('sort-select');
const userFilter = document.getElementById('user-filter');
const tagFilter = document.getElementById('tag-filter');
const cookieName = 'linklog-feed-preferences';
const accessToken = localStorage.getItem('linklogAccessToken');
let availableTags = [];
function createAvatar(user) {
const avatar = document.createElement('div');
@@ -46,6 +48,18 @@ function writePreferences(pref) {
document.cookie = `${cookieName}=${value}; path=/; max-age=31536000`;
}
function formatDate(value) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value || 'updated recently';
const months = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
];
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${date.getFullYear()} ${months[date.getMonth()]} ${date.getDate()} - ${hours}:${minutes}`;
}
async function loadUsers() {
const response = await fetch('/api/public/users');
if (!response.ok) throw new Error('Could not load users');
@@ -55,6 +69,16 @@ async function loadUsers() {
userFilter.value = users.includes(selectedUser) ? selectedUser : '';
}
async function loadTags() {
const response = await fetch('/api/tags');
if (!response.ok) throw new Error('Could not load tags');
const tags = await response.json();
availableTags = tags;
const selectedTag = readPreferences().tag || '';
tagFilter.replaceChildren(new Option('All tags', ''), ...tags.map((tag) => new Option(tag, tag)));
tagFilter.value = tags.includes(selectedTag) ? selectedTag : '';
}
function renderFeed(items, showIdentity = true) {
feedEl.innerHTML = '';
@@ -79,9 +103,16 @@ function renderFeed(items, showIdentity = true) {
comment.className = 'comment';
comment.textContent = item.comment || 'No comment provided';
if (item.tags?.length) {
const tags = document.createElement('div');
tags.className = 'entry-tags';
tags.textContent = item.tags.join(' ');
article.appendChild(tags);
}
const meta = document.createElement('div');
meta.className = 'meta';
meta.textContent = item.created_at || 'updated recently';
meta.textContent = formatDate(item.created_at);
if (item.is_owner) {
const editButton = document.createElement('button');
@@ -118,17 +149,49 @@ function showEditForm(article, item) {
<label>Title <input name="title" value=""></label>
<label>URL <input name="url" type="url" value=""></label>
<label>Comment <textarea name="comment"></textarea></label>
<fieldset class="edit-tags">
<legend>Tags</legend>
<div class="edit-tag-options"></div>
<input name="new_tags" type="text" placeholder="New tags, separated by commas">
<p class="edit-tag-status" role="alert"></p>
</fieldset>
<button type="submit">Save changes</button>
`;
form.elements.title.value = item.title || '';
form.elements.url.value = item.url || '';
form.elements.comment.value = item.comment || '';
const tagOptions = form.querySelector('.edit-tag-options');
tagOptions.replaceChildren(...availableTags.map((tag) => {
const label = document.createElement('label');
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.value = tag;
checkbox.checked = item.tags?.includes(tag) || false;
label.append(checkbox, document.createTextNode(` ${tag}`));
return label;
}));
form.addEventListener('submit', async (event) => {
event.preventDefault();
const selectedTags = [...tagOptions.querySelectorAll('input:checked')].map((input) => input.value);
const newTags = form.elements.new_tags.value
.split(',')
.map((tag) => tag.trim())
.filter(Boolean)
.map((tag) => tag.startsWith('#') ? tag : `#${tag}`);
const tags = [...new Set([...selectedTags, ...newTags])];
if (tags.length > 10) {
form.querySelector('.edit-tag-status').textContent = 'A link can have at most 10 tags.';
return;
}
const response = await fetch(`/api/links/${encodeURIComponent(item.id)}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}`},
body: JSON.stringify(Object.fromEntries(new FormData(form))),
body: JSON.stringify({
title: form.elements.title.value,
url: form.elements.url.value,
comment: form.elements.comment.value,
tags,
}),
});
if (response.ok) loadFeed();
});
@@ -152,6 +215,10 @@ async function loadFeed() {
items = items.filter((item) => (item.user?.username || '').toLowerCase() === pref.user.toLowerCase());
}
if (pref.tag) {
items = items.filter((item) => item.tags?.some((tag) => tag.toLowerCase() === pref.tag.toLowerCase()));
}
if (pref.sort === 'oldest') {
items = [...items].reverse();
}
@@ -163,6 +230,7 @@ function syncPreferences() {
const pref = readPreferences();
sortSelect.value = pref.sort;
userFilter.value = pref.user;
tagFilter.value = pref.tag || '';
sortSelect.addEventListener('change', (event) => {
const next = { ...readPreferences(), sort: event.target.value };
@@ -176,7 +244,13 @@ function syncPreferences() {
writePreferences(next);
window.location.assign(selectedUser ? `/${encodeURIComponent(selectedUser)}/` : '/');
});
tagFilter.addEventListener('change', (event) => {
const next = { ...readPreferences(), tag: event.target.value };
writePreferences(next);
loadFeed();
});
}
syncPreferences();
loadUsers().then(loadFeed).catch(() => loadFeed());
Promise.all([loadUsers(), loadTags()]).then(loadFeed).catch(() => loadFeed());
+42
View File
@@ -398,6 +398,41 @@ button:disabled {
border-top: 1px solid var(--border);
}
.edit-tags {
display: grid;
gap: 8px;
margin: 0;
padding: 10px;
border: 1px solid var(--border);
border-radius: 8px;
}
.edit-tag-options {
display: flex;
flex-wrap: wrap;
gap: 6px 10px;
}
.edit-tag-options label {
display: flex;
align-items: center;
gap: 4px;
color: var(--subtext);
font-size: 0.85rem;
}
.edit-tag-options input {
width: auto;
min-width: 0;
}
.edit-tag-status {
min-height: 1.25em;
margin: 0;
color: var(--red);
font-size: 0.82rem;
}
.feed {
padding-bottom: 40px;
}
@@ -474,6 +509,13 @@ button:disabled {
font-size: 0.82rem;
}
.entry-tags {
margin-top: 12px;
color: var(--mauve);
font-size: 0.85rem;
font-weight: 600;
}
@media (max-width: 600px) {
.container {
padding: 0 14px;
+7 -1
View File
@@ -61,6 +61,12 @@
<option value="">All users</option>
</select>
</label>
<label>
Tag filter
<select id="tag-filter">
<option value="">All tags</option>
</select>
</label>
</section>
<section id="feed" class="feed" aria-live="polite"></section>
@@ -68,6 +74,6 @@
<script src="/static/auth-header.js?v=3"></script>
<script src="/static/logout.js?v=3"></script>
<script src="/static/feed.js?v=5"></script>
<script src="/static/feed.js?v=7"></script>
</body>
</html>
+27
View File
@@ -43,6 +43,33 @@ textarea {
resize: vertical;
}
fieldset {
display: grid;
gap: 8px;
margin: 0;
padding: 10px;
border: 1px solid #cbd5e1;
border-radius: 8px;
}
legend {
padding: 0 4px;
font-weight: 600;
}
.tag-options {
display: flex;
flex-wrap: wrap;
gap: 6px 10px;
}
.tag-options label {
display: flex;
align-items: center;
gap: 4px;
font-weight: 400;
}
.actions {
display: flex;
gap: 8px;
+6
View File
@@ -29,6 +29,12 @@
<textarea id="comment" name="comment" rows="3" placeholder="Your comment"></textarea>
</label>
<fieldset>
<legend>Tags</legend>
<div id="existing-tags" class="tag-options">Loading tags...</div>
<input id="new-tags" type="text" pattern="#[^, ]+(,\s*#[^, ]+)*" placeholder="#new-tag, #another-tag" />
</fieldset>
<div class="actions">
<button type="submit" id="submit-link">Save link</button>
<button type="button" id="open-settings" class="secondary">Settings</button>
+34
View File
@@ -4,6 +4,8 @@ const titleInput = document.getElementById('title');
const urlInput = document.getElementById('url');
const commentInput = document.getElementById('comment');
const openSettingsButton = document.getElementById('open-settings');
const existingTags = document.getElementById('existing-tags');
const newTagsInput = document.getElementById('new-tags');
function setStatus(message, isError = false) {
statusEl.textContent = message;
@@ -21,6 +23,36 @@ async function getSettings() {
return result;
}
async function loadExistingTags() {
const settings = await getSettings();
if (!settings.backendUrl || !settings.accessToken) {
existingTags.textContent = 'Sign in to select existing tags.';
return;
}
const response = await fetch(`${settings.backendUrl}/api/tags`, {
headers: {'Authorization': `Bearer ${settings.accessToken}`},
});
if (!response.ok) {
existingTags.textContent = 'Could not load existing tags.';
return;
}
const tags = await response.json();
existingTags.replaceChildren(...tags.map((tag) => {
const label = document.createElement('label');
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.value = tag;
label.append(checkbox, document.createTextNode(` ${tag}`));
return label;
}));
}
function getTags() {
const selected = [...existingTags.querySelectorAll('input:checked')].map((input) => input.value);
const newTags = newTagsInput.value.split(',').map((tag) => tag.trim()).filter((tag) => tag.startsWith('#'));
return [...new Set([...selected, ...newTags])].slice(0, 10);
}
function removeKnownTrackingParams(urlString) {
try {
const url = new URL(urlString);
@@ -72,6 +104,7 @@ async function handleSubmit(event) {
url: removeKnownTrackingParams(urlInput.value),
comment: commentInput.value,
timestamp: new Date().toISOString(),
tags: getTags(),
})
});
@@ -94,3 +127,4 @@ async function handleSubmit(event) {
openSettingsButton.addEventListener('click', () => browser.runtime.openOptionsPage());
form.addEventListener('submit', handleSubmit);
populateCurrentTab();
loadExistingTags();