Unenforced character count in frontend
Build LinkLog Development Image / development-image (push) Successful in 11s

This commit is contained in:
2026-09-06 10:20:25 +02:00
parent 71f4451b34
commit 443aff8323
9 changed files with 114 additions and 13 deletions
+2
View File
@@ -23,6 +23,8 @@ LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS=24
LINKLOG_PASSWORD_RESET_EXPIRY_HOURS=1
LINKLOG_MASTODON_CLIENT_NAME=LinkLog
LINKLOG_MASTODON_OAUTH_EXPIRY_MINUTES=10
# Warn on the New Entry page when the assembled post would exceed this many characters (Mastodon's default limit is 500).
LINKLOG_MAX_POST_CHARACTERS=500
LINKLOG_LOG_LEVEL=INFO
# Optional comma-separated override. Leave empty to use the built-in list.
LINKLOG_TRACKING_PARAMS=
+1
View File
@@ -4,6 +4,7 @@
* Implemented toolbar search functionality, using Google-like syntax (implicit AND, quoted phrases, `OR`, `-` exclusions, and the `site:` operator)
* The feed now loads a user-selectable number of entries per page (default 25, configurable via `LINKLOG_FRONTEND_LOADPOSTS`) with Previous/Next and numbered page navigation
* Moved tag/user filtering, search, and sorting to the backend, so the feed API returns only the matching, paginated results
* The New Entry page shows a live character count and warns when the assembled post would exceed the configurable `LINKLOG_MAX_POST_CHARACTERS` limit (default 500), to help avoid failed Mastodon posts
### Fixed
* Restored tag and user filtering on the feed: the decorative header arc no longer intercepts clicks on the filter dropdowns
+18
View File
@@ -1592,4 +1592,22 @@ Make it so that <nav id=pagination> at the bottom of the page doesn't exceed the
### Assistant outcome
Made the pagination bar wrap within the page width (`max-width: 100%`, `overflow-x: auto`) and toned down its styling to ghost-style buttons with smaller padding/font and muted colors instead of solid backgrounds. Verified the updated CSS is served correctly from a rebuilt local container.
### User
Make sure the CHANGELOG reflects all changes since commit 74b2c400c6
### Assistant outcome
Reviewed the commit log and working-tree diff since `74b2c400c6` and updated the `v0.3.0` section of `CHANGELOG.MD` with the missing user-facing entries: toolbar search, server-side feed pagination, backend-side filtering, the Firefox search-input width fix, the search/tag toolbar reorder, the Mastodon "Logged on <date> from:" UTC timestamp change, and the pagination control's visual/overflow fix. Also fixed an unrelated pre-existing bug in `frontend/version.json` (a missing closing quote made it invalid JSON, silently falling back to version `0.0.0`).
### User
Add a skill to maintain the changelog.md
### Assistant outcome
Added `.github/skills/changelog-maintenance/SKILL.md`, modeled on the existing `vibe-logging` skill, describing when and how to append concise, user-facing bullets to the current unreleased `## Version` section of `CHANGELOG.MD` (Features/Fixed/Modification), when to bump `frontend/version.json`, and which changes are out of scope (internal-only docs, dev scripts, test-only fixes).
### User
In the new-entry form, provide a warning when the total ammount of characters is over LINKLOG_MAX_POST_CHARACTERS=500 (also indicat the number of characters used/500 count). This is to prevent Mastodon posts from failing - so all characters should be counted.
### Assistant outcome
Added a `LINKLOG_MAX_POST_CHARACTERS` setting (default 500) exposed via `GET /api/public/config`. The New Entry page now shows a live `used/limit characters` counter that recalculates on every relevant input (title, URL, comment, tags, checkbox toggles) by assembling the estimated Mastodon post text the same way the backend's Mastodon plugin does, and switches to a warning style when the limit is exceeded. Verified with the full backend test suite (56 passed) and a live smoke test of `/api/public/config` and the rendered New Entry page.
# Chat Log
+3
View File
@@ -311,6 +311,9 @@ The default selection is 25 with additional choices of 100 and 250 (to be config
If the user uses the filters and/or search in the toolbox then those should limit the entries the server presents, so the filters and search are applied on the server side.
258. Make it so that <nav id=pagination> at the bottom of the page doesn't exceed the page width and is less visually dominant
259. Make sure the CHANGELOG reflects all changes since commit 74b2c400c6
260. Add a skill to maintain the changelog.md
261. In the new-entry form, provide a warning when the total ammount of characters is over LINKLOG_MAX_POST_CHARACTERS=500 (also indicat the number of characters used/500 count). This is to prevent Mastodon posts from failing - so all characters should be counted.
## Future entries
+5 -1
View File
@@ -28,7 +28,11 @@ def public_themes():
@router.get('/config')
def public_config():
return {'feed_page_sizes': settings.feed_page_sizes, 'default_page_size': settings.feed_page_sizes[0]}
return {
'feed_page_sizes': settings.feed_page_sizes,
'default_page_size': settings.feed_page_sizes[0],
'max_post_characters': settings.max_post_characters,
}
@router.get('/feed')
+1
View File
@@ -51,6 +51,7 @@ class Settings:
password_reset_expiry_hours: int = int(os.getenv('LINKLOG_PASSWORD_RESET_EXPIRY_HOURS', '1'))
mastodon_client_name: str = os.getenv('LINKLOG_MASTODON_CLIENT_NAME', 'LinkLog')
mastodon_oauth_expiry_minutes: int = int(os.getenv('LINKLOG_MASTODON_OAUTH_EXPIRY_MINUTES', '10'))
max_post_characters: int = int(os.getenv('LINKLOG_MAX_POST_CHARACTERS', '500'))
log_level: str = os.getenv('LINKLOG_LOG_LEVEL', 'INFO').upper()
tracking_params: list[str] = None
feed_page_sizes: list[int] = None
+69 -11
View File
@@ -16,11 +16,14 @@
const refetchTitleButton = document.getElementById('refetch-title-button');
const submitButton = document.getElementById('submit-button');
const submitStatus = document.getElementById('submit-status');
const characterCount = document.getElementById('character-count');
const token = localStorage.getItem('linklogAccessToken');
let availableTags = [];
let selectedTags = new Set();
let currentUser = null;
let maxPostCharacters = 500;
let mastodonPostPrefix = 'From my #LinkLog: ';
// Utility function to add status messages; splits on \n into real <br> line breaks without using innerHTML.
function setStatus(statusEl, message, isError = false) {
@@ -53,7 +56,7 @@
.then((user) => {
currentUser = user;
entryForm.classList.remove('hidden');
Promise.all([loadTags(), loadMastodonPublishing()]);
Promise.all([loadTags(), loadMastodonPublishing(), loadConfig()]).then(updateCharacterCount);
})
.catch(() => {
localStorage.removeItem('linklogAccessToken');
@@ -61,6 +64,17 @@
entryForm.classList.add('hidden');
});
async function loadConfig() {
try {
const response = await fetch('/api/public/config');
if (!response.ok) throw new Error('Could not load config');
const config = await response.json();
if (config.max_post_characters) maxPostCharacters = config.max_post_characters;
} catch (error) {
console.error('Could not load config:', error);
}
}
// Load available tags
async function loadTags() {
try {
@@ -82,11 +96,63 @@
const config = await response.json();
mastodonEnabledCheckbox.checked = Boolean(config.configured);
mastodonPublishing.classList.toggle('hidden', !config.configured);
if (config.post_prefix) {
mastodonPostPrefix = config.post_prefix;
} else if (config.hashtag) {
mastodonPostPrefix = `#${String(config.hashtag).trim().replace(/^#/, '')} `;
}
} catch (error) {
console.error('Could not load Mastodon configuration:', error);
}
}
// Collect the selected checkbox tags plus any typed new tags, mirroring the submit-time logic.
function collectTags() {
const tags = Array.from(selectedTags);
const newTags = newTagsInput.value.trim();
if (newTags) {
tags.push(...newTags.split(',').map((t) => t.trim()).filter(Boolean));
}
return tags;
}
// Format a UTC timestamp the same way the Mastodon plugin does, so the estimated post length matches the server.
function formatMastodonTimestamp(date) {
const months = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
];
const pad = (value) => String(value).padStart(2, '0');
return `${date.getUTCFullYear()} ${months[date.getUTCMonth()]} ${pad(date.getUTCDate())} - ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}`;
}
// Estimate the assembled Mastodon post length, mirroring backend/app/services/plugin_manager.py.
function estimatePostLength() {
const title = titleInput.value.trim();
const comment = commentInput.value.trim();
const url = removeKnownTrackingParams(urlInput.value.trim());
const parts = [mastodonPostPrefix.trim()];
if (title) parts.push(title);
if (comment) parts.push(comment);
if (title) parts.push(`Logged on ${formatMastodonTimestamp(new Date())} UTC from: ${url}`);
const tags = collectTags();
if (tags.length) parts.push(tags.join(' '));
return parts.join('\n\n').length;
}
function updateCharacterCount() {
const length = estimatePostLength();
const overLimit = length > maxPostCharacters;
characterCount.textContent = overLimit
? `${length}/${maxPostCharacters} characters - exceeds the Mastodon post limit by ${length - maxPostCharacters}`
: `${length}/${maxPostCharacters} characters`;
characterCount.classList.toggle('over-limit', overLimit);
}
[urlInput, titleInput, commentInput, newTagsInput].forEach((input) => {
input.addEventListener('input', updateCharacterCount);
});
// Render tag checkboxes
function renderTags() {
existingTagsEl.innerHTML = '';
@@ -104,6 +170,7 @@
} else {
selectedTags.delete(tag);
}
updateCharacterCount();
});
label.appendChild(checkbox);
@@ -224,22 +291,13 @@
const url = removeKnownTrackingParams(urlInput.value.trim());
const title = titleInput.value.trim();
const comment = commentInput.value.trim();
const newTags = newTagsInput.value.trim();
if (!url || !title) {
setStatus(submitStatus, 'URL and title are required', true);
return;
}
// Combine selected tags and new tags
const tags = Array.from(selectedTags);
if (newTags) {
const newTagsList = newTags
.split(',')
.map((t) => t.trim())
.filter((t) => t);
tags.push(...newTagsList);
}
const tags = collectTags();
submitButton.disabled = true;
setStatus(submitStatus, 'Saving...', false);
+12
View File
@@ -1242,6 +1242,18 @@ button:disabled {
display: none;
}
.char-count {
margin: -8px 0 0;
color: var(--muted);
font-size: 0.82rem;
text-align: right;
}
.char-count.over-limit {
color: var(--red);
font-weight: 600;
}
@media (max-width: 600px) {
.container {
padding: 0 14px;
+3 -1
View File
@@ -71,6 +71,7 @@
<textarea id="comment-input" name="comment" rows="4" placeholder="Optional comment about this link"></textarea>
</label>
</div>
<div id="character-count" class="char-count" aria-live="polite">0/500 characters</div>
<fieldset class="form-section">
<legend>Tags</legend>
@@ -89,6 +90,7 @@
</label>
</fieldset>
<div class="form-actions">
<button id="submit-button" type="submit">Save Entry</button>
<a href="/" class="secondary button">Cancel</a>
@@ -103,6 +105,6 @@
</footer>
<script src="/static/auth-header.js"></script>
<script src="/static/new-entry.js?v=8"></script>
<script src="/static/new-entry.js?v=9"></script>
</body>
</html>