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);
|
||||
}
|
||||
|
||||
.ap-notification__icon {
|
||||
.ap-notification__avatar-wrap {
|
||||
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 {
|
||||
|
||||
@@ -66,9 +66,10 @@ export async function getNotifications(collections, options = {}) {
|
||||
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) {
|
||||
query.published = { $lt: new Date(options.before) };
|
||||
query.published = { $lt: options.before };
|
||||
}
|
||||
|
||||
const rawItems = await ap_notifications
|
||||
@@ -85,9 +86,9 @@ export async function getNotifications(collections, options = {}) {
|
||||
: item.published,
|
||||
}));
|
||||
|
||||
// Generate cursor for next page
|
||||
// Generate cursor for next page (only if full page returned = more may exist)
|
||||
const before =
|
||||
items.length > 0
|
||||
items.length === limit
|
||||
? items[items.length - 1].published
|
||||
: null;
|
||||
|
||||
|
||||
+16
-14
@@ -94,23 +94,20 @@ export async function getTimelineItems(collections, options = {}) {
|
||||
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) {
|
||||
const beforeDate = new Date(options.before);
|
||||
|
||||
if (Number.isNaN(beforeDate.getTime())) {
|
||||
if (Number.isNaN(new Date(options.before).getTime())) {
|
||||
throw new Error("Invalid before cursor");
|
||||
}
|
||||
|
||||
query.published = { $lt: beforeDate };
|
||||
query.published = { $lt: options.before };
|
||||
} else if (options.after) {
|
||||
const afterDate = new Date(options.after);
|
||||
|
||||
if (Number.isNaN(afterDate.getTime())) {
|
||||
if (Number.isNaN(new Date(options.after).getTime())) {
|
||||
throw new Error("Invalid after cursor");
|
||||
}
|
||||
|
||||
query.published = { $gt: afterDate };
|
||||
query.published = { $gt: options.after };
|
||||
}
|
||||
|
||||
const rawItems = await ap_timeline
|
||||
@@ -128,13 +125,16 @@ export async function getTimelineItems(collections, options = {}) {
|
||||
}));
|
||||
|
||||
// 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 =
|
||||
items.length > 0
|
||||
? items[0].published
|
||||
items.length === limit
|
||||
? items[items.length - 1].published
|
||||
: null;
|
||||
const after =
|
||||
items.length > 0
|
||||
? items[items.length - 1].published
|
||||
items.length > 0 && (options.before || options.after)
|
||||
? items[0].published
|
||||
: null;
|
||||
|
||||
return {
|
||||
@@ -190,7 +190,9 @@ export async function updateTimelineItem(collections, uid, updates) {
|
||||
*/
|
||||
export async function deleteOldTimelineItems(collections, cutoffDate) {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
{% from "pagination/macro.njk" import pagination with context %}
|
||||
|
||||
{% block content %}
|
||||
{{ heading({ text: __("activitypub.activities"), level: 1, parent: { text: __("activitypub.title"), href: mountPath } }) }}
|
||||
{{ heading({ text: __("activitypub.activities"), level: 1 }) }}
|
||||
|
||||
{% if activities.length > 0 %}
|
||||
{% for activity in activities %}
|
||||
|
||||
@@ -3,11 +3,7 @@
|
||||
{% from "heading/macro.njk" import heading with context %}
|
||||
|
||||
{% block readercontent %}
|
||||
{{ heading({
|
||||
text: title,
|
||||
level: 1,
|
||||
parent: { text: __("activitypub.reader.title"), href: mountPath + "/admin/reader" }
|
||||
}) }}
|
||||
{{ heading({ text: title, level: 1 }) }}
|
||||
|
||||
{# Reply context — show the post being replied to #}
|
||||
{% if replyContext %}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
{% from "prose/macro.njk" import prose with context %}
|
||||
|
||||
{% block content %}
|
||||
{{ heading({ text: title, level: 1, parent: { text: __("activitypub.title"), href: mountPath } }) }}
|
||||
{{ heading({ text: title, level: 1 }) }}
|
||||
|
||||
{% if tags.length > 0 %}
|
||||
<table class="table">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
{% from "prose/macro.njk" import prose with context %}
|
||||
|
||||
{% block content %}
|
||||
{{ heading({ text: title, level: 1, parent: { text: __("activitypub.title"), href: mountPath } }) }}
|
||||
{{ heading({ text: title, level: 1 }) }}
|
||||
|
||||
{% if pinned.length > 0 %}
|
||||
<table class="table">
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
{% from "pagination/macro.njk" import pagination with context %}
|
||||
|
||||
{% 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 %}
|
||||
{% for follower in followers %}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
{% from "pagination/macro.njk" import pagination with context %}
|
||||
|
||||
{% 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 %}
|
||||
{% for account in following %}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
{% from "prose/macro.njk" import prose with context %}
|
||||
|
||||
{% block content %}
|
||||
{{ heading({ text: title, level: 1, parent: { text: __("activitypub.title"), href: mountPath } }) }}
|
||||
{{ heading({ text: title, level: 1 }) }}
|
||||
|
||||
{% if result %}
|
||||
{{ notificationBanner({ type: result.type, text: result.text }) }}
|
||||
|
||||
@@ -4,11 +4,7 @@
|
||||
{% from "prose/macro.njk" import prose with context %}
|
||||
|
||||
{% block readercontent %}
|
||||
{{ heading({
|
||||
text: title,
|
||||
level: 1,
|
||||
parent: { text: __("activitypub.reader.title"), href: mountPath + "/admin/reader" }
|
||||
}) }}
|
||||
{{ heading({ text: title, level: 1 }) }}
|
||||
|
||||
{# Blocked actors #}
|
||||
<section class="ap-moderation__section">
|
||||
|
||||
@@ -4,11 +4,7 @@
|
||||
{% from "prose/macro.njk" import prose with context %}
|
||||
|
||||
{% block readercontent %}
|
||||
{{ heading({
|
||||
text: __("activitypub.notifications.title"),
|
||||
level: 1,
|
||||
parent: { text: __("activitypub.reader.title"), href: mountPath + "/admin/reader" }
|
||||
}) }}
|
||||
{{ heading({ text: __("activitypub.notifications.title"), level: 1 }) }}
|
||||
|
||||
{% if items.length > 0 %}
|
||||
<div class="ap-notifications__toolbar">
|
||||
|
||||
@@ -3,11 +3,7 @@
|
||||
{% from "heading/macro.njk" import heading with context %}
|
||||
|
||||
{% block readercontent %}
|
||||
{{ heading({
|
||||
text: title,
|
||||
level: 1,
|
||||
parent: { text: __("activitypub.reader.title"), href: mountPath + "/admin/reader" }
|
||||
}) }}
|
||||
{{ heading({ text: title, level: 1 }) }}
|
||||
|
||||
<div class="ap-post-detail" data-mount-path="{{ mountPath }}">
|
||||
{# Back button #}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
{% from "prose/macro.njk" import prose with context %}
|
||||
|
||||
{% block content %}
|
||||
{{ heading({ text: title, level: 1, parent: { text: __("activitypub.title"), href: mountPath } }) }}
|
||||
{{ heading({ text: title, level: 1 }) }}
|
||||
|
||||
{% if result %}
|
||||
{{ notificationBanner({ type: result.type, text: result.text }) }}
|
||||
|
||||
@@ -4,17 +4,10 @@
|
||||
{% from "prose/macro.njk" import prose with context %}
|
||||
|
||||
{% block readercontent %}
|
||||
{{ heading({
|
||||
text: __("activitypub.reader.title"),
|
||||
level: 1,
|
||||
parent: { text: __("activitypub.title"), href: mountPath }
|
||||
}) }}
|
||||
{{ heading({ text: __("activitypub.reader.title"), level: 1 }) }}
|
||||
|
||||
{# Tab navigation #}
|
||||
<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">
|
||||
{{ __("activitypub.reader.tabs.notes") }}
|
||||
</a>
|
||||
@@ -30,6 +23,9 @@
|
||||
<a href="?tab=media" class="ap-tab{% if tab == 'media' %} ap-tab--active{% endif %}" role="tab">
|
||||
{{ __("activitypub.reader.tabs.media") }}
|
||||
</a>
|
||||
<a href="?tab=all" class="ap-tab{% if tab == 'all' %} ap-tab--active{% endif %}" role="tab">
|
||||
{{ __("activitypub.reader.tabs.all") }}
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
{# Timeline items #}
|
||||
|
||||
@@ -4,11 +4,7 @@
|
||||
{% from "prose/macro.njk" import prose with context %}
|
||||
|
||||
{% block readercontent %}
|
||||
{{ heading({
|
||||
text: title,
|
||||
level: 1,
|
||||
parent: { text: __("activitypub.reader.title"), href: mountPath + "/admin/reader" }
|
||||
}) }}
|
||||
{{ heading({ text: title, level: 1 }) }}
|
||||
|
||||
<div class="ap-profile"
|
||||
x-data="{
|
||||
|
||||
@@ -24,8 +24,9 @@
|
||||
{# Author header #}
|
||||
<header class="ap-card__author">
|
||||
{% if item.author.photo %}
|
||||
<img src="{{ item.author.photo }}" alt="{{ item.author.name }}" class="ap-card__avatar"
|
||||
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 "?" }}'}))">
|
||||
<img src="{{ item.author.photo }}" alt="{{ item.author.name }}" class="ap-card__avatar" loading="lazy" crossorigin="anonymous"
|
||||
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 %}
|
||||
<span class="ap-card__avatar ap-card__avatar--default" aria-hidden="true">{{ item.author.name[0] | upper if item.author.name else "?" }}</span>
|
||||
{% endif %}
|
||||
|
||||
@@ -8,19 +8,18 @@
|
||||
<button type="submit" class="ap-notification__dismiss-btn" title="{{ __('activitypub.notifications.dismiss') }}">×</button>
|
||||
</form>
|
||||
|
||||
{# Type icon #}
|
||||
<div class="ap-notification__icon">
|
||||
{% if item.type == "like" %}
|
||||
❤
|
||||
{% elif item.type == "boost" %}
|
||||
🔁
|
||||
{% elif item.type == "follow" %}
|
||||
👤
|
||||
{% elif item.type == "reply" %}
|
||||
💬
|
||||
{% elif item.type == "mention" %}
|
||||
@
|
||||
{# Actor avatar with type badge #}
|
||||
<div class="ap-notification__avatar-wrap">
|
||||
{% if item.actorPhoto %}
|
||||
<img src="{{ item.actorPhoto }}" alt="{{ item.actorName }}" class="ap-notification__avatar" loading="lazy" crossorigin="anonymous"
|
||||
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>
|
||||
{% else %}
|
||||
<span class="ap-notification__avatar ap-notification__avatar--default" aria-hidden="true">{{ item.actorName[0] | upper if item.actorName else "?" }}</span>
|
||||
{% 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>
|
||||
|
||||
{# Notification body #}
|
||||
|
||||
Reference in New Issue
Block a user