Files
indiekit-endpoint-activitypub/views/activitypub-explore.njk
Ricardo fca1738bd3 feat: skeleton loaders replace loading text (Release 6)
Animated card-shaped placeholders with shimmer effect shown during
content loading instead of plain "Loading..." text. Applied to reader,
tag timeline, and explore tabs (both first-load and load-more states).

Confab-Link: http://localhost:8080/sessions/e9d666ac-3c90-4298-9e92-9ac9d142bc06
2026-03-03 15:48:59 +01:00

420 lines
19 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "layouts/ap-reader.njk" %}
{% from "prose/macro.njk" import prose with context %}
{% block readercontent %}
{# Page header #}
<header class="ap-explore-header">
<h2 class="ap-explore-header__title">{{ __("activitypub.reader.explore.title") }}</h2>
<p class="ap-explore-header__desc">{{ __("activitypub.reader.explore.description") }}</p>
</header>
{# ── Tabbed explore container (Alpine.js component) ──────────────────── #}
<div class="ap-explore-tabs-container"
data-mount-path="{{ mountPath }}"
data-csrf="{{ csrfToken }}"
x-data="apExploreTabs()">
{# ── Tab bar ──────────────────────────────────────────────────────────── #}
<nav class="ap-tabs ap-explore-tabs-nav"
id="ap-explore-tab-bar"
role="tablist"
aria-label="{{ __('activitypub.reader.explore.tabs.label') }}">
{# Search tab — always first, not removable #}
<button
type="button"
class="ap-tab"
:class="{ 'ap-tab--active': activeTabId === null }"
role="tab"
:aria-selected="activeTabId === null ? 'true' : 'false'"
aria-controls="ap-tab-panel-search"
id="ap-tab-btn-search"
@click="switchToSearch()"
@keydown="handleTabKeydown($event, 0)">
{{ __("activitypub.reader.explore.tabs.search") }}
</button>
{# User-created instance and hashtag tabs #}
<template x-for="(tab, index) in tabs" :key="tab._id">
<div class="ap-tab-wrapper">
{# Tab button #}
<button
type="button"
class="ap-tab ap-tab--user"
:class="{ 'ap-tab--active': activeTabId === tab._id }"
role="tab"
:aria-selected="activeTabId === tab._id ? 'true' : 'false'"
:aria-controls="'ap-tab-panel-' + tab._id"
:id="'ap-tab-btn-' + tab._id"
@click="switchTab(tab._id)"
@keydown="handleTabKeydown($event, index + 1)">
<span class="ap-tab__label" :title="tabLabel(tab)" x-text="tabLabel(tab)"></span>
<span
x-show="tab.type === 'instance'"
class="ap-tab__badge"
:class="tab.scope === 'local' ? 'ap-tab__badge--local' : 'ap-tab__badge--federated'"
x-text="tab.scope"></span>
</button>
{# Reorder and close controls #}
<div class="ap-tab-controls">
<button
type="button"
class="ap-tab-control ap-tab-control--up"
@click.stop="moveUp(tab)"
:disabled="index === 0"
:aria-label="'{{ __('activitypub.reader.explore.tabs.moveUp') }}: ' + tabLabel(tab)">↑</button>
<button
type="button"
class="ap-tab-control ap-tab-control--down"
@click.stop="moveDown(tab)"
:disabled="index === tabs.length - 1"
:aria-label="'{{ __('activitypub.reader.explore.tabs.moveDown') }}: ' + tabLabel(tab)">↓</button>
<button
type="button"
class="ap-tab-control ap-tab-control--remove"
@click.stop="removeTab(tab)"
:aria-label="'{{ __('activitypub.reader.explore.tabs.remove') }}: ' + tabLabel(tab)">×</button>
</div>
</div>
</template>
{# +# button — add a hashtag tab #}
<div class="ap-tab-add-hashtag">
<button
type="button"
class="ap-tab ap-tab--add"
@click="showHashtagForm = !showHashtagForm"
:aria-expanded="showHashtagForm ? 'true' : 'false'"
title="{{ __('activitypub.reader.explore.tabs.addHashtag') }}">+#</button>
<form
x-show="showHashtagForm"
x-cloak
class="ap-tab-hashtag-form"
@submit.prevent="submitHashtagTab()"
@keydown.escape.prevent="showHashtagForm = false; hashtagInput = ''">
<span class="ap-tab-hashtag-form__prefix">#</span>
<input
type="text"
x-model="hashtagInput"
class="ap-tab-hashtag-form__input"
placeholder="{{ __('activitypub.reader.explore.tabs.hashtagTabPlaceholder') }}"
aria-label="{{ __('activitypub.reader.explore.tabs.addHashtag') }}"
autocomplete="off"
maxlength="100">
<button type="submit" class="ap-tab-hashtag-form__btn">
{{ __("activitypub.reader.explore.tabs.addTab") }}
</button>
</form>
</div>
</nav>{# end tab bar nav #}
{# Error message (CSRF expiry, network errors) #}
<p x-show="error" x-cloak class="ap-explore-error" x-text="error"></p>
{# ── Search tab panel ─────────────────────────────────────────────────── #}
<div id="ap-tab-panel-search"
role="tabpanel"
aria-labelledby="ap-tab-btn-search"
x-show="activeTabId === null">
{# Instance form with autocomplete #}
<form action="{{ mountPath }}/admin/reader/explore" method="get" class="ap-explore-form"
x-data="apInstanceSearch('{{ mountPath }}')"
@submit="onSubmit">
<div class="ap-explore-form__row">
<div class="ap-explore-autocomplete">
<input
type="text"
name="instance"
value="{{ instance }}"
class="ap-explore-form__input"
placeholder="{{ __('activitypub.reader.explore.instancePlaceholder') }}"
aria-label="{{ __('activitypub.reader.explore.instancePlaceholder') }}"
autocomplete="off"
required
x-model="query"
@input.debounce.300ms="search()"
@keydown.arrow-down.prevent="highlightNext()"
@keydown.arrow-up.prevent="highlightPrev()"
@keydown.enter="selectHighlighted($event)"
@keydown.escape="close()"
@focus="showResults && suggestions.length > 0 ? showResults = true : null"
@click.away="close()"
x-ref="input">
{# Autocomplete dropdown #}
<div class="ap-explore-autocomplete__dropdown" x-show="showResults && suggestions.length > 0" x-cloak>
<template x-for="(item, index) in suggestions" :key="item.domain">
<button type="button"
class="ap-explore-autocomplete__item"
:class="{ 'ap-explore-autocomplete__item--highlighted': index === highlighted }"
@click="selectItem(item)"
@mouseenter="highlighted = index">
<span class="ap-explore-autocomplete__domain" x-text="item.domain"></span>
<span class="ap-explore-autocomplete__meta">
<span class="ap-explore-autocomplete__software" x-text="item.software"></span>
<template x-if="item.mau > 0">
<span class="ap-explore-autocomplete__mau" x-text="item.mau.toLocaleString() + ' {{ __("activitypub.reader.explore.mauLabel") }}'"></span>
</template>
</span>
<span class="ap-explore-autocomplete__status" x-show="item._timelineStatus !== undefined">
<template x-if="item._timelineStatus === 'checking'">
<span class="ap-explore-autocomplete__checking">⏳</span>
</template>
<template x-if="item._timelineStatus === true">
<span class="ap-explore-autocomplete__supported" title="{{ __('activitypub.reader.explore.timelineSupported') }}">✅</span>
</template>
<template x-if="item._timelineStatus === false">
<span class="ap-explore-autocomplete__unsupported" title="{{ __('activitypub.reader.explore.timelineUnsupported') }}">❌</span>
</template>
</span>
</button>
</template>
</div>
</div>
<div class="ap-explore-form__scope">
<label class="ap-explore-form__scope-label">
<input type="radio" name="scope" value="local"
{% if scope == "local" %}checked{% endif %}>
{{ __("activitypub.reader.explore.local") }}
</label>
<label class="ap-explore-form__scope-label">
<input type="radio" name="scope" value="federated"
{% if scope == "federated" %}checked{% endif %}>
{{ __("activitypub.reader.explore.federated") }}
</label>
</div>
<button type="submit" class="ap-explore-form__btn">
{{ __("activitypub.reader.explore.browse") }}
</button>
</div>
<div class="ap-explore-form__hashtag-row">
<label class="ap-explore-form__hashtag-label" for="ap-explore-hashtag">
{{ __("activitypub.reader.explore.hashtagLabel") }}
</label>
<span class="ap-explore-form__hashtag-prefix">#</span>
<input
type="text"
id="ap-explore-hashtag"
name="hashtag"
value="{{ hashtag }}"
class="ap-explore-form__input ap-explore-form__input--hashtag"
placeholder="{{ __('activitypub.reader.explore.hashtagPlaceholder') }}"
aria-label="{{ __('activitypub.reader.explore.hashtagPlaceholder') }}"
autocomplete="off"
pattern="[\w]+"
maxlength="100">
<span class="ap-explore-form__hashtag-hint">{{ __("activitypub.reader.explore.hashtagHint") }}</span>
</div>
</form>
{# Error state #}
{% if error %}
<div class="ap-explore-error">{{ error }}</div>
{% endif %}
{# Results #}
{% if instance and not error %}
{# Pin as tab button — outside form, inside apExploreTabs scope #}
<div class="ap-explore-pin-bar">
<button
type="button"
class="ap-explore-pin-btn"
@click="pinInstance('{{ instance }}', '{{ scope }}')"
:disabled="pinning">
<span x-show="!pinning">{{ __("activitypub.reader.explore.tabs.pinAsTab") }}</span>
<span x-show="pinning" x-cloak>…</span>
</button>
</div>
{% if items.length > 0 %}
<div class="ap-timeline ap-explore-timeline"
id="ap-explore-timeline"
data-instance="{{ instance }}"
data-scope="{{ scope }}"
data-hashtag="{{ hashtag }}"
data-mount-path="{{ mountPath }}"
data-max-id="{{ maxId if maxId else '' }}">
{% for item in items %}
{% include "partials/ap-item-card.njk" %}
{% endfor %}
</div>
{# Infinite scroll for explore search tab #}
{% if maxId %}
<div class="ap-load-more"
id="ap-explore-load-more"
data-cursor="{{ maxId }}"
data-api-url="{{ mountPath }}/admin/reader/api/explore"
data-cursor-param="max_id"
data-cursor-field="maxId"
data-timeline-id="ap-explore-timeline"
data-extra-params='{{ { instance: instance, scope: scope, hashtag: hashtag } | dump }}'
x-data="apInfiniteScroll()"
x-init="init()">
<div class="ap-load-more__sentinel" x-ref="sentinel"></div>
<button class="ap-load-more__btn" @click="loadMore()" :disabled="loading" x-show="!done && !loading">
{{ __("activitypub.reader.pagination.loadMore") }}
</button>
<div class="ap-skeleton-group" x-show="loading" x-cloak>
{% include "partials/ap-skeleton-card.njk" %}
{% include "partials/ap-skeleton-card.njk" %}
{% include "partials/ap-skeleton-card.njk" %}
</div>
<p class="ap-load-more__done" x-show="done" x-cloak>{{ __("activitypub.reader.pagination.noMore") }}</p>
</div>
{% endif %}
{% elif instance %}
{{ prose({ text: __("activitypub.reader.explore.noResults") }) }}
{% endif %}
{% endif %}
</div>{# end Search tab panel #}
{# ── Dynamic tab panels (instance + hashtag) ──────────────────────────── #}
<template x-for="tab in tabs" :key="tab._id + '-panel'">
<div
:id="'ap-tab-panel-' + tab._id"
role="tabpanel"
:aria-labelledby="'ap-tab-btn-' + tab._id"
x-show="activeTabId === tab._id"
x-cloak>
{# ── Instance tab panel ───────────────────────────────────────────── #}
<template x-if="tab.type === 'instance'">
<div class="ap-explore-instance-panel">
{# Skeleton loaders — first load, no content yet #}
<div class="ap-skeleton-group"
x-show="tabState[tab._id] && tabState[tab._id].loading && !tabState[tab._id].html">
{% include "partials/ap-skeleton-card.njk" %}
{% include "partials/ap-skeleton-card.njk" %}
{% include "partials/ap-skeleton-card.njk" %}
</div>
{# Error state with retry #}
<div class="ap-explore-tab-error"
x-show="tabState[tab._id] && tabState[tab._id].error && !tabState[tab._id].html">
<p class="ap-explore-tab-error__message" x-text="tabState[tab._id] && tabState[tab._id].error"></p>
<button type="button" class="ap-explore-tab-error__retry" @click="retryTab(tab)">
{{ __("activitypub.reader.explore.tabs.retry") }}
</button>
</div>
{# Timeline content — server-rendered cards injected via x-html #}
<div class="ap-timeline ap-explore-tab-timeline"
x-show="tabState[tab._id] && tabState[tab._id].html"
x-html="tabState[tab._id] ? tabState[tab._id].html : ''">
</div>
{# Load more button + skeleton loaders for subsequent pages #}
<div class="ap-load-more"
x-show="tabState[tab._id] && tabState[tab._id].html && !tabState[tab._id].done">
<button class="ap-load-more__btn"
x-show="!tabState[tab._id]?.loading"
@click="loadMoreTab(tab)"
:disabled="tabState[tab._id]?.loading">
{{ __("activitypub.reader.pagination.loadMore") }}
</button>
<div class="ap-skeleton-group"
x-show="tabState[tab._id]?.loading">
{% include "partials/ap-skeleton-card.njk" %}
{% include "partials/ap-skeleton-card.njk" %}
</div>
</div>
{# Empty state — loaded successfully but no posts #}
<div class="ap-explore-tab-empty"
x-show="tabState[tab._id] && tabState[tab._id].done && !tabState[tab._id].html && !tabState[tab._id].loading && !tabState[tab._id].error">
<p>{{ __("activitypub.reader.explore.noResults") }}</p>
</div>
{# End of feed message #}
<p class="ap-load-more__done"
x-show="tabState[tab._id] && tabState[tab._id].done && tabState[tab._id].html"
x-cloak>
{{ __("activitypub.reader.pagination.noMore") }}
</p>
{# Infinite scroll sentinel — watched by IntersectionObserver #}
<div class="ap-tab-sentinel"></div>
</div>
</template>
{# ── Hashtag tab panel ────────────────────────────────────────────── #}
<template x-if="tab.type === 'hashtag'">
<div class="ap-explore-hashtag-panel">
{# Source info line — shows which instances are being searched #}
<p class="ap-hashtag-sources"
x-show="tabState[tab._id] && tabState[tab._id].sourceMeta && tabState[tab._id].sourceMeta.instancesQueried > 0"
x-text="hashtagSourcesLine(tab)"
x-cloak></p>
{# Skeleton loaders — first load, no content yet #}
<div class="ap-skeleton-group"
x-show="tabState[tab._id] && tabState[tab._id].loading && !tabState[tab._id].html">
{% include "partials/ap-skeleton-card.njk" %}
{% include "partials/ap-skeleton-card.njk" %}
{% include "partials/ap-skeleton-card.njk" %}
</div>
{# Error state with retry #}
<div class="ap-explore-tab-error"
x-show="tabState[tab._id] && tabState[tab._id].error && !tabState[tab._id].html">
<p class="ap-explore-tab-error__message" x-text="tabState[tab._id] && tabState[tab._id].error"></p>
<button type="button" class="ap-explore-tab-error__retry" @click="retryTab(tab)">
{{ __("activitypub.reader.explore.tabs.retry") }}
</button>
</div>
{# Timeline content #}
<div class="ap-timeline ap-explore-tab-timeline"
x-show="tabState[tab._id] && tabState[tab._id].html"
x-html="tabState[tab._id] ? tabState[tab._id].html : ''">
</div>
{# Load more button + skeleton loaders for subsequent pages #}
<div class="ap-load-more"
x-show="tabState[tab._id] && tabState[tab._id].html && !tabState[tab._id].done">
<button class="ap-load-more__btn"
x-show="!tabState[tab._id]?.loading"
@click="loadMoreTab(tab)"
:disabled="tabState[tab._id]?.loading">
{{ __("activitypub.reader.pagination.loadMore") }}
</button>
<div class="ap-skeleton-group"
x-show="tabState[tab._id]?.loading">
{% include "partials/ap-skeleton-card.njk" %}
{% include "partials/ap-skeleton-card.njk" %}
</div>
</div>
{# Empty state — no instance tabs pinned yet #}
<div class="ap-explore-tab-empty"
x-show="tabState[tab._id] && tabState[tab._id].done && !tabState[tab._id].html && !tabState[tab._id].loading && !tabState[tab._id].error">
<p>{{ __("activitypub.reader.explore.tabs.noInstances") }}</p>
</div>
{# End of feed message #}
<p class="ap-load-more__done"
x-show="tabState[tab._id] && tabState[tab._id].done && tabState[tab._id].html"
x-cloak>
{{ __("activitypub.reader.pagination.noMore") }}
</p>
{# Infinite scroll sentinel #}
<div class="ap-tab-sentinel"></div>
</div>
</template>
</div>
</template>
</div>{# end ap-explore-tabs-container #}
{% endblock %}