4 Commits
Author SHA1 Message Date
olaf c748241291 Some eye-candy changes
Build LinkLog Development Image / development-image (push) Successful in 10s
2026-08-26 10:30:33 +02:00
olaf 95accdf436 Post to mastodon from the profile page for logged in users 2026-08-26 10:20:48 +02:00
olaf 9deb28330a eyecandy and delete 2026-08-26 10:04:23 +02:00
olaf 2e5610555e Format mastodon output 2026-08-26 09:51:28 +02:00
22 changed files with 280 additions and 27 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
<svg width="100%" height="100%" viewBox="0 0 1804 1712" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"> <svg width="100%" height="100%" viewBox="0 0 1804 1712" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1,0,0,1,-669.006,-255.89)"> <g transform="matrix(1,0,0,1,-669.006,-255.89)">
<g transform="matrix(0.972876,0,0,1.26057,-24.1588,-587.485)"> <g transform="matrix(0.972876,0,0,1.26057,-24.1588,-587.485)">
<rect x="712.491" y="669.041" width="1854.11" height="1357.72" style="fill:rgb(88,91,112);"/> <rect x="712.491" y="669.041" width="1854.11" height="1357.72" style="fill:rgb(24, 24, 37);"/>
</g> </g>
<g transform="matrix(5.14628,0,0,6.12686,41.2191,-8673.98)"> <g transform="matrix(5.14628,0,0,6.12686,41.2191,-8673.98)">
<path d="M141.95,1644.94L141.95,1633.94C157.554,1633.4 169.724,1631.04 178.462,1626.86C187.2,1622.69 193.344,1615.8 196.894,1606.21C200.444,1596.61 202.218,1583.47 202.218,1566.77C202.218,1554.44 201.146,1544.16 199,1535.93C196.855,1527.7 193.285,1521.25 188.292,1516.57C185.016,1513.6 181.154,1511.26 176.707,1509.54C172.26,1507.83 167.15,1506.62 161.376,1505.92C155.603,1505.21 149.128,1504.86 141.95,1504.86L141.95,1493.86L384.076,1493.86L384.076,1504.86C373.388,1504.86 364.221,1505.7 356.575,1507.38C348.929,1509.06 342.708,1512.12 337.909,1516.57C333.111,1521.01 329.581,1527.27 327.319,1535.35C325.056,1543.42 323.925,1553.9 323.925,1566.77L323.925,1633.36C339.06,1633.36 353.532,1631.08 367.341,1626.51C381.15,1621.95 393.399,1616 404.087,1608.66C414.776,1601.41 423.299,1593.3 429.657,1584.32C436.016,1575.35 439.546,1566.61 440.248,1558.11L452.536,1558.11C452.536,1562.4 452.419,1568.35 452.185,1575.96C451.951,1583.56 451.6,1591.85 451.132,1600.82C450.586,1609.95 449.942,1618.34 449.201,1625.99C448.46,1633.63 447.582,1639.95 446.568,1644.94L141.95,1644.94Z" style="fill:rgb(245,224,220);fill-rule:nonzero;"/> <path d="M141.95,1644.94L141.95,1633.94C157.554,1633.4 169.724,1631.04 178.462,1626.86C187.2,1622.69 193.344,1615.8 196.894,1606.21C200.444,1596.61 202.218,1583.47 202.218,1566.77C202.218,1554.44 201.146,1544.16 199,1535.93C196.855,1527.7 193.285,1521.25 188.292,1516.57C185.016,1513.6 181.154,1511.26 176.707,1509.54C172.26,1507.83 167.15,1506.62 161.376,1505.92C155.603,1505.21 149.128,1504.86 141.95,1504.86L141.95,1493.86L384.076,1493.86L384.076,1504.86C373.388,1504.86 364.221,1505.7 356.575,1507.38C348.929,1509.06 342.708,1512.12 337.909,1516.57C333.111,1521.01 329.581,1527.27 327.319,1535.35C325.056,1543.42 323.925,1553.9 323.925,1566.77L323.925,1633.36C339.06,1633.36 353.532,1631.08 367.341,1626.51C381.15,1621.95 393.399,1616 404.087,1608.66C414.776,1601.41 423.299,1593.3 429.657,1584.32C436.016,1575.35 439.546,1566.61 440.248,1558.11L452.536,1558.11C452.536,1562.4 452.419,1568.35 452.185,1575.96C451.951,1583.56 451.6,1591.85 451.132,1600.82C450.586,1609.95 449.942,1618.34 449.201,1625.99C448.46,1633.63 447.582,1639.95 446.568,1644.94L141.95,1644.94Z" style="fill:rgb(245,224,220);fill-rule:nonzero;"/>

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

