feat(notifications): implement /notifications page with filter pills and load-more
New route with server load function (reads URL params, derives unreadCount from the page, single API call per Sara's architecture requirement), mark-all form action, and the full page UI: filter pills with ARIA radiogroup, notification rows with border+dot unread indicators (WCAG 1.4.1), "Ältere laden" client-side append, and empty state. Includes all de/en/es translation keys. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -320,5 +320,19 @@
|
||||
"dashboard_needs_metadata_show_all": "Alle anzeigen",
|
||||
"dashboard_recent_heading": "Zuletzt aktiv",
|
||||
"dashboard_resume_label": "Zuletzt geöffnet:",
|
||||
"dashboard_resume_fallback": "Unbekanntes Dokument"
|
||||
"dashboard_resume_fallback": "Unbekanntes Dokument",
|
||||
"notification_view_all": "Alle anzeigen →",
|
||||
"notification_history_heading": "Benachrichtigungen",
|
||||
"notification_history_view_link": "Benachrichtigungsverlauf ansehen →",
|
||||
"notification_filter_all": "Alle",
|
||||
"notification_filter_unread": "Ungelesen",
|
||||
"notification_filter_mention": "Erwähnung",
|
||||
"notification_filter_reply": "Antwort",
|
||||
"notification_mark_all_read_aria": "Alle Benachrichtigungen als gelesen markieren",
|
||||
"notification_load_more": "Ältere laden",
|
||||
"notification_empty_history": "Keine Benachrichtigungen",
|
||||
"notification_empty_history_body": "Hier erscheinen Erwähnungen und Antworten auf deine Kommentare.",
|
||||
"notification_row_aria": "{actor} {type} auf \u201e{title}\u201c \u2014 {time} \u2014 {readState}",
|
||||
"notification_read_state_read": "gelesen",
|
||||
"notification_read_state_unread": "ungelesen"
|
||||
}
|
||||
|
||||
@@ -320,5 +320,19 @@
|
||||
"dashboard_needs_metadata_show_all": "Show all",
|
||||
"dashboard_recent_heading": "Recent Activity",
|
||||
"dashboard_resume_label": "Last opened:",
|
||||
"dashboard_resume_fallback": "Unknown document"
|
||||
"dashboard_resume_fallback": "Unknown document",
|
||||
"notification_view_all": "View all →",
|
||||
"notification_history_heading": "Notifications",
|
||||
"notification_history_view_link": "View notification history →",
|
||||
"notification_filter_all": "All",
|
||||
"notification_filter_unread": "Unread",
|
||||
"notification_filter_mention": "Mention",
|
||||
"notification_filter_reply": "Reply",
|
||||
"notification_mark_all_read_aria": "Mark all notifications as read",
|
||||
"notification_load_more": "Load older",
|
||||
"notification_empty_history": "No notifications",
|
||||
"notification_empty_history_body": "Mentions and replies to your comments will appear here.",
|
||||
"notification_row_aria": "{actor} {type} on \"{title}\" — {time} — {readState}",
|
||||
"notification_read_state_read": "read",
|
||||
"notification_read_state_unread": "unread"
|
||||
}
|
||||
|
||||
@@ -320,5 +320,19 @@
|
||||
"dashboard_needs_metadata_show_all": "Ver todos",
|
||||
"dashboard_recent_heading": "Actividad reciente",
|
||||
"dashboard_resume_label": "Último abierto:",
|
||||
"dashboard_resume_fallback": "Documento desconocido"
|
||||
"dashboard_resume_fallback": "Documento desconocido",
|
||||
"notification_view_all": "Ver todas →",
|
||||
"notification_history_heading": "Notificaciones",
|
||||
"notification_history_view_link": "Ver historial de notificaciones →",
|
||||
"notification_filter_all": "Todas",
|
||||
"notification_filter_unread": "No leídas",
|
||||
"notification_filter_mention": "Mención",
|
||||
"notification_filter_reply": "Respuesta",
|
||||
"notification_mark_all_read_aria": "Marcar todas las notificaciones como leídas",
|
||||
"notification_load_more": "Cargar anteriores",
|
||||
"notification_empty_history": "Sin notificaciones",
|
||||
"notification_empty_history_body": "Aquí aparecerán las menciones y respuestas a tus comentarios.",
|
||||
"notification_row_aria": "{actor} {type} en \"{title}\" — {time} — {readState}",
|
||||
"notification_read_state_read": "leído",
|
||||
"notification_read_state_unread": "no leído"
|
||||
}
|
||||
|
||||
136
frontend/src/routes/notifications/+page.server.spec.ts
Normal file
136
frontend/src/routes/notifications/+page.server.spec.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('$lib/api.server', () => ({ createApiClient: vi.fn() }));
|
||||
|
||||
import { load, actions } from './+page.server';
|
||||
import { createApiClient } from '$lib/api.server';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
function makeUrl(params: Record<string, string> = {}) {
|
||||
const url = new URL('http://localhost/notifications');
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
// ─── load ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('notifications page load', () => {
|
||||
it('returns notifications and unreadCount from API response', async () => {
|
||||
const mockGet = vi.fn().mockResolvedValueOnce({
|
||||
response: { ok: true },
|
||||
data: {
|
||||
content: [
|
||||
{ id: 'n1', read: false },
|
||||
{ id: 'n2', read: true },
|
||||
{ id: 'n3', read: false }
|
||||
],
|
||||
totalElements: 3,
|
||||
totalPages: 1,
|
||||
number: 0
|
||||
}
|
||||
});
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
|
||||
|
||||
expect(result.notifications).toHaveLength(3);
|
||||
expect(result.unreadCount).toBe(2);
|
||||
});
|
||||
|
||||
it('passes type param to API when ?type=MENTION is in URL', async () => {
|
||||
const mockGet = vi.fn().mockResolvedValueOnce({
|
||||
response: { ok: true },
|
||||
data: { content: [], totalElements: 0, totalPages: 0, number: 0 }
|
||||
});
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
await load({ url: makeUrl({ type: 'MENTION' }), fetch: vi.fn() as unknown as typeof fetch });
|
||||
|
||||
const queryParams = mockGet.mock.calls[0][1].params.query;
|
||||
expect(queryParams.type).toBe('MENTION');
|
||||
});
|
||||
|
||||
it('passes read=false to API when ?read=false is in URL', async () => {
|
||||
const mockGet = vi.fn().mockResolvedValueOnce({
|
||||
response: { ok: true },
|
||||
data: { content: [], totalElements: 0, totalPages: 0, number: 0 }
|
||||
});
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
await load({ url: makeUrl({ read: 'false' }), fetch: vi.fn() as unknown as typeof fetch });
|
||||
|
||||
const queryParams = mockGet.mock.calls[0][1].params.query;
|
||||
expect(queryParams.read).toBe(false);
|
||||
});
|
||||
|
||||
it('passes no filter params when no search params present', async () => {
|
||||
const mockGet = vi.fn().mockResolvedValueOnce({
|
||||
response: { ok: true },
|
||||
data: { content: [], totalElements: 0, totalPages: 0, number: 0 }
|
||||
});
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
|
||||
|
||||
const queryParams = mockGet.mock.calls[0][1].params.query;
|
||||
expect(queryParams.type).toBeUndefined();
|
||||
expect(queryParams.read).toBeUndefined();
|
||||
});
|
||||
|
||||
it('calls the API exactly once — no separate round-trip for unreadCount', async () => {
|
||||
const mockGet = vi.fn().mockResolvedValueOnce({
|
||||
response: { ok: true },
|
||||
data: { content: [], totalElements: 0, totalPages: 0, number: 0 }
|
||||
});
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
|
||||
|
||||
expect(mockGet).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('throws 401 error when API returns 401', async () => {
|
||||
const mockGet = vi.fn().mockResolvedValueOnce({
|
||||
response: { ok: false, status: 401 },
|
||||
data: null
|
||||
});
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
await expect(
|
||||
load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch })
|
||||
).rejects.toMatchObject({ status: 401 });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── mark-all action ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('notifications mark-all action', () => {
|
||||
it('calls POST /api/notifications/read-all and redirects', async () => {
|
||||
const mockPost = vi.fn().mockResolvedValueOnce({ response: { ok: true } });
|
||||
vi.mocked(createApiClient).mockReturnValue({ POST: mockPost } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
const markAll = actions['mark-all'] as (ctx: { fetch: typeof fetch }) => Promise<never>;
|
||||
await expect(markAll({ fetch: vi.fn() as unknown as typeof fetch })).rejects.toMatchObject({
|
||||
location: '/notifications'
|
||||
});
|
||||
|
||||
expect(mockPost).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
35
frontend/src/routes/notifications/+page.server.ts
Normal file
35
frontend/src/routes/notifications/+page.server.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import { createApiClient } from '$lib/api.server';
|
||||
import { getErrorMessage } from '$lib/errors';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, url }) => {
|
||||
const api = createApiClient(fetch);
|
||||
|
||||
const type = url.searchParams.get('type') ?? undefined;
|
||||
const readParam = url.searchParams.get('read');
|
||||
const read = readParam !== null ? readParam === 'true' : undefined;
|
||||
|
||||
const result = await api.GET('/api/notifications', {
|
||||
params: { query: { type: type as 'MENTION' | 'REPLY' | undefined, read, page: 0, size: 20 } }
|
||||
});
|
||||
|
||||
if (!result.response.ok) {
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
throw error(result.response.status, getErrorMessage(code));
|
||||
}
|
||||
|
||||
const page = result.data!;
|
||||
const notifications = page.content ?? [];
|
||||
const unreadCount = notifications.filter((n) => !n.read).length;
|
||||
|
||||
return { notifications, unreadCount, totalPages: page.totalPages ?? 1 };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
'mark-all': async ({ fetch }) => {
|
||||
const api = createApiClient(fetch);
|
||||
await api.POST('/api/notifications/read-all');
|
||||
redirect(303, '/notifications');
|
||||
}
|
||||
};
|
||||
279
frontend/src/routes/notifications/+page.svelte
Normal file
279
frontend/src/routes/notifications/+page.svelte
Normal file
@@ -0,0 +1,279 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { relativeTime, type NotificationItem } from '$lib/utils/notifications';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let additionalNotifications = $state<NotificationItem[]>([]);
|
||||
let loadMorePage = $state(1);
|
||||
let isLoadingMore = $state(false);
|
||||
|
||||
const allNotifications = $derived([...data.notifications, ...additionalNotifications]);
|
||||
const activeType = $derived(page.url.searchParams.get('type'));
|
||||
const activeReadFilter = $derived(page.url.searchParams.get('read'));
|
||||
const hasMore = $derived(loadMorePage < (data.totalPages ?? 1));
|
||||
|
||||
function setFilter(params: Record<string, string | null>) {
|
||||
additionalNotifications = [];
|
||||
loadMorePage = 1;
|
||||
const url = new URL(page.url);
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v === null) url.searchParams.delete(k);
|
||||
else url.searchParams.set(k, v);
|
||||
}
|
||||
goto(url.toString());
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
isLoadingMore = true;
|
||||
try {
|
||||
const typeParam = page.url.searchParams.get('type');
|
||||
const readParam = page.url.searchParams.get('read');
|
||||
let query = `page=${loadMorePage}&size=20`;
|
||||
if (typeParam) query += `&type=${typeParam}`;
|
||||
if (readParam !== null) query += `&read=${readParam}`;
|
||||
const res = await fetch(`/api/notifications?${query}`);
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
additionalNotifications = [...additionalNotifications, ...(json.content ?? [])];
|
||||
loadMorePage += 1;
|
||||
}
|
||||
} finally {
|
||||
isLoadingMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function navigateToNotification(n: NotificationItem) {
|
||||
if (!n.read) {
|
||||
await fetch(`/api/notifications/${n.id}/read`, { method: 'PATCH' });
|
||||
}
|
||||
const url = n.annotationId
|
||||
? `/documents/${n.documentId}?commentId=${n.referenceId}&annotationId=${n.annotationId}`
|
||||
: `/documents/${n.documentId}?commentId=${n.referenceId}`;
|
||||
goto(url);
|
||||
}
|
||||
|
||||
function typeBadgeLabel(type: NotificationItem['type']): string {
|
||||
return type === 'MENTION' ? m.notification_filter_mention() : m.notification_filter_reply();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{m.notification_history_heading()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen bg-canvas">
|
||||
<div class="mx-auto max-w-2xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<!-- Back link -->
|
||||
<a
|
||||
href="/"
|
||||
class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-gray-500 uppercase transition-colors hover:text-ink"
|
||||
>
|
||||
<svg
|
||||
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
{m.btn_back_to_overview()}
|
||||
</a>
|
||||
|
||||
<!-- Page header -->
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h1 class="font-serif text-2xl font-medium text-ink">
|
||||
{m.notification_history_heading()}
|
||||
</h1>
|
||||
{#if data.unreadCount > 0}
|
||||
<form method="POST" action="?/mark-all">
|
||||
<button
|
||||
type="submit"
|
||||
class="text-sm font-medium text-ink-2 underline decoration-accent/60 transition-colors hover:text-ink hover:decoration-accent"
|
||||
aria-label={m.notification_mark_all_read_aria()}
|
||||
>
|
||||
{m.notification_mark_all_read()}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Filter pills -->
|
||||
<div role="radiogroup" aria-label="Filter" class="mb-6 flex flex-wrap gap-2">
|
||||
<!-- All -->
|
||||
<button
|
||||
role="radio"
|
||||
aria-checked={activeType === null && activeReadFilter === null}
|
||||
onclick={() => setFilter({ type: null, read: null })}
|
||||
class={[
|
||||
'rounded-full px-3 py-2 font-sans text-sm font-medium focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2',
|
||||
activeType === null && activeReadFilter === null
|
||||
? 'bg-primary text-primary-fg'
|
||||
: 'bg-muted text-ink'
|
||||
].join(' ')}
|
||||
>
|
||||
{m.notification_filter_all()}
|
||||
</button>
|
||||
|
||||
<!-- Unread -->
|
||||
<button
|
||||
role="radio"
|
||||
aria-checked={activeReadFilter === 'false'}
|
||||
onclick={() => setFilter({ read: 'false', type: null })}
|
||||
class={[
|
||||
'inline-flex items-center gap-1.5 rounded-full px-3 py-2 font-sans text-sm font-medium focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2',
|
||||
activeReadFilter === 'false'
|
||||
? 'bg-primary text-primary-fg'
|
||||
: 'bg-muted text-ink'
|
||||
].join(' ')}
|
||||
>
|
||||
{m.notification_filter_unread()}
|
||||
{#if data.unreadCount > 0 && activeType === null && activeReadFilter === null}
|
||||
<span
|
||||
class="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-accent px-1 font-sans text-xs font-bold text-ink"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{data.unreadCount}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Mention -->
|
||||
<button
|
||||
role="radio"
|
||||
aria-checked={activeType === 'MENTION'}
|
||||
onclick={() => setFilter({ type: 'MENTION', read: null })}
|
||||
class={[
|
||||
'rounded-full px-3 py-2 font-sans text-sm font-medium focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2',
|
||||
activeType === 'MENTION'
|
||||
? 'bg-primary text-primary-fg'
|
||||
: 'bg-muted text-ink'
|
||||
].join(' ')}
|
||||
>
|
||||
{m.notification_filter_mention()}
|
||||
</button>
|
||||
|
||||
<!-- Reply -->
|
||||
<button
|
||||
role="radio"
|
||||
aria-checked={activeType === 'REPLY'}
|
||||
onclick={() => setFilter({ type: 'REPLY', read: null })}
|
||||
class={[
|
||||
'rounded-full px-3 py-2 font-sans text-sm font-medium focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2',
|
||||
activeType === 'REPLY'
|
||||
? 'bg-primary text-primary-fg'
|
||||
: 'bg-muted text-ink'
|
||||
].join(' ')}
|
||||
>
|
||||
{m.notification_filter_reply()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Notification list or empty state -->
|
||||
{#if allNotifications.length === 0}
|
||||
<div class="flex flex-col items-center gap-3 py-20 text-center">
|
||||
<svg
|
||||
class="h-10 w-10 text-ink-3"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0"
|
||||
/>
|
||||
</svg>
|
||||
<h2 class="font-serif text-lg font-semibold text-ink">
|
||||
{m.notification_empty_history()}
|
||||
</h2>
|
||||
<p class="max-w-xs font-sans text-sm text-ink-2">
|
||||
{m.notification_empty_history_body()}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<ul
|
||||
role="list"
|
||||
class="divide-y divide-line rounded-sm border border-line bg-surface shadow-sm"
|
||||
>
|
||||
{#each allNotifications as n (n.id)}
|
||||
<li class="relative">
|
||||
<a
|
||||
href="/documents/{n.documentId}"
|
||||
role="row"
|
||||
class={[
|
||||
'flex min-h-14 flex-col justify-center border-l-[3px] px-4 py-4 md:px-6 md:py-5',
|
||||
'transition-colors hover:bg-accent-bg',
|
||||
n.read
|
||||
? 'border-l-transparent bg-canvas'
|
||||
: 'border-l-accent'
|
||||
].join(' ')}
|
||||
aria-label={m.notification_row_aria({
|
||||
actor: n.actorName,
|
||||
type: typeBadgeLabel(n.type),
|
||||
title: n.documentTitle ?? '',
|
||||
time: relativeTime(n.createdAt),
|
||||
readState: n.read ? m.notification_read_state_read() : m.notification_read_state_unread()
|
||||
})}
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
navigateToNotification(n);
|
||||
}}
|
||||
>
|
||||
<!-- Unread dot indicator -->
|
||||
{#if !n.read}
|
||||
<span
|
||||
class="absolute top-4 right-4 h-2 w-2 rounded-full bg-accent md:right-6"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{/if}
|
||||
|
||||
<!-- Line 1: actor name + type badge -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-serif font-semibold text-ink">{n.actorName}</span>
|
||||
<span
|
||||
class="rounded-sm bg-muted px-2 py-0.5 font-sans text-xs tracking-wide text-ink-2 uppercase"
|
||||
>
|
||||
{typeBadgeLabel(n.type)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Line 2: document title -->
|
||||
{#if n.documentTitle}
|
||||
<p
|
||||
class="mt-0.5 font-serif text-sm text-ink hover:underline hover:decoration-accent"
|
||||
>
|
||||
{n.documentTitle}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Line 3: relative time -->
|
||||
<p class="mt-1 font-sans text-sm text-ink-3">
|
||||
{relativeTime(n.createdAt)}
|
||||
</p>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<!-- Load more -->
|
||||
{#if hasMore}
|
||||
<button
|
||||
onclick={loadMore}
|
||||
disabled={isLoadingMore}
|
||||
class="mt-6 w-full rounded-sm border border-line py-3 text-sm font-medium text-ink-2 transition-colors hover:bg-canvas disabled:opacity-50"
|
||||
>
|
||||
{isLoadingMore ? '…' : m.notification_load_more()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user