Post to mastodon from the profile page for logged in users
This commit is contained in:
@@ -652,3 +652,21 @@ Put the buttons on the right-hand side of the link item, make them smaller, and
|
|||||||
|
|
||||||
### Assistant outcome
|
### 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.
|
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.
|
||||||
|
|||||||
@@ -119,6 +119,9 @@
|
|||||||
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.
|
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.
|
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
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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, delete_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
|
||||||
@@ -88,6 +92,32 @@ def delete_link_endpoint(
|
|||||||
return {'status': 'deleted', 'id': link_id}
|
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()
|
||||||
|
|||||||
@@ -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
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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;
|
||||||
'''),
|
'''),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -167,6 +167,18 @@ def delete_link(link_id: str, user_id: str) -> bool:
|
|||||||
return cursor.rowcount > 0
|
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(
|
||||||
|
|||||||
@@ -150,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()
|
||||||
|
|||||||
@@ -403,6 +403,7 @@ def test_public_and_admin_pages_render_html():
|
|||||||
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 && !showIdentity)' in feed_script
|
assert 'if (item.is_owner && !showIdentity)' in feed_script
|
||||||
assert 'deleteEntry(item, deleteButton)' 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
|
||||||
@@ -452,6 +453,8 @@ def test_link_submission_posts_to_enabled_mastodon_plugin():
|
|||||||
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': ['From my #LinkLog: A useful page\n\nWorth sharing\n\nfrom: https://example.com/useful\n\n#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()
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -130,8 +130,17 @@ function renderFeed(items, showIdentity = true) {
|
|||||||
deleteButton.className = 'delete-button';
|
deleteButton.className = 'delete-button';
|
||||||
deleteButton.textContent = 'Delete';
|
deleteButton.textContent = 'Delete';
|
||||||
deleteButton.addEventListener('click', () => deleteEntry(item, deleteButton));
|
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);
|
actions.append(editButton, deleteButton);
|
||||||
article.appendChild(actions);
|
article.appendChild(actions);
|
||||||
|
article.appendChild(mastodonButton);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showIdentity) {
|
if (showIdentity) {
|
||||||
@@ -152,6 +161,19 @@ 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) {
|
async function deleteEntry(item, button) {
|
||||||
if (!window.confirm(`Delete this link?`)) return;
|
if (!window.confirm(`Delete this link?`)) return;
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
|
|||||||
@@ -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 |
@@ -445,6 +445,33 @@ button:disabled {
|
|||||||
font-size: 0.82rem;
|
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 {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -493,7 +520,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;
|
||||||
|
|||||||
@@ -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=8"></script>
|
<script src="/static/feed.js?v=9"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user