Unenforced character count in frontend
Build LinkLog Development Image / development-image (push) Successful in 11s
Build LinkLog Development Image / development-image (push) Successful in 11s
This commit is contained in:
@@ -23,6 +23,8 @@ LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS=24
|
|||||||
LINKLOG_PASSWORD_RESET_EXPIRY_HOURS=1
|
LINKLOG_PASSWORD_RESET_EXPIRY_HOURS=1
|
||||||
LINKLOG_MASTODON_CLIENT_NAME=LinkLog
|
LINKLOG_MASTODON_CLIENT_NAME=LinkLog
|
||||||
LINKLOG_MASTODON_OAUTH_EXPIRY_MINUTES=10
|
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
|
LINKLOG_LOG_LEVEL=INFO
|
||||||
# Optional comma-separated override. Leave empty to use the built-in list.
|
# Optional comma-separated override. Leave empty to use the built-in list.
|
||||||
LINKLOG_TRACKING_PARAMS=
|
LINKLOG_TRACKING_PARAMS=
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* Implemented toolbar search functionality, using Google-like syntax (implicit AND, quoted phrases, `OR`, `-` exclusions, and the `site:` operator)
|
* 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
|
* 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
|
* 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
|
### Fixed
|
||||||
* Restored tag and user filtering on the feed: the decorative header arc no longer intercepts clicks on the filter dropdowns
|
* Restored tag and user filtering on the feed: the decorative header arc no longer intercepts clicks on the filter dropdowns
|
||||||
|
|||||||
@@ -1592,4 +1592,22 @@ Make it so that <nav id=pagination> at the bottom of the page doesn't exceed the
|
|||||||
|
|
||||||
### Assistant outcome
|
### 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.
|
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
|
# Chat Log
|
||||||
|
|||||||
@@ -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.
|
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
|
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
|
## Future entries
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,11 @@ def public_themes():
|
|||||||
|
|
||||||
@router.get('/config')
|
@router.get('/config')
|
||||||
def public_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')
|
@router.get('/feed')
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ class Settings:
|
|||||||
password_reset_expiry_hours: int = int(os.getenv('LINKLOG_PASSWORD_RESET_EXPIRY_HOURS', '1'))
|
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_client_name: str = os.getenv('LINKLOG_MASTODON_CLIENT_NAME', 'LinkLog')
|
||||||
mastodon_oauth_expiry_minutes: int = int(os.getenv('LINKLOG_MASTODON_OAUTH_EXPIRY_MINUTES', '10'))
|
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()
|
log_level: str = os.getenv('LINKLOG_LOG_LEVEL', 'INFO').upper()
|
||||||
tracking_params: list[str] = None
|
tracking_params: list[str] = None
|
||||||
feed_page_sizes: list[int] = None
|
feed_page_sizes: list[int] = None
|
||||||
|
|||||||
@@ -16,11 +16,14 @@
|
|||||||
const refetchTitleButton = document.getElementById('refetch-title-button');
|
const refetchTitleButton = document.getElementById('refetch-title-button');
|
||||||
const submitButton = document.getElementById('submit-button');
|
const submitButton = document.getElementById('submit-button');
|
||||||
const submitStatus = document.getElementById('submit-status');
|
const submitStatus = document.getElementById('submit-status');
|
||||||
|
const characterCount = document.getElementById('character-count');
|
||||||
|
|
||||||
const token = localStorage.getItem('linklogAccessToken');
|
const token = localStorage.getItem('linklogAccessToken');
|
||||||
let availableTags = [];
|
let availableTags = [];
|
||||||
let selectedTags = new Set();
|
let selectedTags = new Set();
|
||||||
let currentUser = null;
|
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.
|
// Utility function to add status messages; splits on \n into real <br> line breaks without using innerHTML.
|
||||||
function setStatus(statusEl, message, isError = false) {
|
function setStatus(statusEl, message, isError = false) {
|
||||||
@@ -53,7 +56,7 @@
|
|||||||
.then((user) => {
|
.then((user) => {
|
||||||
currentUser = user;
|
currentUser = user;
|
||||||
entryForm.classList.remove('hidden');
|
entryForm.classList.remove('hidden');
|
||||||
Promise.all([loadTags(), loadMastodonPublishing()]);
|
Promise.all([loadTags(), loadMastodonPublishing(), loadConfig()]).then(updateCharacterCount);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
localStorage.removeItem('linklogAccessToken');
|
localStorage.removeItem('linklogAccessToken');
|
||||||
@@ -61,6 +64,17 @@
|
|||||||
entryForm.classList.add('hidden');
|
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
|
// Load available tags
|
||||||
async function loadTags() {
|
async function loadTags() {
|
||||||
try {
|
try {
|
||||||
@@ -82,11 +96,63 @@
|
|||||||
const config = await response.json();
|
const config = await response.json();
|
||||||
mastodonEnabledCheckbox.checked = Boolean(config.configured);
|
mastodonEnabledCheckbox.checked = Boolean(config.configured);
|
||||||
mastodonPublishing.classList.toggle('hidden', !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) {
|
} catch (error) {
|
||||||
console.error('Could not load Mastodon configuration:', 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
|
// Render tag checkboxes
|
||||||
function renderTags() {
|
function renderTags() {
|
||||||
existingTagsEl.innerHTML = '';
|
existingTagsEl.innerHTML = '';
|
||||||
@@ -104,6 +170,7 @@
|
|||||||
} else {
|
} else {
|
||||||
selectedTags.delete(tag);
|
selectedTags.delete(tag);
|
||||||
}
|
}
|
||||||
|
updateCharacterCount();
|
||||||
});
|
});
|
||||||
|
|
||||||
label.appendChild(checkbox);
|
label.appendChild(checkbox);
|
||||||
@@ -224,22 +291,13 @@
|
|||||||
const url = removeKnownTrackingParams(urlInput.value.trim());
|
const url = removeKnownTrackingParams(urlInput.value.trim());
|
||||||
const title = titleInput.value.trim();
|
const title = titleInput.value.trim();
|
||||||
const comment = commentInput.value.trim();
|
const comment = commentInput.value.trim();
|
||||||
const newTags = newTagsInput.value.trim();
|
|
||||||
|
|
||||||
if (!url || !title) {
|
if (!url || !title) {
|
||||||
setStatus(submitStatus, 'URL and title are required', true);
|
setStatus(submitStatus, 'URL and title are required', true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Combine selected tags and new tags
|
const tags = collectTags();
|
||||||
const tags = Array.from(selectedTags);
|
|
||||||
if (newTags) {
|
|
||||||
const newTagsList = newTags
|
|
||||||
.split(',')
|
|
||||||
.map((t) => t.trim())
|
|
||||||
.filter((t) => t);
|
|
||||||
tags.push(...newTagsList);
|
|
||||||
}
|
|
||||||
|
|
||||||
submitButton.disabled = true;
|
submitButton.disabled = true;
|
||||||
setStatus(submitStatus, 'Saving...', false);
|
setStatus(submitStatus, 'Saving...', false);
|
||||||
|
|||||||
@@ -1242,6 +1242,18 @@ button:disabled {
|
|||||||
display: none;
|
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) {
|
@media (max-width: 600px) {
|
||||||
.container {
|
.container {
|
||||||
padding: 0 14px;
|
padding: 0 14px;
|
||||||
|
|||||||
@@ -71,6 +71,7 @@
|
|||||||
<textarea id="comment-input" name="comment" rows="4" placeholder="Optional comment about this link"></textarea>
|
<textarea id="comment-input" name="comment" rows="4" placeholder="Optional comment about this link"></textarea>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="character-count" class="char-count" aria-live="polite">0/500 characters</div>
|
||||||
|
|
||||||
<fieldset class="form-section">
|
<fieldset class="form-section">
|
||||||
<legend>Tags</legend>
|
<legend>Tags</legend>
|
||||||
@@ -89,6 +90,7 @@
|
|||||||
</label>
|
</label>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button id="submit-button" type="submit">Save Entry</button>
|
<button id="submit-button" type="submit">Save Entry</button>
|
||||||
<a href="/" class="secondary button">Cancel</a>
|
<a href="/" class="secondary button">Cancel</a>
|
||||||
@@ -103,6 +105,6 @@
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="/static/auth-header.js"></script>
|
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user