+1 -1
View File
@@ -225,7 +225,7 @@ For a real deployment, start with the docker-compose-example.yaml file. replace
## Mastodon Configuration ## Mastodon Configuration
After logging in, open <http://localhost:8000/profile>, enter the Mastodon instance, and select **Connect Mastodon**. LinkLog registers an OAuth application on that instance, opens Mastodon authorization, and stores the returned per-user access token after the callback. The requested scopes are `read:accounts` and `write:statuses`. After logging in, open <http://localhost:8000/profile>, enter the Mastodon instance, and select **Connect Mastodon**. LinkLog registers an OAuth application on that instance, opens Mastodon authorization, and stores the returned per-user access token after the callback. The requested scopes are `read:accounts` and `write:statuses`. Posts use the configured prefix followed directly by the title, an optional comment, a `from: URL` line when a title exists, and tags on the final line, with blank lines between sections.
- **Instance**: hostname or URL such as `mastodon.social` or `https://mastodon.social` - **Instance**: hostname or URL such as `mastodon.social` or `https://mastodon.social`
- **Post prefix**: text placed immediately before the link; defaults to `From my #LinkLog: ` - **Post prefix**: text placed immediately before the link; defaults to `From my #LinkLog: `
+42
View File
@@ -628,3 +628,45 @@ Changed Mastodon status publishing to form-encoded API requests, added safe conf
### Assistant outcome ### Assistant outcome
Removed the duplicate `TRAEFIK_HOST` configuration, changed both Traefik router rules to use `LINKLOG_PUBLIC_URL`, standardized the value as a host name, and normalized scheme-less values to valid HTTP/HTTPS backend callback URLs. Compose validation, URL normalization, and the complete Dockerized backend suite passed with 22 tests. Removed the duplicate `TRAEFIK_HOST` configuration, changed both Traefik router rules to use `LINKLOG_PUBLIC_URL`, standardized the value as a host name, and normalized scheme-less values to valid HTTP/HTTPS backend callback URLs. Compose validation, URL normalization, and the complete Dockerized backend suite passed with 22 tests.
### User
Format Mastodon posts as `From my #LinkLog:`, the web page title, an optional comment, and `from: URL`.
### Assistant outcome
Updated the Mastodon formatter to emit the configured prefix, title, optional comment, and source URL as blank-line-separated blocks. Tags remain on the source URL line. The focused posting test and complete Dockerized backend suite passed with 22 tests, and the running container is healthy on port 5469.
### User
Only print the `from` line if there is no title. Put the title directly behind the colon, and put all tags on the last line.
### Assistant outcome
Updated Mastodon formatting to place the title directly after `From my #LinkLog:`, omit the `from:` line when no title is available, and place tags on a separate final line. Added no-title regression coverage. The focused tests passed and the complete Dockerized backend suite passed with 23 tests.
### User
Do not show edit buttons on the home page. On `/<user>/`, show the edit button on the right and add a delete button.
### Assistant outcome
Added an owner-scoped `DELETE /api/links/{link_id}` endpoint. Feed edit/delete controls now render only on user-specific pages, never on the home feed, and are right-aligned in an entry action group. Added owner-only deletion regression coverage and updated the feed cache key. The complete Dockerized backend suite passed with 23 tests, and the running container is healthy.
### User
Put the buttons on the right-hand side of the link item, make them smaller, and keep the same color scheme.
### Assistant outcome
Changed the entry action group to float at the right edge of user-specific link items, allowing content to wrap around it. Reduced Edit and Delete button padding, radius, and font size while preserving Catppuccin surface and danger colors.
### User
Put the Mastodon button on the lower-right corner of the log entry.
### Assistant outcome
Split the Mastodon control from the Edit/Delete action group and positioned it at the lower-right corner of user-specific link entries. Reserved bottom space in each entry to prevent overlap.
### User
Change the Mastodon button location to immediately below the Edit and Delete buttons.
### Assistant outcome
Changed the Mastodon button from absolute lower-right positioning to normal right-floated flow with `clear: right`, placing it directly beneath the Edit/Delete row while preserving the existing logo and posted-state styling.
### User
When an authorized user is on their `/<user>/` page, show a button to post on Mastodon. After a message has been posted, keep the button functional but change its color and use the Mastodon logo inside it.
### Assistant outcome
Added persisted Mastodon post state, an owner-scoped repost endpoint, and an owner-only Mastodon button on user-specific pages. The button includes a bundled Mastodon logo, says `Post to Mastodon` before posting, changes to the posted teal state and `Post again` afterward, and remains functional for reposting. Home-page entries remain free of controls. The complete Dockerized backend suite passed with 23 tests and the running container is healthy.
+7
View File
@@ -115,6 +115,13 @@
109. use VIVE that is in the current directory 109. use VIVE that is in the current directory
110. Posting to mastodon seems to fail, try to fix, add debug logging 110. Posting to mastodon seems to fail, try to fix, add debug logging
111. LINKLOG_PUBLIC_URL and TRAEFIK_HOST are the same and can be merged. (use LINKLOG_PUBLIC_URL), fix docker-compose to use said variable 111. LINKLOG_PUBLIC_URL and TRAEFIK_HOST are the same and can be merged. (use LINKLOG_PUBLIC_URL), fix docker-compose to use said variable
112. Format mastodon posts like From my #LinkLog: Title, optional comment, and from: URL
113. Only print the 'from' line if there is no title. Put the title directly behind the colon, and put all tags on the last line.
114. In the frontend do not show edit buttons on the home page even when a user is logged in. On the /<user>/ page show the edit button on the right of the entry. Also add a delete button.
115. Put the buttons on the right hand side of the link item, make the buttons smaller and same color scheme
116. Put the mastodon button on the lower right corner of the log entry
117. Change that location to be immediately below the edit and delete button
116. When the user is authorized and on its /<user>/ page show a Mastodon post button with logo; after posting keep it functional but change its color.
## Future entries ## Future entries
+46 -1
View File
@@ -5,7 +5,8 @@ from fastapi import APIRouter, Header, HTTPException, status
import logging import logging
from pydantic import BaseModel from pydantic import BaseModel
from backend.app.services.link_service import create_link, list_public_links, list_tags, update_link from backend.app.services.link_service import create_link, delete_link, get_link_tags, list_public_links, list_tags, mark_mastodon_posted, update_link
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
@@ -47,6 +48,9 @@ def create_link_endpoint(payload: LinkCreate, authorization: str | None = Header
except ValueError as error: except ValueError as error:
raise HTTPException(status_code=422, detail=str(error)) from error raise HTTPException(status_code=422, detail=str(error)) from error
plugin_results = plugin_manager.dispatch({'type': 'link_created', **record}) plugin_results = plugin_manager.dispatch({'type': 'link_created', **record})
mastodon_result = next((result for result in plugin_results if result.get('plugin') == 'mastodon'), None)
if mastodon_result and mastodon_result.get('status') == 'posted':
mark_mastodon_posted(record['id'], info['user_id'], mastodon_result.get('post_id'))
if any(result.get('status') == 'failed' for result in plugin_results): if any(result.get('status') == 'failed' for result in plugin_results):
logger.warning('One or more plugins failed for link_id=%s results=%s', record['id'], plugin_results) logger.warning('One or more plugins failed for link_id=%s results=%s', record['id'], plugin_results)
return record return record
@@ -73,6 +77,47 @@ def update_link_endpoint(
return record return record
@router.delete('/links/{link_id}')
def delete_link_endpoint(
link_id: 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')
if not delete_link(link_id, info['user_id']):
raise HTTPException(status_code=404, detail='Link not found or not owned by user')
return {'status': 'deleted', 'id': link_id}
@router.post('/links/{link_id}/mastodon')
def post_link_to_mastodon(
link_id: 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')
with get_connection() as conn:
row = conn.execute('SELECT * FROM links WHERE id = ? AND user_id = ?', (link_id, info['user_id'])).fetchone()
if row is None:
raise HTTPException(status_code=404, detail='Link not found or not owned by user')
event = dict(row)
with get_connection() as conn:
event['tags'] = get_link_tags(conn, link_id)
result = plugin_manager.post_to_mastodon({'type': 'link_created', **event})
if result.get('status') == 'posted':
mark_mastodon_posted(link_id, info['user_id'], result.get('post_id'))
return {'status': 'posted', 'post_id': result.get('post_id')}
if result.get('status') == 'skipped':
raise HTTPException(status_code=409, detail='Mastodon is not enabled or configured')
raise HTTPException(status_code=502, detail=result.get('reason', 'Mastodon post failed'))
@router.get('/links') @router.get('/links')
def list_links(): def list_links():
return list_public_links() return list_public_links()
+1
View File
@@ -43,6 +43,7 @@ def public_feed(
'is_owner': item['user_id'] == current_user_id, 'is_owner': item['user_id'] == current_user_id,
'can_edit': item['user_id'] == current_user_id, 'can_edit': item['user_id'] == current_user_id,
'created_at': item['created_at'], 'created_at': item['created_at'],
'mastodon_posted': bool(item['mastodon_posted']),
} }
for item in items for item in items
] ]
+5
View File
@@ -151,6 +151,11 @@ CREATE TABLE IF NOT EXISTS mastodon_oauth_states (
); );
CREATE INDEX IF NOT EXISTS idx_mastodon_oauth_states_state_hash CREATE INDEX IF NOT EXISTS idx_mastodon_oauth_states_state_hash
ON mastodon_oauth_states(state_hash); ON mastodon_oauth_states(state_hash);
'''),
(9, '''
ALTER TABLE links ADD COLUMN mastodon_posted INTEGER NOT NULL DEFAULT 0;
ALTER TABLE links ADD COLUMN mastodon_post_id TEXT;
ALTER TABLE links ADD COLUMN mastodon_posted_at TEXT;
'''), '''),
] ]
+22
View File
@@ -157,6 +157,28 @@ def update_link(
return record return record
def delete_link(link_id: str, user_id: str) -> bool:
with get_connection() as conn:
cursor = conn.execute(
'DELETE FROM links WHERE id = ? AND user_id = ?',
(link_id, user_id),
)
conn.commit()
return cursor.rowcount > 0
def mark_mastodon_posted(link_id: str, user_id: str, post_id: str | None) -> bool:
with get_connection() as conn:
cursor = conn.execute(
'''UPDATE links
SET mastodon_posted = 1, mastodon_post_id = ?, mastodon_posted_at = CURRENT_TIMESTAMP
WHERE id = ? AND user_id = ?''',
(post_id, link_id, user_id),
)
conn.commit()
return cursor.rowcount > 0
def list_public_users(): def list_public_users():
with get_connection() as conn: with get_connection() as conn:
rows = conn.execute( rows = conn.execute(
+16 -8
View File
@@ -59,18 +59,19 @@ class MastodonPlugin(BasePlugin):
) )
return {'status': 'skipped', 'plugin': self.name, 'reason': 'not_configured'} return {'status': 'skipped', 'plugin': self.name, 'reason': 'not_configured'}
status_parts = [event.get('title') or event.get('url', '')]
if event.get('comment'):
status_parts.append(event['comment'])
post_prefix = config.get('post_prefix') post_prefix = config.get('post_prefix')
if post_prefix is None and config.get('hashtag'): if post_prefix is None and config.get('hashtag'):
post_prefix = f'#{str(config["hashtag"]).strip().lstrip("#")} ' post_prefix = f'#{str(config["hashtag"]).strip().lstrip("#")} '
post_prefix = str(post_prefix if post_prefix is not None else DEFAULT_POST_PREFIX) post_prefix = str(post_prefix if post_prefix is not None else DEFAULT_POST_PREFIX).strip()
status = f'{post_prefix}{event.get("url", "")}'.strip() title = str(event.get('title') or '').strip()
status_parts = [f'{post_prefix} {title}'.strip()]
if event.get('comment'):
status_parts.append(event['comment'])
if title:
status_parts.append(f'from: {event.get("url", "")}')
if event.get('tags'): if event.get('tags'):
status = f'{status} {" ".join(event["tags"])}' status_parts.append(' '.join(event['tags']))
status_parts.append(status) post_body = '\n\n'.join(status_parts)
post_body = '\n'.join(status_parts)
endpoint = f'{instance}/api/v1/statuses' endpoint = f'{instance}/api/v1/statuses'
logger.debug( logger.debug(
'Posting link to Mastodon: endpoint=%s user_id=%s event_id=%s body_length=%d', 'Posting link to Mastodon: endpoint=%s user_id=%s event_id=%s body_length=%d',
@@ -149,5 +150,12 @@ class PluginManager:
logger.debug('Plugin dispatch result: plugin=%s event_id=%s result=%s', plugin.name, event.get('id'), result) logger.debug('Plugin dispatch result: plugin=%s event_id=%s result=%s', plugin.name, event.get('id'), result)
return results return results
def post_to_mastodon(self, event):
self.refresh_from_db()
plugin = next(plugin for plugin in self.plugins if plugin.name == 'mastodon')
if not plugin.enabled:
return {'status': 'skipped', 'plugin': 'mastodon', 'reason': 'disabled'}
return plugin.handle_event(event)
plugin_manager = PluginManager() plugin_manager = PluginManager()
+33 -3
View File
@@ -6,7 +6,7 @@ import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs from urllib.parse import parse_qs
from uuid import uuid4 from uuid import uuid4
from unittest.mock import patch from unittest.mock import MagicMock, patch
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -334,6 +334,11 @@ def test_only_link_owner_can_edit_link():
}) })
assert denied.status_code == 404 assert denied.status_code == 404
deleted = client.delete(f'/api/links/{link_id}', headers=owner_headers)
assert deleted.status_code == 200
assert client.delete(f'/api/links/{link_id}', headers=owner_headers).status_code == 404
assert client.delete(f'/api/links/{link_id}', headers=login_headers('bob')).status_code == 404
def test_logout_revokes_token_and_admin_can_list_plugins(): def test_logout_revokes_token_and_admin_can_list_plugins():
headers = login_headers() headers = login_headers()
@@ -396,7 +401,9 @@ def test_public_and_admin_pages_render_html():
assert '<a id="auth-username" class="user-name" href="/">' in admin_page assert '<a id="auth-username" class="user-name" href="/">' in admin_page
assert 'admin.js?v=5' in admin_page assert 'admin.js?v=5' in admin_page
feed_script = client.get('/static/feed.js?v=7').text feed_script = client.get('/static/feed.js?v=7').text
assert 'if (item.is_owner)' in feed_script assert 'if (item.is_owner && !showIdentity)' in feed_script
assert 'deleteEntry(item, deleteButton)' in feed_script
assert 'postToMastodon(item, mastodonButton)' in feed_script
assert 'tag.toLowerCase() === pref.tag.toLowerCase()' 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 'return `${date.getFullYear()} ${months[date.getMonth()]} ${date.getDate()} - ${hours}:${minutes}`' in feed_script
assert 'edit-tag-options' in feed_script assert 'edit-tag-options' in feed_script
@@ -445,13 +452,36 @@ def test_link_submission_posts_to_enabled_mastodon_plugin():
assert received['path'] == '/api/v1/statuses' assert received['path'] == '/api/v1/statuses'
assert received['authorization'] == 'Bearer test-token' assert received['authorization'] == 'Bearer test-token'
assert received['content_type'] == 'application/x-www-form-urlencoded' assert received['content_type'] == 'application/x-www-form-urlencoded'
assert received['body'] == {'status': ['A useful page\nWorth sharing\nFrom my #LinkLog: https://example.com/useful #python #web']} assert received['body'] == {'status': ['From my #LinkLog: A useful page\n\nWorth sharing\n\nfrom: https://example.com/useful\n\n#python #web']}
posted_item = next(item for item in client.get('/api/public/feed/alice', headers=headers).json() if item['id'] == response.json()['id'])
assert posted_item['mastodon_posted'] is True
finally: finally:
server.shutdown() server.shutdown()
thread.join() thread.join()
server.server_close() server.server_close()
def test_mastodon_post_without_title_omits_source_line():
from backend.app.services.plugin_manager import MastodonPlugin
response = MagicMock()
response.__enter__.return_value.read.return_value = b'{"id":"status-2"}'
plugin = MastodonPlugin()
plugin.initialize({'instance': 'https://mastodon.example', 'access_token': 'test-token'})
with patch('backend.app.services.plugin_manager.urlopen', return_value=response) as open_url:
result = plugin.handle_event({
'url': 'https://example.com/useful',
'title': '',
'comment': 'A comment',
'tags': ['#tag'],
})
body = parse_qs(open_url.call_args.args[0].data.decode())['status'][0]
assert result['status'] == 'posted'
assert body == 'From my #LinkLog:\n\nA comment\n\n#tag'
def test_plugin_config_can_be_saved_for_mastodon(): def test_plugin_config_can_be_saved_for_mastodon():
headers = login_headers() headers = login_headers()
assert client.put('/api/user/plugins/mastodon', headers=headers, json={ assert client.put('/api/user/plugins/mastodon', headers=headers, json={
+2 -2
View File
@@ -10,7 +10,7 @@ def test_database_migrations_are_versioned_and_idempotent():
connection = sqlite3.connect(':memory:') connection = sqlite3.connect(':memory:')
apply_migrations(connection) apply_migrations(connection)
assert get_schema_version(connection) == 8 assert get_schema_version(connection) == 9
tables = { tables = {
row[0] row[0]
for row in connection.execute( for row in connection.execute(
@@ -27,6 +27,6 @@ def test_database_migrations_are_versioned_and_idempotent():
assert set(DEFAULT_TAGS) <= seeded_tags assert set(DEFAULT_TAGS) <= seeded_tags
apply_migrations(connection) apply_migrations(connection)
assert get_schema_version(connection) == 8 assert get_schema_version(connection) == 9
connection.close() connection.close()
+46 -2
View File
@@ -117,13 +117,30 @@ function renderFeed(items, showIdentity = true) {
meta.className = 'meta'; meta.className = 'meta';
meta.textContent = formatDate(item.created_at); meta.textContent = formatDate(item.created_at);
if (item.is_owner) { if (item.is_owner && !showIdentity) {
const actions = document.createElement('div');
actions.className = 'entry-actions';
const editButton = document.createElement('button'); const editButton = document.createElement('button');
editButton.type = 'button'; editButton.type = 'button';
editButton.className = 'edit-button'; editButton.className = 'edit-button';
editButton.textContent = 'Edit'; editButton.textContent = 'Edit';
editButton.addEventListener('click', () => showEditForm(article, item)); editButton.addEventListener('click', () => showEditForm(article, item));
article.appendChild(editButton); const deleteButton = document.createElement('button');
deleteButton.type = 'button';
deleteButton.className = 'delete-button';
deleteButton.textContent = 'Delete';
deleteButton.addEventListener('click', () => deleteEntry(item, deleteButton));
const mastodonButton = document.createElement('button');
mastodonButton.type = 'button';
mastodonButton.className = `mastodon-button${item.mastodon_posted ? ' posted' : ''}`;
const mastodonLogo = document.createElement('img');
mastodonLogo.src = '/static/mastodon.svg';
mastodonLogo.alt = '';
mastodonButton.append(mastodonLogo, document.createTextNode(item.mastodon_posted ? 'Post again' : 'Post to Mastodon'));
mastodonButton.addEventListener('click', () => postToMastodon(item, mastodonButton));
actions.append(editButton, deleteButton);
article.appendChild(actions);
article.appendChild(mastodonButton);
} }
if (showIdentity) { if (showIdentity) {
@@ -144,6 +161,33 @@ function renderFeed(items, showIdentity = true) {
}); });
} }
async function postToMastodon(item, button) {
button.disabled = true;
const response = await fetch(`/api/links/${encodeURIComponent(item.id)}/mastodon`, {
method: 'POST',
headers: {Authorization: `Bearer ${accessToken}`},
});
if (response.ok) {
await loadFeed();
} else {
button.disabled = false;
}
}
async function deleteEntry(item, button) {
if (!window.confirm(`Delete this link?`)) return;
button.disabled = true;
const response = await fetch(`/api/links/${encodeURIComponent(item.id)}`, {
method: 'DELETE',
headers: {Authorization: `Bearer ${accessToken}`},
});
if (response.ok) {
await loadFeed();
} else {
button.disabled = false;
}
}
function showEditForm(article, item) { function showEditForm(article, item) {
if (article.querySelector('.edit-form')) return; if (article.querySelector('.edit-form')) return;
const form = document.createElement('form'); const form = document.createElement('form');
+1 -1
View File
@@ -5,7 +5,7 @@
<svg width="100%" height="100%" viewBox="0 0 1804 1712" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"> <svg width="100%" height="100%" viewBox="0 0 1804 1712" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1,0,0,1,-669.006,-255.89)"> <g transform="matrix(1,0,0,1,-669.006,-255.89)">
<g transform="matrix(0.972876,0,0,1.26057,-24.1588,-587.485)"> <g transform="matrix(0.972876,0,0,1.26057,-24.1588,-587.485)">
<rect x="712.491" y="669.041" width="1854.11" height="1357.72" style="fill:rgb(88,91,112);"/> <rect x="712.491" y="669.041" width="1854.11" height="1357.72" style="fill:rgb(24, 24, 37);"/>
</g> </g>
<g transform="matrix(5.14628,0,0,6.12686,41.2191,-8673.98)"> <g transform="matrix(5.14628,0,0,6.12686,41.2191,-8673.98)">
<path d="M141.95,1644.94L141.95,1633.94C157.554,1633.4 169.724,1631.04 178.462,1626.86C187.2,1622.69 193.344,1615.8 196.894,1606.21C200.444,1596.61 202.218,1583.47 202.218,1566.77C202.218,1554.44 201.146,1544.16 199,1535.93C196.855,1527.7 193.285,1521.25 188.292,1516.57C185.016,1513.6 181.154,1511.26 176.707,1509.54C172.26,1507.83 167.15,1506.62 161.376,1505.92C155.603,1505.21 149.128,1504.86 141.95,1504.86L141.95,1493.86L384.076,1493.86L384.076,1504.86C373.388,1504.86 364.221,1505.7 356.575,1507.38C348.929,1509.06 342.708,1512.12 337.909,1516.57C333.111,1521.01 329.581,1527.27 327.319,1535.35C325.056,1543.42 323.925,1553.9 323.925,1566.77L323.925,1633.36C339.06,1633.36 353.532,1631.08 367.341,1626.51C381.15,1621.95 393.399,1616 404.087,1608.66C414.776,1601.41 423.299,1593.3 429.657,1584.32C436.016,1575.35 439.546,1566.61 440.248,1558.11L452.536,1558.11C452.536,1562.4 452.419,1568.35 452.185,1575.96C451.951,1583.56 451.6,1591.85 451.132,1600.82C450.586,1609.95 449.942,1618.34 449.201,1625.99C448.46,1633.63 447.582,1639.95 446.568,1644.94L141.95,1644.94Z" style="fill:rgb(245,224,220);fill-rule:nonzero;"/> <path d="M141.95,1644.94L141.95,1633.94C157.554,1633.4 169.724,1631.04 178.462,1626.86C187.2,1622.69 193.344,1615.8 196.894,1606.21C200.444,1596.61 202.218,1583.47 202.218,1566.77C202.218,1554.44 201.146,1544.16 199,1535.93C196.855,1527.7 193.285,1521.25 188.292,1516.57C185.016,1513.6 181.154,1511.26 176.707,1509.54C172.26,1507.83 167.15,1506.62 161.376,1505.92C155.603,1505.21 149.128,1504.86 141.95,1504.86L141.95,1493.86L384.076,1493.86L384.076,1504.86C373.388,1504.86 364.221,1505.7 356.575,1507.38C348.929,1509.06 342.708,1512.12 337.909,1516.57C333.111,1521.01 329.581,1527.27 327.319,1535.35C325.056,1543.42 323.925,1553.9 323.925,1566.77L323.925,1633.36C339.06,1633.36 353.532,1631.08 367.341,1626.51C381.15,1621.95 393.399,1616 404.087,1608.66C414.776,1601.41 423.299,1593.3 429.657,1584.32C436.016,1575.35 439.546,1566.61 440.248,1558.11L452.536,1558.11C452.536,1562.4 452.419,1568.35 452.185,1575.96C451.951,1583.56 451.6,1591.85 451.132,1600.82C450.586,1609.95 449.942,1618.34 449.201,1625.99C448.46,1633.63 447.582,1639.95 446.568,1644.94L141.95,1644.94Z" style="fill:rgb(245,224,220);fill-rule:nonzero;"/>

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-label="Mastodon">
<path fill="currentColor" d="M21.6 8.2c0-4.1-2.7-5.3-2.7-5.3C17.5 2.3 14.8 2 12 2h-.1c-2.8 0-5.5.3-6.9.9 0 0-2.7 1.2-2.7 5.3 0 .9 0 2 0 3.1 0 4.2.3 8.3 1.8 9.4 1.7 1.3 4.1 1.6 6.1 1.7 1.9.1 3.6-.4 3.6-.4l-.1-1.8s-1.6.5-3.5.4c-1.8-.1-3.6-.2-3.9-2.1-.1-.5-.1-1-.1-1.5 0 0 1.7.4 3.9.5 1.3.1 2.6.1 3.9-.1 2.5-.3 4.7-.8 4.7-.8v-1.8c0-1.4 0-2.8 0-3.1 0-4.1-2.7-5.3-2.7-5.3-1.4-.6-3.8-.9-6-.9h-.1c-2.2 0-4.6.3-6 .9 0 0-2.7 1.2-2.7 5.3v.1h3.3v-.1c0-1.7.7-2.1.7-2.1.4-.2 1.1-.3 1.8-.3.7 0 1.4.2 1.8.4.7.4 1.1 1.2 1.1 2.3v.5c-1.1-.3-2.3-.4-3.5-.4-2.2 0-3.3.8-3.3.8v3.2h3.3v-1.5c.4-.2 1.1-.3 1.9-.3.8 0 1.5.1 1.9.3v1.5h3.3V9.5c0-1.1.4-1.9 1.1-2.3.4-.2 1.1-.4 1.8-.4.7 0 1.4.1 1.8.3 0 0 .7.4.7 2.1v.1h3.3V8.2z"/>
</svg>

After

Width:  |  Height:  |  Size: 803 B

+52 -6
View File
@@ -52,13 +52,14 @@ body::selection {
padding: 0 24px; padding: 0 24px;
} }
.site-header { .site-header {
position: relative; position: relative;
z-index: 20; z-index: 20;
padding: 56px 0 48px; padding: 6px 0 8px;
background: background:
linear-gradient(115deg, rgba(203, 166, 247, 0.18), transparent 45%), linear-gradient(115deg, var(--mantle), transparent 45%),
var(--mantle); rgba(203, 166, 247, 0.18);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
@@ -420,11 +421,56 @@ button:disabled {
.edit-button { .edit-button {
min-width: 0; min-width: 0;
margin-top: 14px; padding: 5px 9px;
padding: 8px 12px; border-radius: 6px;
background: var(--surface-1); background: var(--surface-1);
border-color: var(--surface-2); border-color: var(--surface-2);
color: var(--text); color: var(--text);
font-size: 0.82rem;
}
.entry-actions {
display: flex;
float: right;
gap: 8px;
margin: 0 0 8px 16px;
}
.delete-button {
min-width: 0;
padding: 5px 9px;
border-radius: 6px;
border-color: rgba(243, 139, 168, 0.45);
background: transparent;
color: var(--red);
font-size: 0.82rem;
}
.mastodon-button {
clear: right;
float: right;
margin: 0 0 8px 16px;
display: inline-flex;
align-items: center;
gap: 5px;
min-width: 0;
padding: 5px 9px;
border-radius: 6px;
background: var(--surface-1);
border-color: var(--surface-2);
color: var(--text);
font-size: 0.82rem;
}
.mastodon-button.posted {
background: var(--teal);
border-color: var(--teal);
color: var(--crust);
}
.mastodon-button img {
width: 15px;
height: 15px;
} }
.edit-form { .edit-form {
@@ -475,7 +521,7 @@ button:disabled {
} }
.link-item { .link-item {
padding: 22px; padding: 11px;
background: rgba(49, 50, 68, 0.84); background: rgba(49, 50, 68, 0.84);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 12px; border-radius: 12px;
+1 -1
View File
@@ -79,6 +79,6 @@
<script src="/static/auth-header.js?v=3"></script> <script src="/static/auth-header.js?v=3"></script>
<script src="/static/logout.js?v=3"></script> <script src="/static/logout.js?v=3"></script>
<script src="/static/feed.js?v=7"></script> <script src="/static/feed.js?v=9"></script>
</body> </body>
</html> </html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 950 B

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 13 KiB

+1 -1
View File
@@ -5,7 +5,7 @@
<svg width="100%" height="100%" viewBox="0 0 1804 1712" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"> <svg width="100%" height="100%" viewBox="0 0 1804 1712" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1,0,0,1,-669.006,-255.89)"> <g transform="matrix(1,0,0,1,-669.006,-255.89)">
<g transform="matrix(0.972876,0,0,1.26057,-24.1588,-587.485)"> <g transform="matrix(0.972876,0,0,1.26057,-24.1588,-587.485)">
<rect x="712.491" y="669.041" width="1854.11" height="1357.72" style="fill:rgb(88,91,112);"/> <rect x="712.491" y="669.041" width="1854.11" height="1357.72" style="fill:rgb(24, 24, 37);"/>
</g> </g>
<g transform="matrix(5.14628,0,0,6.12686,41.2191,-8673.98)"> <g transform="matrix(5.14628,0,0,6.12686,41.2191,-8673.98)">
<path d="M141.95,1644.94L141.95,1633.94C157.554,1633.4 169.724,1631.04 178.462,1626.86C187.2,1622.69 193.344,1615.8 196.894,1606.21C200.444,1596.61 202.218,1583.47 202.218,1566.77C202.218,1554.44 201.146,1544.16 199,1535.93C196.855,1527.7 193.285,1521.25 188.292,1516.57C185.016,1513.6 181.154,1511.26 176.707,1509.54C172.26,1507.83 167.15,1506.62 161.376,1505.92C155.603,1505.21 149.128,1504.86 141.95,1504.86L141.95,1493.86L384.076,1493.86L384.076,1504.86C373.388,1504.86 364.221,1505.7 356.575,1507.38C348.929,1509.06 342.708,1512.12 337.909,1516.57C333.111,1521.01 329.581,1527.27 327.319,1535.35C325.056,1543.42 323.925,1553.9 323.925,1566.77L323.925,1633.36C339.06,1633.36 353.532,1631.08 367.341,1626.51C381.15,1621.95 393.399,1616 404.087,1608.66C414.776,1601.41 423.299,1593.3 429.657,1584.32C436.016,1575.35 439.546,1566.61 440.248,1558.11L452.536,1558.11C452.536,1562.4 452.419,1568.35 452.185,1575.96C451.951,1583.56 451.6,1591.85 451.132,1600.82C450.586,1609.95 449.942,1618.34 449.201,1625.99C448.46,1633.63 447.582,1639.95 446.568,1644.94L141.95,1644.94Z" style="fill:rgb(245,224,220);fill-rule:nonzero;"/> <path d="M141.95,1644.94L141.95,1633.94C157.554,1633.4 169.724,1631.04 178.462,1626.86C187.2,1622.69 193.344,1615.8 196.894,1606.21C200.444,1596.61 202.218,1583.47 202.218,1566.77C202.218,1554.44 201.146,1544.16 199,1535.93C196.855,1527.7 193.285,1521.25 188.292,1516.57C185.016,1513.6 181.154,1511.26 176.707,1509.54C172.26,1507.83 167.15,1506.62 161.376,1505.92C155.603,1505.21 149.128,1504.86 141.95,1504.86L141.95,1493.86L384.076,1493.86L384.076,1504.86C373.388,1504.86 364.221,1505.7 356.575,1507.38C348.929,1509.06 342.708,1512.12 337.909,1516.57C333.111,1521.01 329.581,1527.27 327.319,1535.35C325.056,1543.42 323.925,1553.9 323.925,1566.77L323.925,1633.36C339.06,1633.36 353.532,1631.08 367.341,1626.51C381.15,1621.95 393.399,1616 404.087,1608.66C414.776,1601.41 423.299,1593.3 429.657,1584.32C436.016,1575.35 439.546,1566.61 440.248,1558.11L452.536,1558.11C452.536,1562.4 452.419,1568.35 452.185,1575.96C451.951,1583.56 451.6,1591.85 451.132,1600.82C450.586,1609.95 449.942,1618.34 449.201,1625.99C448.46,1633.63 447.582,1639.95 446.568,1644.94L141.95,1644.94Z" style="fill:rgb(245,224,220);fill-rule:nonzero;"/>

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB