fix: pagination, headers, avatars, tab order, and notification UI
- Fix cursor pagination: use string comparison (not Date objects) for published field queries in both timeline and notifications - Fix "Older" cursor to use oldest item's date, not newest - Remove redundant parent breadcrumb from all AP page headings - Reorder tabs: Notes first, All last - Fix avatar loading: non-destructive hide/show with lazy loading - Add actor avatars with type badge overlay to notification cards - Add Fediverse navigation group in sidebar
This commit is contained in:
+27
-2
@@ -793,9 +793,34 @@
|
|||||||
box-shadow: 0 0 8px 0 hsl(var(--tint-yellow) 50% / 0.3);
|
box-shadow: 0 0 8px 0 hsl(var(--tint-yellow) 50% / 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ap-notification__icon {
|
.ap-notification__avatar-wrap {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
font-size: 1.5em;
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-notification__avatar {
|
||||||
|
border: var(--border-width-thin) solid var(--color-outline);
|
||||||
|
border-radius: 50%;
|
||||||
|
height: 40px;
|
||||||
|
object-fit: cover;
|
||||||
|
width: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-notification__avatar--default {
|
||||||
|
align-items: center;
|
||||||
|
background: var(--color-offset-variant);
|
||||||
|
color: var(--color-on-offset);
|
||||||
|
display: inline-flex;
|
||||||
|
font-size: 1.1em;
|
||||||
|
font-weight: 600;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-notification__type-badge {
|
||||||
|
bottom: -2px;
|
||||||
|
font-size: 0.75em;
|
||||||
|
position: absolute;
|
||||||
|
right: -4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ap-notification__body {
|
.ap-notification__body {
|
||||||
|
|||||||
@@ -66,9 +66,10 @@ export async function getNotifications(collections, options = {}) {
|
|||||||
query.read = false;
|
query.read = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cursor pagination
|
// Cursor pagination — published is stored as ISO string, so compare
|
||||||
|
// as strings (lexicographic ISO 8601 comparison is correct for dates)
|
||||||
if (options.before) {
|
if (options.before) {
|
||||||
query.published = { $lt: new Date(options.before) };
|
query.published = { $lt: options.before };
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawItems = await ap_notifications
|
const rawItems = await ap_notifications
|
||||||
@@ -85,9 +86,9 @@ export async function getNotifications(collections, options = {}) {
|
|||||||
: item.published,
|
: item.published,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Generate cursor for next page
|
// Generate cursor for next page (only if full page returned = more may exist)
|
||||||
const before =
|
const before =
|
||||||
items.length > 0
|
items.length === limit
|
||||||
? items[items.length - 1].published
|
? items[items.length - 1].published
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
|||||||
+16
-14
@@ -94,23 +94,20 @@ export async function getTimelineItems(collections, options = {}) {
|
|||||||
query["author.url"] = options.authorUrl;
|
query["author.url"] = options.authorUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cursor pagination — validate dates
|
// Cursor pagination — published is stored as ISO string, so compare
|
||||||
|
// as strings (lexicographic ISO 8601 comparison is correct for dates)
|
||||||
if (options.before) {
|
if (options.before) {
|
||||||
const beforeDate = new Date(options.before);
|
if (Number.isNaN(new Date(options.before).getTime())) {
|
||||||
|
|
||||||
if (Number.isNaN(beforeDate.getTime())) {
|
|
||||||
throw new Error("Invalid before cursor");
|
throw new Error("Invalid before cursor");
|
||||||
}
|
}
|
||||||
|
|
||||||
query.published = { $lt: beforeDate };
|
query.published = { $lt: options.before };
|
||||||
} else if (options.after) {
|
} else if (options.after) {
|
||||||
const afterDate = new Date(options.after);
|
if (Number.isNaN(new Date(options.after).getTime())) {
|
||||||
|
|
||||||
if (Number.isNaN(afterDate.getTime())) {
|
|
||||||
throw new Error("Invalid after cursor");
|
throw new Error("Invalid after cursor");
|
||||||
}
|
}
|
||||||
|
|
||||||
query.published = { $gt: afterDate };
|
query.published = { $gt: options.after };
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawItems = await ap_timeline
|
const rawItems = await ap_timeline
|
||||||
@@ -128,13 +125,16 @@ export async function getTimelineItems(collections, options = {}) {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Generate cursors for pagination
|
// Generate cursors for pagination
|
||||||
|
// Items are sorted newest-first, so:
|
||||||
|
// - "before" cursor (for "Older" link) = oldest item's date (last in array)
|
||||||
|
// - "after" cursor (for "Newer" link) = newest item's date (first in array)
|
||||||
const before =
|
const before =
|
||||||
items.length > 0
|
items.length === limit
|
||||||
? items[0].published
|
? items[items.length - 1].published
|
||||||
: null;
|
: null;
|
||||||
const after =
|
const after =
|
||||||
items.length > 0
|
items.length > 0 && (options.before || options.after)
|
||||||
? items[items.length - 1].published
|
? items[0].published
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -190,7 +190,9 @@ export async function updateTimelineItem(collections, uid, updates) {
|
|||||||
*/
|
*/
|
||||||
export async function deleteOldTimelineItems(collections, cutoffDate) {
|
export async function deleteOldTimelineItems(collections, cutoffDate) {
|
||||||
const { ap_timeline } = collections;
|
const { ap_timeline } = collections;
|
||||||
const result = await ap_timeline.deleteMany({ published: { $lt: cutoffDate } });
|
// published is stored as ISO string — convert cutoff to string for comparison
|
||||||
|
const cutoff = cutoffDate instanceof Date ? cutoffDate.toISOString() : cutoffDate;
|
||||||
|
const result = await ap_timeline.deleteMany({ published: { $lt: cutoff } });
|
||||||
return result.deletedCount;
|
return result.deletedCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
{% from "pagination/macro.njk" import pagination with context %}
|
{% from "pagination/macro.njk" import pagination with context %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{{ heading({ text: __("activitypub.activities"), level: 1, parent: { text: __("activitypub.title"), href: mountPath } }) }}
|
{{ heading({ text: __("activitypub.activities"), level: 1 }) }}
|
||||||
|
|
||||||
{% if activities.length > 0 %}
|
{% if activities.length > 0 %}
|
||||||
{% for activity in activities %}
|
{% for activity in activities %}
|
||||||
|
|||||||
@@ -3,11 +3,7 @@
|
|||||||
{% from "heading/macro.njk" import heading with context %}
|
{% from "heading/macro.njk" import heading with context %}
|
||||||
|
|
||||||
{% block readercontent %}
|
{% block readercontent %}
|
||||||
{{ heading({
|
{{ heading({ text: title, level: 1 }) }}
|
||||||
text: title,
|
|
||||||
level: 1,
|
|
||||||
parent: { text: __("activitypub.reader.title"), href: mountPath + "/admin/reader" }
|
|
||||||
}) }}
|
|
||||||
|
|
||||||
{# Reply context — show the post being replied to #}
|
{# Reply context — show the post being replied to #}
|
||||||
{% if replyContext %}
|
{% if replyContext %}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
{% from "prose/macro.njk" import prose with context %}
|
{% from "prose/macro.njk" import prose with context %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{{ heading({ text: title, level: 1, parent: { text: __("activitypub.title"), href: mountPath } }) }}
|
{{ heading({ text: title, level: 1 }) }}
|
||||||
|
|
||||||
{% if tags.length > 0 %}
|
{% if tags.length > 0 %}
|
||||||
<table class="table">
|
<table class="table">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
{% from "prose/macro.njk" import prose with context %}
|
{% from "prose/macro.njk" import prose with context %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{{ heading({ text: title, level: 1, parent: { text: __("activitypub.title"), href: mountPath } }) }}
|
{{ heading({ text: title, level: 1 }) }}
|
||||||
|
|
||||||
{% if pinned.length > 0 %}
|
{% if pinned.length > 0 %}
|
||||||
<table class="table">
|
<table class="table">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
{% from "pagination/macro.njk" import pagination with context %}
|
{% from "pagination/macro.njk" import pagination with context %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{{ heading({ text: followerCount + " " + __("activitypub.followers"), level: 1, parent: { text: __("activitypub.title"), href: mountPath } }) }}
|
{{ heading({ text: followerCount + " " + __("activitypub.followers"), level: 1 }) }}
|
||||||
|
|
||||||
{% if followers.length > 0 %}
|
{% if followers.length > 0 %}
|
||||||
{% for follower in followers %}
|
{% for follower in followers %}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
{% from "pagination/macro.njk" import pagination with context %}
|
{% from "pagination/macro.njk" import pagination with context %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{{ heading({ text: followingCount + " " + __("activitypub.following"), level: 1, parent: { text: __("activitypub.title"), href: mountPath } }) }}
|
{{ heading({ text: followingCount + " " + __("activitypub.following"), level: 1 }) }}
|
||||||
|
|
||||||
{% if following.length > 0 %}
|
{% if following.length > 0 %}
|
||||||
{% for account in following %}
|
{% for account in following %}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
{% from "prose/macro.njk" import prose with context %}
|
{% from "prose/macro.njk" import prose with context %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{{ heading({ text: title, level: 1, parent: { text: __("activitypub.title"), href: mountPath } }) }}
|
{{ heading({ text: title, level: 1 }) }}
|
||||||
|
|
||||||
{% if result %}
|
{% if result %}
|
||||||
{{ notificationBanner({ type: result.type, text: result.text }) }}
|
{{ notificationBanner({ type: result.type, text: result.text }) }}
|
||||||
|
|||||||
@@ -4,11 +4,7 @@
|
|||||||
{% from "prose/macro.njk" import prose with context %}
|
{% from "prose/macro.njk" import prose with context %}
|
||||||
|
|
||||||
{% block readercontent %}
|
{% block readercontent %}
|
||||||
{{ heading({
|
{{ heading({ text: title, level: 1 }) }}
|
||||||
text: title,
|
|
||||||
level: 1,
|
|
||||||
parent: { text: __("activitypub.reader.title"), href: mountPath + "/admin/reader" }
|
|
||||||
}) }}
|
|
||||||
|
|
||||||
{# Blocked actors #}
|
{# Blocked actors #}
|
||||||
<section class="ap-moderation__section">
|
<section class="ap-moderation__section">
|
||||||
|
|||||||
@@ -4,11 +4,7 @@
|
|||||||
{% from "prose/macro.njk" import prose with context %}
|
{% from "prose/macro.njk" import prose with context %}
|
||||||
|
|
||||||
{% block readercontent %}
|
{% block readercontent %}
|
||||||
{{ heading({
|
{{ heading({ text: __("activitypub.notifications.title"), level: 1 }) }}
|
||||||
text: __("activitypub.notifications.title"),
|
|
||||||
level: 1,
|
|
||||||
parent: { text: __("activitypub.reader.title"), href: mountPath + "/admin/reader" }
|
|
||||||
}) }}
|
|
||||||
|
|
||||||
{% if items.length > 0 %}
|
{% if items.length > 0 %}
|
||||||
<div class="ap-notifications__toolbar">
|
<div class="ap-notifications__toolbar">
|
||||||
|
|||||||
@@ -3,11 +3,7 @@
|
|||||||
{% from "heading/macro.njk" import heading with context %}
|
{% from "heading/macro.njk" import heading with context %}
|
||||||
|
|
||||||
{% block readercontent %}
|
{% block readercontent %}
|
||||||
{{ heading({
|
{{ heading({ text: title, level: 1 }) }}
|
||||||
text: title,
|
|
||||||
level: 1,
|
|
||||||
parent: { text: __("activitypub.reader.title"), href: mountPath + "/admin/reader" }
|
|
||||||
}) }}
|
|
||||||
|
|
||||||
<div class="ap-post-detail" data-mount-path="{{ mountPath }}">
|
<div class="ap-post-detail" data-mount-path="{{ mountPath }}">
|
||||||
{# Back button #}
|
{# Back button #}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
{% from "prose/macro.njk" import prose with context %}
|
{% from "prose/macro.njk" import prose with context %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{{ heading({ text: title, level: 1, parent: { text: __("activitypub.title"), href: mountPath } }) }}
|
{{ heading({ text: title, level: 1 }) }}
|
||||||
|
|
||||||
{% if result %}
|
{% if result %}
|
||||||
{{ notificationBanner({ type: result.type, text: result.text }) }}
|
{{ notificationBanner({ type: result.type, text: result.text }) }}
|
||||||
|
|||||||
@@ -4,17 +4,10 @@
|
|||||||
{% from "prose/macro.njk" import prose with context %}
|
{% from "prose/macro.njk" import prose with context %}
|
||||||
|
|
||||||
{% block readercontent %}
|
{% block readercontent %}
|
||||||
{{ heading({
|
{{ heading({ text: __("activitypub.reader.title"), level: 1 }) }}
|
||||||
text: __("activitypub.reader.title"),
|
|
||||||
level: 1,
|
|
||||||
parent: { text: __("activitypub.title"), href: mountPath }
|
|
||||||
}) }}
|
|
||||||
|
|
||||||
{# Tab navigation #}
|
{# Tab navigation #}
|
||||||
<nav class="ap-tabs" role="tablist">
|
<nav class="ap-tabs" role="tablist">
|
||||||
<a href="?tab=all" class="ap-tab{% if tab == 'all' %} ap-tab--active{% endif %}" role="tab">
|
|
||||||
{{ __("activitypub.reader.tabs.all") }}
|
|
||||||
</a>
|
|
||||||
<a href="?tab=notes" class="ap-tab{% if tab == 'notes' %} ap-tab--active{% endif %}" role="tab">
|
<a href="?tab=notes" class="ap-tab{% if tab == 'notes' %} ap-tab--active{% endif %}" role="tab">
|
||||||
{{ __("activitypub.reader.tabs.notes") }}
|
{{ __("activitypub.reader.tabs.notes") }}
|
||||||
</a>
|
</a>
|
||||||
@@ -30,6 +23,9 @@
|
|||||||
<a href="?tab=media" class="ap-tab{% if tab == 'media' %} ap-tab--active{% endif %}" role="tab">
|
<a href="?tab=media" class="ap-tab{% if tab == 'media' %} ap-tab--active{% endif %}" role="tab">
|
||||||
{{ __("activitypub.reader.tabs.media") }}
|
{{ __("activitypub.reader.tabs.media") }}
|
||||||
</a>
|
</a>
|
||||||
|
<a href="?tab=all" class="ap-tab{% if tab == 'all' %} ap-tab--active{% endif %}" role="tab">
|
||||||
|
{{ __("activitypub.reader.tabs.all") }}
|
||||||
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{# Timeline items #}
|
{# Timeline items #}
|
||||||
|
|||||||
@@ -4,11 +4,7 @@
|
|||||||
{% from "prose/macro.njk" import prose with context %}
|
{% from "prose/macro.njk" import prose with context %}
|
||||||
|
|
||||||
{% block readercontent %}
|
{% block readercontent %}
|
||||||
{{ heading({
|
{{ heading({ text: title, level: 1 }) }}
|
||||||
text: title,
|
|
||||||
level: 1,
|
|
||||||
parent: { text: __("activitypub.reader.title"), href: mountPath + "/admin/reader" }
|
|
||||||
}) }}
|
|
||||||
|
|
||||||
<div class="ap-profile"
|
<div class="ap-profile"
|
||||||
x-data="{
|
x-data="{
|
||||||
|
|||||||
@@ -24,8 +24,9 @@
|
|||||||
{# Author header #}
|
{# Author header #}
|
||||||
<header class="ap-card__author">
|
<header class="ap-card__author">
|
||||||
{% if item.author.photo %}
|
{% if item.author.photo %}
|
||||||
<img src="{{ item.author.photo }}" alt="{{ item.author.name }}" class="ap-card__avatar"
|
<img src="{{ item.author.photo }}" alt="{{ item.author.name }}" class="ap-card__avatar" loading="lazy" crossorigin="anonymous"
|
||||||
onerror="this.replaceWith(Object.assign(document.createElement('span'),{className:'ap-card__avatar ap-card__avatar--default',textContent:'{{ item.author.name[0] | upper if item.author.name else "?" }}'}))">
|
onerror="this.style.display='none';this.nextElementSibling.style.display=''">
|
||||||
|
<span class="ap-card__avatar ap-card__avatar--default" style="display:none" aria-hidden="true">{{ item.author.name[0] | upper if item.author.name else "?" }}</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="ap-card__avatar ap-card__avatar--default" aria-hidden="true">{{ item.author.name[0] | upper if item.author.name else "?" }}</span>
|
<span class="ap-card__avatar ap-card__avatar--default" aria-hidden="true">{{ item.author.name[0] | upper if item.author.name else "?" }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -8,19 +8,18 @@
|
|||||||
<button type="submit" class="ap-notification__dismiss-btn" title="{{ __('activitypub.notifications.dismiss') }}">×</button>
|
<button type="submit" class="ap-notification__dismiss-btn" title="{{ __('activitypub.notifications.dismiss') }}">×</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{# Type icon #}
|
{# Actor avatar with type badge #}
|
||||||
<div class="ap-notification__icon">
|
<div class="ap-notification__avatar-wrap">
|
||||||
{% if item.type == "like" %}
|
{% if item.actorPhoto %}
|
||||||
❤
|
<img src="{{ item.actorPhoto }}" alt="{{ item.actorName }}" class="ap-notification__avatar" loading="lazy" crossorigin="anonymous"
|
||||||
{% elif item.type == "boost" %}
|
onerror="this.style.display='none';this.nextElementSibling.style.display=''">
|
||||||
🔁
|
<span class="ap-notification__avatar ap-notification__avatar--default" style="display:none" aria-hidden="true">{{ item.actorName[0] | upper if item.actorName else "?" }}</span>
|
||||||
{% elif item.type == "follow" %}
|
{% else %}
|
||||||
👤
|
<span class="ap-notification__avatar ap-notification__avatar--default" aria-hidden="true">{{ item.actorName[0] | upper if item.actorName else "?" }}</span>
|
||||||
{% elif item.type == "reply" %}
|
|
||||||
💬
|
|
||||||
{% elif item.type == "mention" %}
|
|
||||||
@
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<span class="ap-notification__type-badge">
|
||||||
|
{% if item.type == "like" %}❤{% elif item.type == "boost" %}🔁{% elif item.type == "follow" %}👤{% elif item.type == "reply" %}💬{% elif item.type == "mention" %}@{% endif %}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Notification body #}
|
{# Notification body #}
|
||||||
|
|||||||
Reference in New Issue
Block a user