Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bd64b870c | ||
|
|
9494d2b119 | ||
|
|
3661d0b4b7 | ||
|
|
d433a305f5 | ||
|
|
1c2d1b71ff |
@@ -9,8 +9,21 @@
|
|||||||
* Ability to minimize settings panels to only show headers in the Admin and Profile interfaces for easy navigation
|
* Ability to minimize settings panels to only show headers in the Admin and Profile interfaces for easy navigation
|
||||||
* Styled settings panel headers with distinct theme accent colors for visual distinction
|
* Styled settings panel headers with distinct theme accent colors for visual distinction
|
||||||
* Settings panels are collapsed by default when opening the Admin and Profile pages
|
* Settings panels are collapsed by default when opening the Admin and Profile pages
|
||||||
|
* Show checked-by-default Mastodon publishing on New Entry only for configured users
|
||||||
|
* Made every web header logo link to the home page
|
||||||
|
* Added a high-contrast Red color theme
|
||||||
|
* Brightened the Red theme surfaces and deepened its crimson accents
|
||||||
|
* Allow every user to filter the feed using every available tag or label
|
||||||
### Fixed
|
### Fixed
|
||||||
* Fixed element ID conflict on the Admin page so Available Themes load correctly
|
* Fixed element ID conflict on the Admin page so Available Themes load correctly
|
||||||
|
* Positioned new-entry tag checkboxes after their label text
|
||||||
|
* Kept new-entry tag checkboxes immediately left of their labels at all viewport sizes
|
||||||
|
* Prevented the new-entry form grid from placing tag checkboxes above their labels
|
||||||
|
* Kept unconfigured New Entry Mastodon publishing controls hidden
|
||||||
|
* Restored the home-page header layout, keeping the action controls above the filter toolbar
|
||||||
|
* Made the tag filter refresh the feed directly when its selection changes
|
||||||
|
* Cache-busted the feed script to ensure browsers load the responsive tag filter
|
||||||
|
* Fixed feed initialization so tag filtering does not receive promise results as a selected tag [Still no complete fix]
|
||||||
|
|
||||||
## Version v0.1.1
|
## Version v0.1.1
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -1340,3 +1340,81 @@ On the admin page under Available Themes, the page does not load available theme
|
|||||||
|
|
||||||
### Assistant outcome
|
### Assistant outcome
|
||||||
Fixed element ID conflict on `admin.html` where both the header theme submenu and the admin form container used `id="theme-options"`. Renamed the admin form container to `id="admin-theme-options"` and updated `admin.js` to target `#admin-theme-options`. Available themes now load correctly on the admin page. Passed all 55 tests.
|
Fixed element ID conflict on `admin.html` where both the header theme submenu and the admin form container used `id="theme-options"`. Renamed the admin form container to `id="admin-theme-options"` and updated `admin.js` to target `#admin-theme-options`. Available themes now load correctly on the admin page. Passed all 55 tests.
|
||||||
|
|
||||||
|
### User
|
||||||
|
on the new-entry page the checkbox should be following the labels - now they are positioned above.
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
Reordered the new-entry tag control markup so each label is followed by its checkbox. JavaScript syntax validation and the focused API test module passed with 35 tests.
|
||||||
|
|
||||||
|
### User
|
||||||
|
The checkboxes should be immediately to the left of the label not below left
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
Restored checkbox-before-label ordering and added a scoped compact-sizing rule for new-entry tag checkboxes. This prevents the mobile global input-width rule from placing a checkbox on its own line. JavaScript syntax validation and the focused API test module passed with 35 tests.
|
||||||
|
|
||||||
|
### User
|
||||||
|
The checkbox takes the full width of the grid which forces the label to be printed underneath instead of directly next to it.
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
Fixed the actual layout conflict: `.form-section label` uses a grid layout whose selector overrode the tag label's flex layout. Added the more specific `.form-section label.tag-checkbox` inline-flex rule and reset its checkbox margin, keeping each checkbox directly left of its tag text. The focused API test module passed with 35 tests and `git diff --check` passed.
|
||||||
|
|
||||||
|
### User
|
||||||
|
Only show the Mastodon publishing on new page if the user has Mastodon posting configured. Then default the checkbox to yes
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
Added a safe `configured` status to the authenticated Mastodon configuration response. New Entry now hides its Mastodon publishing section unless the current user has both an instance and access token configured; when shown, its checkbox defaults to checked. Link creation accepts `post_to_mastodon`, allowing an unchecked control to skip Mastodon while preserving dispatch of other plugins. Added posting and configuration regression coverage.
|
||||||
|
|
||||||
|
### User
|
||||||
|
In the currently running contaier the user olaf has no mastodon configured but the mastodon checkbox is still visible
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
Found that `.form-section { display: grid; }` appeared later in the stylesheet than `.hidden { display: none; }`, overriding the initial hidden state of the Mastodon fieldset. Added `.form-section.hidden { display: none; }`, so the publishing control stays hidden until the authenticated configuration check explicitly reveals it.
|
||||||
|
|
||||||
|
### User
|
||||||
|
When clicking on the logo always go to the home page
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
Wrapped the header logo in each web template with an accessible home-page link. Extended the heading font selector for the new logo-link wrapper, preserving existing header styling. Confirmed all eight templates contain exactly one home-linked logo and the focused API test module passed with 36 tests.
|
||||||
|
|
||||||
|
### User
|
||||||
|
Add a red collor theme
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
Added a Red theme to the theme catalog with a high-contrast crimson palette in the frontend stylesheet. It is enabled by default on new installations and is available to select under Admin > Available themes. Extended the theme-selection regression test to verify the public theme API returns it when enabled.
|
||||||
|
|
||||||
|
### User
|
||||||
|
Make the theme brighter and darker red
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
Refined the Red theme palette with brighter white and red-tinted surfaces, plus deeper saturated crimson accents, borders, and shadows while preserving dark high-contrast text. The focused theme API test and whitespace validation passed.
|
||||||
|
|
||||||
|
### User
|
||||||
|
Since checkin d433a305f5cd8ccdafde8a0f7764462e8c167162 on the home page the filter toolbar moved so that the menu button and new entry are not in the top left anylonger - revert to the previous layout
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
Confirmed the referenced commit did not change the feed header, then identified the current template had the toolbar outside its `.header-tools` wrapper. Restored the prior header structure: the New Entry and menu controls remain grouped above the filter toolbar on the right side of the home-page header. Added a rendered-page regression assertion for `.header-tools`.
|
||||||
|
|
||||||
|
### User
|
||||||
|
I want every user to filter on every available tag/label
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
Separated feed filtering from label management: `/api/tags` now returns every tag/label to all callers, allowing complete filtering of the public feed. `/api/user/labels` remains ownership-scoped, so users still cannot manage other users' labels. Updated tag visibility regression coverage for authenticated and anonymous callers.
|
||||||
|
|
||||||
|
### User
|
||||||
|
tag filter doesn't seem to respond when pressed
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
Updated the tag-filter change handler to pass the newly selected tag directly to the feed refresh instead of relying on the browser to persist and immediately reread the preference cookie. This makes filtering work even when cookie persistence is unavailable or delayed. Cache-busted the feed script to `feed.js?v=10` and added a regression assertion for the direct refresh path.
|
||||||
|
|
||||||
|
### User
|
||||||
|
The tag filter still does not allow me to select tags, even though the raw html contains the correct content. Also the user filter and the sort functionality work too
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
Verified the live running container with Chromium and Playwright: selecting `#AI` changes the tag dropdown value, writes the preference cookie, and renders only matching feed entries. The deployed script already contains the direct-refresh handler, so cache-busted it again to `feed.js?v=11` and rebuilt the Compose service. Confirmed the live page serves the new asset version.
|
||||||
|
|
||||||
|
### User
|
||||||
|
I believe tag selection was still working for v0.1.0 - it is still broken
|
||||||
|
|
||||||
|
### Assistant outcome
|
||||||
|
Found the actual regression in the direct-refresh update: `Promise.all([loadUsers(), loadTags()]).then(loadFeed)` passed its results array into the new `selectedTag` parameter. The feed then attempted to call `toLowerCase()` on that array during initialization. Restored the v0.1.0 callback shape with `.then(() => loadFeed())`, retained direct selected-tag refreshes, added a regression assertion, and cache-busted the feed asset to `feed.js?v=12`.
|
||||||
|
|||||||
@@ -239,6 +239,19 @@
|
|||||||
218. In the Admin interface allow to minimize each settings pannel to only show its header, In order to easely navigate the page.
|
218. In the Admin interface allow to minimize each settings pannel to only show its header, In order to easely navigate the page.
|
||||||
219. Clicking on the collapse button doesn't toggle
|
219. Clicking on the collapse button doesn't toggle
|
||||||
218. In the Admin interface allow to minimize each settings pannel to only show its header, In order to easely navigate the page.
|
218. In the Admin interface allow to minimize each settings pannel to only show its header, In order to easely navigate the page.
|
||||||
|
223. On the new-entry page the checkbox should be following the labels - now they are positioned above.
|
||||||
|
224. The checkboxes should be immediately to the left of the label not below left
|
||||||
|
225. The checkbox takes the full width of the grid which forces the label to be printed underneath instead of directly next to it.
|
||||||
|
226. Only show the Mastodon publishing on new page if the user has Mastodon posting configured. Then default the checkbox to yes
|
||||||
|
227. In the currently running contaier the user olaf has no mastodon configured but the mastodon checkbox is still visible
|
||||||
|
228. When clicking on the logo always go to the home page
|
||||||
|
229. Add a red collor theme
|
||||||
|
230. Make the theme brighter and darker red
|
||||||
|
231. Since checkin d433a305f5cd8ccdafde8a0f7764462e8c167162 on the home page the filter toolbar moved so that the menu button and new entry are not in the top left anylonger - revert to the previous layout
|
||||||
|
232. I want every user to filter on every available tag/label
|
||||||
|
233. tag filter doesn't seem to respond when pressed
|
||||||
|
234. The tag filter still does not allow me to select tags, even though the raw html contains the correct content. Also the user filter and the sort functionality work too
|
||||||
|
235. I believe tag selection was still working for v0.1.0 - it is still broken
|
||||||
|
|
||||||
## Future entries
|
## Future entries
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ from fastapi import APIRouter, Depends, Header, HTTPException, Response, status
|
|||||||
import logging
|
import logging
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from backend.app.api.dependencies import get_optional_current_user
|
|
||||||
from backend.app.services.link_service import create_link, delete_link, find_owned_link_by_title_url, get_link_tags, get_owned_link, list_public_links, list_tags, mark_mastodon_posted, update_link
|
from backend.app.services.link_service import create_link, delete_link, find_owned_link_by_title_url, get_link_tags, get_owned_link, list_public_links, list_tags, mark_mastodon_posted, update_link
|
||||||
from backend.app.database import get_connection
|
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
|
||||||
@@ -24,6 +23,7 @@ class LinkCreate(BaseModel):
|
|||||||
comment: str = ''
|
comment: str = ''
|
||||||
timestamp: str | None = None
|
timestamp: str | None = None
|
||||||
tags: list[str] = []
|
tags: list[str] = []
|
||||||
|
post_to_mastodon: bool = True
|
||||||
|
|
||||||
|
|
||||||
class LinkUpdate(BaseModel):
|
class LinkUpdate(BaseModel):
|
||||||
@@ -34,9 +34,8 @@ class LinkUpdate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
@router.get('/tags')
|
@router.get('/tags')
|
||||||
def available_tags(user: dict | None = Depends(get_optional_current_user)):
|
def available_tags():
|
||||||
user_id = user['id'] if user else None
|
return list_tags()
|
||||||
return list_tags(user_id=user_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get('/scrape')
|
@router.get('/scrape')
|
||||||
@@ -89,7 +88,11 @@ def create_link_endpoint(payload: LinkCreate, response: Response, authorization:
|
|||||||
raise HTTPException(status_code=422, detail=str(error)) from error
|
raise HTTPException(status_code=422, detail=str(error)) from error
|
||||||
if duplicate:
|
if duplicate:
|
||||||
response.status_code = status.HTTP_200_OK
|
response.status_code = status.HTTP_200_OK
|
||||||
plugin_results = plugin_manager.dispatch({'type': 'link_created', **record})
|
plugin_results = plugin_manager.dispatch({
|
||||||
|
'type': 'link_created',
|
||||||
|
'post_to_mastodon': payload.post_to_mastodon,
|
||||||
|
**record,
|
||||||
|
})
|
||||||
mastodon_result = next((result for result in plugin_results if result.get('plugin') == 'mastodon'), None)
|
mastodon_result = next((result for result in plugin_results if result.get('plugin') == 'mastodon'), None)
|
||||||
if mastodon_result and mastodon_result.get('status') == 'posted':
|
if mastodon_result and mastodon_result.get('status') == 'posted':
|
||||||
mark_mastodon_posted(record['id'], info['user_id'], mastodon_result.get('post_id'))
|
mark_mastodon_posted(record['id'], info['user_id'], mastodon_result.get('post_id'))
|
||||||
|
|||||||
@@ -390,6 +390,8 @@ def get_user_plugin_config(plugin_name: str, user: dict = Depends(get_current_us
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
config = json.loads(row['config']) if row['config'] else {}
|
config = json.loads(row['config']) if row['config'] else {}
|
||||||
|
if plugin_name == 'mastodon':
|
||||||
|
config['configured'] = bool(config.get('instance') and config.get('access_token'))
|
||||||
if config.get('access_token'):
|
if config.get('access_token'):
|
||||||
config.pop('access_token')
|
config.pop('access_token')
|
||||||
return config
|
return config
|
||||||
|
|||||||
@@ -227,32 +227,9 @@ def list_public_users():
|
|||||||
return [row['username'] for row in rows]
|
return [row['username'] for row in rows]
|
||||||
|
|
||||||
|
|
||||||
def list_tags(user_id: str | None = None):
|
def list_tags():
|
||||||
with get_connection() as conn:
|
with get_connection() as conn:
|
||||||
if user_id:
|
rows = conn.execute('SELECT name FROM tags ORDER BY name').fetchall()
|
||||||
rows = conn.execute(
|
|
||||||
'''
|
|
||||||
SELECT tags.name
|
|
||||||
FROM tags
|
|
||||||
LEFT JOIN users ON users.id = tags.created_by
|
|
||||||
WHERE tags.created_by IS NULL
|
|
||||||
OR tags.created_by = ?
|
|
||||||
OR users.is_admin = 1
|
|
||||||
ORDER BY tags.name
|
|
||||||
''',
|
|
||||||
(user_id,),
|
|
||||||
).fetchall()
|
|
||||||
else:
|
|
||||||
rows = conn.execute(
|
|
||||||
'''
|
|
||||||
SELECT tags.name
|
|
||||||
FROM tags
|
|
||||||
LEFT JOIN users ON users.id = tags.created_by
|
|
||||||
WHERE tags.created_by IS NULL
|
|
||||||
OR users.is_admin = 1
|
|
||||||
ORDER BY tags.name
|
|
||||||
''',
|
|
||||||
).fetchall()
|
|
||||||
tags = []
|
tags = []
|
||||||
seen = set()
|
seen = set()
|
||||||
for row in rows:
|
for row in rows:
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ class MastodonPlugin(BasePlugin):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def handle_event(self, event):
|
def handle_event(self, event):
|
||||||
|
if not event.get('post_to_mastodon', True):
|
||||||
|
return {'status': 'skipped', 'plugin': self.name, 'reason': 'not_requested'}
|
||||||
|
|
||||||
config = dict(self.config)
|
config = dict(self.config)
|
||||||
user_id = event.get('user_id')
|
user_id = event.get('user_id')
|
||||||
if user_id:
|
if user_id:
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ THEMES = {
|
|||||||
'dracula': {'label': 'Dracula', 'description': 'A vivid dark theme with high-contrast accents.'},
|
'dracula': {'label': 'Dracula', 'description': 'A vivid dark theme with high-contrast accents.'},
|
||||||
'nord': {'label': 'Nord', 'description': 'A cool, muted blue-gray theme.'},
|
'nord': {'label': 'Nord', 'description': 'A cool, muted blue-gray theme.'},
|
||||||
'solarized': {'label': 'Solarized', 'description': 'A balanced theme available in a light style.'},
|
'solarized': {'label': 'Solarized', 'description': 'A balanced theme available in a light style.'},
|
||||||
|
'red': {'label': 'Red', 'description': 'A warm crimson theme with high-contrast surfaces.'},
|
||||||
}
|
}
|
||||||
DEFAULT_ENABLED_THEMES = tuple(THEMES)
|
DEFAULT_ENABLED_THEMES = tuple(THEMES)
|
||||||
|
|
||||||
|
|||||||
@@ -150,10 +150,10 @@ def test_configuration_requires_authentication_and_admin_role():
|
|||||||
def test_admin_can_select_multiple_themes():
|
def test_admin_can_select_multiple_themes():
|
||||||
headers = login_headers()
|
headers = login_headers()
|
||||||
response = client.put('/api/admin/themes', headers=headers, json={
|
response = client.put('/api/admin/themes', headers=headers, json={
|
||||||
'themes': ['plain-day', 'plain-night', 'latte', 'frappe', 'macchiato', 'mocha', 'dracula', 'nord', 'solarized'],
|
'themes': ['plain-day', 'plain-night', 'latte', 'frappe', 'macchiato', 'mocha', 'dracula', 'nord', 'solarized', 'red'],
|
||||||
})
|
})
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()['enabled'] == ['plain-day', 'plain-night', 'latte', 'frappe', 'macchiato', 'mocha', 'dracula', 'nord', 'solarized']
|
assert response.json()['enabled'] == ['plain-day', 'plain-night', 'latte', 'frappe', 'macchiato', 'mocha', 'dracula', 'nord', 'solarized', 'red']
|
||||||
public_response = client.get('/api/public/themes')
|
public_response = client.get('/api/public/themes')
|
||||||
assert public_response.status_code == 200
|
assert public_response.status_code == 200
|
||||||
assert [theme['id'] for theme in public_response.json()] == response.json()['enabled']
|
assert [theme['id'] for theme in public_response.json()] == response.json()['enabled']
|
||||||
@@ -564,17 +564,17 @@ def test_label_visibility_isolation_and_grandfathering():
|
|||||||
assert '#Cybersecurity' in charlie_label_names
|
assert '#Cybersecurity' in charlie_label_names
|
||||||
assert '#BobOnlyLabel' not in charlie_label_names
|
assert '#BobOnlyLabel' not in charlie_label_names
|
||||||
|
|
||||||
# Bob views /api/tags (authenticated): sees Bob tag & default/grandfathered, NOT Charlie tag
|
# Every user can filter by every available tag, including another user's label.
|
||||||
bob_tags = client.get('/api/tags', headers=bob_headers).json()
|
bob_tags = client.get('/api/tags', headers=bob_headers).json()
|
||||||
assert '#BobOnlyLabel' in bob_tags
|
assert '#BobOnlyLabel' in bob_tags
|
||||||
assert '#GrandfatheredLabel' in bob_tags
|
assert '#GrandfatheredLabel' in bob_tags
|
||||||
assert '#CharlieOnlyLabel' not in bob_tags
|
assert '#CharlieOnlyLabel' in bob_tags
|
||||||
|
|
||||||
# Anonymous views /api/tags: sees default/grandfathered, NOT Bob or Charlie tag
|
# The public feed filter has the same complete tag catalog.
|
||||||
anon_tags = client.get('/api/tags').json()
|
anon_tags = client.get('/api/tags').json()
|
||||||
assert '#GrandfatheredLabel' in anon_tags
|
assert '#GrandfatheredLabel' in anon_tags
|
||||||
assert '#BobOnlyLabel' not in anon_tags
|
assert '#BobOnlyLabel' in anon_tags
|
||||||
assert '#CharlieOnlyLabel' not in anon_tags
|
assert '#CharlieOnlyLabel' in anon_tags
|
||||||
|
|
||||||
# Bob cannot edit or delete grandfathered label
|
# Bob cannot edit or delete grandfathered label
|
||||||
assert client.put(f"/api/user/labels/{grandfathered_id}", headers=bob_headers, json={'name': '#RenamedGrandfathered'}).status_code == 404
|
assert client.put(f"/api/user/labels/{grandfathered_id}", headers=bob_headers, json={'name': '#RenamedGrandfathered'}).status_code == 404
|
||||||
@@ -792,6 +792,7 @@ def test_public_and_admin_pages_render_html():
|
|||||||
assert 'alice' in user_page
|
assert 'alice' in user_page
|
||||||
assert 'data-user-filter="alice"' in user_page
|
assert 'data-user-filter="alice"' in user_page
|
||||||
assert 'profile-summary' in user_page
|
assert 'profile-summary' in user_page
|
||||||
|
assert '<div class="header-tools">' in user_page
|
||||||
feed_script = TestClient(app).get('/static/feed.js?v=4').text
|
feed_script = TestClient(app).get('/static/feed.js?v=4').text
|
||||||
assert 'window.location.assign(selectedUser ? `/${encodeURIComponent(selectedUser)}/` : \'/\')' in feed_script
|
assert 'window.location.assign(selectedUser ? `/${encodeURIComponent(selectedUser)}/` : \'/\')' in feed_script
|
||||||
assert client.get('/login').status_code == 200
|
assert client.get('/login').status_code == 200
|
||||||
@@ -827,7 +828,11 @@ def test_public_and_admin_pages_render_html():
|
|||||||
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 'postToMastodon(item, mastodonButton)' in feed_script
|
||||||
assert 'tag.toLowerCase() === pref.tag.toLowerCase()' in feed_script
|
assert 'tag.toLowerCase() === activeTag.toLowerCase()' in feed_script
|
||||||
|
assert 'loadFeed(event.target.value)' in feed_script
|
||||||
|
assert 'Promise.all([loadUsers(), loadTags()]).then(() => loadFeed())' in feed_script
|
||||||
|
assert "fetch('/api/tags', {" in feed_script
|
||||||
|
assert 'Authorization: `Bearer ${accessToken}`' 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 "entryMeta.className = 'entry-meta'" in feed_script
|
assert "entryMeta.className = 'entry-meta'" in feed_script
|
||||||
assert "meta.className = 'meta'" in feed_script
|
assert "meta.className = 'meta'" in feed_script
|
||||||
@@ -889,6 +894,18 @@ def test_link_submission_posts_to_enabled_mastodon_plugin():
|
|||||||
server.server_close()
|
server.server_close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_link_submission_can_skip_mastodon_posting():
|
||||||
|
with patch('backend.app.api.links.plugin_manager.dispatch', return_value=[]) as dispatch:
|
||||||
|
response = client.post('/api/links', headers=login_headers(), json={
|
||||||
|
'title': 'Private share',
|
||||||
|
'url': 'https://example.com/private-share',
|
||||||
|
'post_to_mastodon': False,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
assert dispatch.call_args.args[0]['post_to_mastodon'] is False
|
||||||
|
|
||||||
|
|
||||||
def test_mastodon_post_without_title_omits_source_line():
|
def test_mastodon_post_without_title_omits_source_line():
|
||||||
from backend.app.services.plugin_manager import MastodonPlugin
|
from backend.app.services.plugin_manager import MastodonPlugin
|
||||||
|
|
||||||
@@ -922,6 +939,7 @@ def test_plugin_config_can_be_saved_for_mastodon():
|
|||||||
payload = client.get('/api/user/plugins/mastodon', headers=headers).json()
|
payload = client.get('/api/user/plugins/mastodon', headers=headers).json()
|
||||||
assert payload['instance'] == 'mastodon.social'
|
assert payload['instance'] == 'mastodon.social'
|
||||||
assert payload['post_prefix'] == 'From my #LinkLog: '
|
assert payload['post_prefix'] == 'From my #LinkLog: '
|
||||||
|
assert payload['configured'] is True
|
||||||
|
|
||||||
admin_update = client.put('/api/admin/plugins/mastodon', headers=headers, json={
|
admin_update = client.put('/api/admin/plugins/mastodon', headers=headers, json={
|
||||||
'enabled': True,
|
'enabled': True,
|
||||||
|
|||||||
@@ -73,7 +73,9 @@ async function loadUsers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadTags() {
|
async function loadTags() {
|
||||||
const response = await fetch('/api/tags');
|
const response = await fetch('/api/tags', {
|
||||||
|
headers: accessToken ? {Authorization: `Bearer ${accessToken}`} : {},
|
||||||
|
});
|
||||||
if (!response.ok) throw new Error('Could not load tags');
|
if (!response.ok) throw new Error('Could not load tags');
|
||||||
const tags = await response.json();
|
const tags = await response.json();
|
||||||
availableTags = tags;
|
availableTags = tags;
|
||||||
@@ -254,7 +256,7 @@ function showEditForm(article, item) {
|
|||||||
article.appendChild(form);
|
article.appendChild(form);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadFeed() {
|
async function loadFeed(selectedTag = null) {
|
||||||
const routeUser = document.body.dataset.userFilter;
|
const routeUser = document.body.dataset.userFilter;
|
||||||
const endpoint = routeUser
|
const endpoint = routeUser
|
||||||
? `/api/public/feed/${encodeURIComponent(routeUser)}`
|
? `/api/public/feed/${encodeURIComponent(routeUser)}`
|
||||||
@@ -266,13 +268,14 @@ async function loadFeed() {
|
|||||||
|
|
||||||
let items = data || [];
|
let items = data || [];
|
||||||
const pref = readPreferences();
|
const pref = readPreferences();
|
||||||
|
const activeTag = selectedTag ?? pref.tag;
|
||||||
|
|
||||||
if (pref.user && !routeUser) {
|
if (pref.user && !routeUser) {
|
||||||
items = items.filter((item) => (item.user?.username || '').toLowerCase() === pref.user.toLowerCase());
|
items = items.filter((item) => (item.user?.username || '').toLowerCase() === pref.user.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pref.tag) {
|
if (activeTag) {
|
||||||
items = items.filter((item) => item.tags?.some((tag) => tag.toLowerCase() === pref.tag.toLowerCase()));
|
items = items.filter((item) => item.tags?.some((tag) => tag.toLowerCase() === activeTag.toLowerCase()));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pref.sort === 'oldest') {
|
if (pref.sort === 'oldest') {
|
||||||
@@ -304,9 +307,9 @@ function syncPreferences() {
|
|||||||
tagFilter.addEventListener('change', (event) => {
|
tagFilter.addEventListener('change', (event) => {
|
||||||
const next = { ...readPreferences(), tag: event.target.value };
|
const next = { ...readPreferences(), tag: event.target.value };
|
||||||
writePreferences(next);
|
writePreferences(next);
|
||||||
loadFeed();
|
loadFeed(event.target.value);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
syncPreferences();
|
syncPreferences();
|
||||||
Promise.all([loadUsers(), loadTags()]).then(loadFeed).catch(() => loadFeed());
|
Promise.all([loadUsers(), loadTags()]).then(() => loadFeed()).catch(() => loadFeed());
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
const commentInput = document.getElementById('comment-input');
|
const commentInput = document.getElementById('comment-input');
|
||||||
const newTagsInput = document.getElementById('new-tags-input');
|
const newTagsInput = document.getElementById('new-tags-input');
|
||||||
const existingTagsEl = document.getElementById('existing-tags');
|
const existingTagsEl = document.getElementById('existing-tags');
|
||||||
|
const mastodonPublishing = document.getElementById('mastodon-publishing');
|
||||||
const mastodonEnabledCheckbox = document.getElementById('mastodon-enabled');
|
const mastodonEnabledCheckbox = document.getElementById('mastodon-enabled');
|
||||||
const scrapeButton = document.getElementById('scrape-button');
|
const scrapeButton = document.getElementById('scrape-button');
|
||||||
const scrapeStatus = document.getElementById('scrape-status');
|
const scrapeStatus = document.getElementById('scrape-status');
|
||||||
@@ -46,7 +47,7 @@
|
|||||||
.then((user) => {
|
.then((user) => {
|
||||||
currentUser = user;
|
currentUser = user;
|
||||||
entryForm.classList.remove('hidden');
|
entryForm.classList.remove('hidden');
|
||||||
loadTags();
|
Promise.all([loadTags(), loadMastodonPublishing()]);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
localStorage.removeItem('linklogAccessToken');
|
localStorage.removeItem('linklogAccessToken');
|
||||||
@@ -66,6 +67,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadMastodonPublishing() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/user/plugins/mastodon', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!response.ok) return;
|
||||||
|
const config = await response.json();
|
||||||
|
mastodonEnabledCheckbox.checked = Boolean(config.configured);
|
||||||
|
mastodonPublishing.classList.toggle('hidden', !config.configured);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Could not load Mastodon configuration:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Render tag checkboxes
|
// Render tag checkboxes
|
||||||
function renderTags() {
|
function renderTags() {
|
||||||
existingTagsEl.innerHTML = '';
|
existingTagsEl.innerHTML = '';
|
||||||
@@ -171,6 +186,7 @@
|
|||||||
url,
|
url,
|
||||||
comment,
|
comment,
|
||||||
tags,
|
tags,
|
||||||
|
post_to_mastodon: mastodonEnabledCheckbox.checked,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,15 @@
|
|||||||
--border: rgba(7, 54, 66, 0.18); --shadow: 0 18px 50px rgba(7, 54, 66, 0.14);
|
--border: rgba(7, 54, 66, 0.18); --shadow: 0 18px 50px rgba(7, 54, 66, 0.14);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root[data-theme='red'] {
|
||||||
|
--base: #fffafa; --mantle: #ffedef; --crust: #fbd6da;
|
||||||
|
--surface-0: #ffffff; --surface-1: #ffe2e6; --surface-2: #f4bbc4;
|
||||||
|
--text: #350810; --subtext: #5c1724; --muted: #8c4653;
|
||||||
|
--mauve: #9e1737; --lavender: #86132d; --blue: #216f91;
|
||||||
|
--teal: #087567; --peach: #b84324; --red: #a70d2d;
|
||||||
|
--border: rgba(83, 10, 25, 0.2); --shadow: 0 18px 50px rgba(122, 8, 33, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
*::after {
|
*::after {
|
||||||
@@ -169,7 +178,7 @@ body::selection {
|
|||||||
object-position: left center;
|
object-position: left center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-header .site-logo + h1,
|
.site-header .site-logo-link + h1,
|
||||||
.feed-link {
|
.feed-link {
|
||||||
font-family: 'Asset', 'Space Grotesk', sans-serif;
|
font-family: 'Asset', 'Space Grotesk', sans-serif;
|
||||||
}
|
}
|
||||||
@@ -1006,6 +1015,10 @@ button:disabled {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-section.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.form-section label {
|
.form-section label {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -1065,7 +1078,15 @@ button:disabled {
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-section label.tag-checkbox {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
.tag-checkbox input {
|
.tag-checkbox input {
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header-row">
|
<div class="header-row">
|
||||||
<div>
|
<div>
|
||||||
<img class="site-logo" src="/static/logo.svg" alt="LinkLog" />
|
<a class="site-logo-link" href="/" aria-label="LinkLog home"><img class="site-logo" src="/static/logo.svg" alt="LinkLog" /></a>
|
||||||
<h1>About</h1>
|
<h1>About</h1>
|
||||||
<p>A quiet place for the links worth keeping.</p>
|
<p>A quiet place for the links worth keeping.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header-row">
|
<div class="header-row">
|
||||||
<div>
|
<div>
|
||||||
<img class="site-logo" src="/static/logo.svg" alt="LinkLog" />
|
<a class="site-logo-link" href="/" aria-label="LinkLog home"><img class="site-logo" src="/static/logo.svg" alt="LinkLog" /></a>
|
||||||
<h1>Admin</h1>
|
<h1>Admin</h1>
|
||||||
<p>Manage users and plugin configuration</p>
|
<p>Manage users and plugin configuration</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header-row">
|
<div class="header-row">
|
||||||
<div>
|
<div>
|
||||||
<img class="site-logo" src="/static/logo.svg" alt="LinkLog" />
|
<a class="site-logo-link" href="/" aria-label="LinkLog home"><img class="site-logo" src="/static/logo.svg" alt="LinkLog" /></a>
|
||||||
<p>Public link feed</p>
|
<p>Public link feed</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-tools">
|
<div class="header-tools">
|
||||||
@@ -86,6 +86,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/theme.js?v=1"></script>
|
<script src="/static/theme.js?v=1"></script>
|
||||||
<script src="/static/feed.js?v=9"></script>
|
<script src="/static/feed.js?v=12"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header-row">
|
<div class="header-row">
|
||||||
<div>
|
<div>
|
||||||
<img class="site-logo" src="/static/logo.svg" alt="LinkLog" />
|
<a class="site-logo-link" href="/" aria-label="LinkLog home"><img class="site-logo" src="/static/logo.svg" alt="LinkLog" /></a>
|
||||||
<h1>Labels</h1>
|
<h1>Labels</h1>
|
||||||
<p>Manage your link labels</p>
|
<p>Manage your link labels</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header-row">
|
<div class="header-row">
|
||||||
<div>
|
<div>
|
||||||
<img class="site-logo" src="/static/logo.svg" alt="LinkLog" />
|
<a class="site-logo-link" href="/" aria-label="LinkLog home"><img class="site-logo" src="/static/logo.svg" alt="LinkLog" /></a>
|
||||||
<p>Access your LinkLog settings</p>
|
<p>Access your LinkLog settings</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
|
|||||||
@@ -13,10 +13,9 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header-row">
|
<div class="header-row">
|
||||||
<div>
|
<div>
|
||||||
<img class="site-logo" src="/static/logo.svg" alt="LinkLog" />
|
<a class="site-logo-link" href="/" aria-label="LinkLog home"><img class="site-logo" src="/static/logo.svg" alt="LinkLog" /></a>
|
||||||
<p>New Entry</p>
|
<p>New Entry</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-tools">
|
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button id="new-entry-button" class="new-entry-button hidden" type="button"><a href="/new-entry">New entry</a></button>
|
<button id="new-entry-button" class="new-entry-button hidden" type="button"><a href="/new-entry">New entry</a></button>
|
||||||
<button class="menu-toggle" type="button" aria-expanded="false" aria-controls="auth-menu">Menu</button>
|
<button class="menu-toggle" type="button" aria-expanded="false" aria-controls="auth-menu">Menu</button>
|
||||||
@@ -80,11 +79,11 @@
|
|||||||
</label>
|
</label>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset class="form-section">
|
<fieldset id="mastodon-publishing" class="form-section hidden">
|
||||||
<legend>Mastodon Publishing</legend>
|
<legend>Mastodon Publishing</legend>
|
||||||
<label>
|
<label>
|
||||||
<input id="mastodon-enabled" type="checkbox" />
|
<input id="mastodon-enabled" type="checkbox" checked />
|
||||||
Post to Mastodon (if configured)
|
Post to Mastodon
|
||||||
</label>
|
</label>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
@@ -102,6 +101,6 @@
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="/static/auth-header.js"></script>
|
<script src="/static/auth-header.js"></script>
|
||||||
<script src="/static/new-entry.js"></script>
|
<script src="/static/new-entry.js?v=2"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header-row">
|
<div class="header-row">
|
||||||
<div>
|
<div>
|
||||||
<img class="site-logo" src="/static/logo.svg" alt="LinkLog" />
|
<a class="site-logo-link" href="/" aria-label="LinkLog home"><img class="site-logo" src="/static/logo.svg" alt="LinkLog" /></a>
|
||||||
<h1>Configure LinkLog</h1>
|
<h1>Configure LinkLog</h1>
|
||||||
<p>Create the first administrator and test email delivery.</p>
|
<p>Create the first administrator and test email delivery.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header-row">
|
<div class="header-row">
|
||||||
<div>
|
<div>
|
||||||
<img class="site-logo" src="/static/logo.svg" alt="LinkLog" />
|
<a class="site-logo-link" href="/" aria-label="LinkLog home"><img class="site-logo" src="/static/logo.svg" alt="LinkLog" /></a>
|
||||||
<h1>Profile</h1>
|
<h1>Profile</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
|
|||||||
Reference in New Issue
Block a user