480 lines
16 KiB
Plaintext
480 lines
16 KiB
Plaintext
---
|
|
import { z } from 'astro/zod'
|
|
import { Icon } from 'astro-icon/components'
|
|
import { actions } from 'astro:actions'
|
|
|
|
import Button from '../components/Button.astro'
|
|
import CopyButton from '../components/CopyButton.astro'
|
|
import PushNotificationBanner from '../components/PushNotificationBanner.astro'
|
|
import TimeFormatted from '../components/TimeFormatted.astro'
|
|
import Tooltip from '../components/Tooltip.astro'
|
|
import { getNotificationTypeInfo } from '../constants/notificationTypes'
|
|
import BaseLayout from '../layouts/BaseLayout.astro'
|
|
import { cn } from '../lib/cn'
|
|
import { getOrCreateNotificationPreferences } from '../lib/notificationPreferences'
|
|
import { makeNotificationActions, makeNotificationContent, makeNotificationTitle } from '../lib/notifications'
|
|
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
|
|
import { prisma } from '../lib/prisma'
|
|
import { makeLoginUrl } from '../lib/redirectUrls'
|
|
|
|
const user = Astro.locals.user
|
|
if (!user) return Astro.redirect(makeLoginUrl(Astro.url))
|
|
|
|
const PAGE_SIZE = 20
|
|
|
|
const { data: params } = zodParseQueryParamsStoringErrors(
|
|
{
|
|
page: z.coerce.number().int().min(1).default(1),
|
|
},
|
|
Astro
|
|
)
|
|
const skip = (params.page - 1) * PAGE_SIZE
|
|
|
|
const [dbNotifications, notificationPreferences, totalNotifications, pushSubscriptions] =
|
|
await Astro.locals.banners.tryMany([
|
|
[
|
|
'Error while fetching notifications',
|
|
() =>
|
|
prisma.notification.findMany({
|
|
where: {
|
|
userId: user.id,
|
|
},
|
|
orderBy: {
|
|
createdAt: 'desc',
|
|
},
|
|
skip,
|
|
take: PAGE_SIZE,
|
|
select: {
|
|
id: true,
|
|
type: true,
|
|
createdAt: true,
|
|
read: true,
|
|
aboutAccountStatusChange: true,
|
|
aboutCommentStatusChange: true,
|
|
aboutServiceVerificationStatusChange: true,
|
|
aboutSuggestionStatusChange: true,
|
|
aboutComment: {
|
|
select: {
|
|
id: true,
|
|
author: {
|
|
select: {
|
|
id: true,
|
|
},
|
|
},
|
|
status: true,
|
|
content: true,
|
|
communityNote: true,
|
|
service: {
|
|
select: {
|
|
slug: true,
|
|
name: true,
|
|
},
|
|
},
|
|
parent: {
|
|
select: {
|
|
author: {
|
|
select: {
|
|
id: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
aboutServiceSuggestionId: true,
|
|
aboutServiceSuggestion: {
|
|
select: {
|
|
status: true,
|
|
type: true,
|
|
service: {
|
|
select: {
|
|
name: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
aboutServiceSuggestionMessage: {
|
|
select: {
|
|
id: true,
|
|
content: true,
|
|
suggestion: {
|
|
select: {
|
|
id: true,
|
|
service: {
|
|
select: {
|
|
name: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
aboutEvent: {
|
|
select: {
|
|
title: true,
|
|
type: true,
|
|
service: {
|
|
select: {
|
|
slug: true,
|
|
name: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
aboutService: {
|
|
select: {
|
|
slug: true,
|
|
name: true,
|
|
verificationStatus: true,
|
|
},
|
|
},
|
|
aboutKarmaTransaction: {
|
|
select: {
|
|
points: true,
|
|
action: true,
|
|
description: true,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
[],
|
|
],
|
|
[
|
|
'Error while fetching notification preferences',
|
|
() =>
|
|
getOrCreateNotificationPreferences(user.id, {
|
|
id: true,
|
|
enableOnMyCommentStatusChange: true,
|
|
enableAutowatchMyComments: true,
|
|
enableNotifyPendingRepliesOnWatch: true,
|
|
karmaNotificationThreshold: true,
|
|
}),
|
|
null,
|
|
],
|
|
[
|
|
'Error while fetching total notifications',
|
|
() => prisma.notification.count({ where: { userId: user.id } }),
|
|
0,
|
|
],
|
|
[
|
|
'Error while fetching push subscriptions',
|
|
() =>
|
|
prisma.pushSubscription.findMany({
|
|
where: { userId: user.id },
|
|
select: {
|
|
endpoint: true,
|
|
},
|
|
}),
|
|
[],
|
|
],
|
|
])
|
|
|
|
if (!notificationPreferences) console.error('No notification preferences found')
|
|
|
|
const totalPages = Math.ceil(totalNotifications / PAGE_SIZE)
|
|
|
|
const notificationPreferenceFields = [
|
|
{
|
|
id: 'enableOnMyCommentStatusChange',
|
|
label: 'Notify me when my comment status changes.',
|
|
icon: 'ri:chat-check-line',
|
|
},
|
|
{
|
|
id: 'enableAutowatchMyComments',
|
|
label: 'Autowatch my comments to receive notifications when they get a new reply.',
|
|
icon: 'ri:eye-line',
|
|
},
|
|
{
|
|
id: 'enableNotifyPendingRepliesOnWatch',
|
|
label: 'Notify me also about unmoderated replies for watched comments.',
|
|
icon: 'ri:chat-delete-line',
|
|
},
|
|
] as const satisfies {
|
|
id: Omit<keyof NonNullable<typeof notificationPreferences>, 'id'>
|
|
label: string
|
|
icon: string
|
|
}[]
|
|
|
|
const notifications = dbNotifications.map((notification) => ({
|
|
...notification,
|
|
typeInfo: getNotificationTypeInfo(notification.type),
|
|
title: makeNotificationTitle(notification, user),
|
|
content: makeNotificationContent(notification),
|
|
actions: makeNotificationActions(notification, Astro.url.origin),
|
|
}))
|
|
---
|
|
|
|
<BaseLayout
|
|
pageTitle="Notifications"
|
|
description="View your notifications and manage your notification preferences."
|
|
widthClassName="max-w-screen-lg"
|
|
ogImage={{
|
|
template: 'generic',
|
|
title: 'Notifications',
|
|
description: 'View and manage your notifications',
|
|
icon: 'ri:notification-line',
|
|
}}
|
|
>
|
|
<section class="mx-auto w-full">
|
|
{
|
|
notifications.length >= 5 && (
|
|
<PushNotificationBanner
|
|
class="mb-4"
|
|
dismissable
|
|
pushSubscriptions={pushSubscriptions}
|
|
hideIfEnabled
|
|
/>
|
|
)
|
|
}
|
|
|
|
<div class="mb-4 flex items-center justify-between">
|
|
<h1 class="font-title flex items-center text-2xl leading-tight font-bold tracking-wider">
|
|
<Icon name="ri:notification-line" class="mr-2 size-6 text-zinc-400" />
|
|
Notifications
|
|
</h1>
|
|
|
|
<form method="POST" action={actions.notification.updateReadStatus}>
|
|
<input type="hidden" name="notificationId" value="all" />
|
|
<input type="hidden" name="read" value="true" />
|
|
<Button
|
|
type="submit"
|
|
label="Mark all as read"
|
|
icon="ri:check-double-line"
|
|
disabled={notifications.length === 0}
|
|
color="white"
|
|
/>
|
|
</form>
|
|
</div>
|
|
|
|
<div
|
|
class="mb-4 flex items-center gap-2 rounded-lg border border-blue-500/20 bg-blue-500/10 p-3 text-blue-200"
|
|
data-new-notification-banner
|
|
style={{ display: 'none' }}
|
|
>
|
|
<span>You received a new notification</span>
|
|
<Button
|
|
type="button"
|
|
label="Reload"
|
|
icon="ri:refresh-line"
|
|
color="white"
|
|
class="no-js:hidden ml-auto"
|
|
onclick="window.location.reload()"
|
|
/>
|
|
</div>
|
|
|
|
{
|
|
notifications.length === 0 ? (
|
|
<div class="flex flex-col items-center justify-center rounded-lg border border-zinc-800 bg-zinc-900 p-10 text-center shadow-sm">
|
|
<Icon name="material-symbols:notifications-outline" class="size-16 text-zinc-600" />
|
|
<h3 class="font-title mt-2 text-zinc-400">No notifications</h3>
|
|
</div>
|
|
) : (
|
|
<div class="space-y-2">
|
|
{notifications.map((notification) => (
|
|
<div
|
|
class={cn('rounded-lg border border-l-4 border-zinc-800 bg-zinc-900 p-3 shadow-sm', {
|
|
'border-l-blue-600': !notification.read,
|
|
})}
|
|
>
|
|
<div class="flex items-start justify-between">
|
|
<div class="flex items-start gap-3">
|
|
<div class="mt-0.5 rounded-md bg-zinc-800 p-2">
|
|
<Icon name={notification.typeInfo.icon} class="size-5 text-zinc-300" />
|
|
</div>
|
|
|
|
<div>
|
|
<div class="font-medium text-zinc-200">{notification.title}</div>
|
|
<p class="text-sm wrap-anywhere text-zinc-400">{notification.content}</p>
|
|
<div class="mt-1 text-xs text-zinc-500">
|
|
<TimeFormatted date={notification.createdAt} prefix={false} caseType="sentence" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2">
|
|
<form method="POST" action={actions.notification.updateReadStatus}>
|
|
<input type="hidden" name="notificationId" value={notification.id} />
|
|
<input type="hidden" name="read" value={notification.read ? 'false' : 'true'} />
|
|
<Tooltip
|
|
as="button"
|
|
text={notification.read ? 'Mark as unread' : 'Mark as read'}
|
|
position="left"
|
|
type="submit"
|
|
class="flex size-8 items-center justify-center rounded-full border border-zinc-700 bg-zinc-800 text-zinc-400 transition-colors duration-200 hover:bg-zinc-700 hover:text-zinc-200"
|
|
>
|
|
<Icon name={notification.read ? 'ri:close-line' : 'ri:check-line'} class="size-4" />
|
|
</Tooltip>
|
|
</form>
|
|
{notification.actions.map((action) => (
|
|
<Tooltip
|
|
as="a"
|
|
href={action.url}
|
|
class="flex size-8 items-center justify-center rounded-full border border-zinc-700 bg-zinc-800 text-zinc-400 transition-colors duration-200 hover:bg-zinc-700 hover:text-zinc-200"
|
|
text={action.title}
|
|
position="left"
|
|
>
|
|
<Icon name={action.iconName ?? 'ri:arrow-right-line'} class="size-4" />
|
|
<span class="sr-only">{action.title}</span>
|
|
</Tooltip>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{totalPages > 1 && (
|
|
<div class="mt-8 flex justify-center gap-4">
|
|
<form method="GET" action="/notifications" class="inline">
|
|
<input type="hidden" name="page" value={params.page - 1} />
|
|
<button
|
|
type="submit"
|
|
class="rounded-md border border-zinc-700 bg-zinc-800 px-4 py-2 text-sm text-zinc-200 transition-colors duration-200 hover:bg-zinc-700"
|
|
disabled={params.page <= 1}
|
|
>
|
|
Previous
|
|
</button>
|
|
</form>
|
|
<span class="inline-flex items-center px-2 text-sm text-zinc-400">
|
|
Page {params.page} of {totalPages}
|
|
</span>
|
|
<form method="GET" action="/notifications" class="inline">
|
|
<input type="hidden" name="page" value={params.page + 1} />
|
|
<button
|
|
type="submit"
|
|
class="rounded-md border border-zinc-700 bg-zinc-800 px-4 py-2 text-sm text-zinc-200 transition-colors duration-200 hover:bg-zinc-700"
|
|
disabled={params.page >= totalPages}
|
|
>
|
|
Next
|
|
</button>
|
|
</form>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
<h2 class="font-title mt-8 mb-3 flex items-center border-b border-zinc-800 text-lg font-bold">
|
|
<Icon name="ri:settings-3-line" class="mr-2 size-5 text-zinc-400" />
|
|
Notification Settings
|
|
</h2>
|
|
|
|
<PushNotificationBanner class="mb-3" pushSubscriptions={pushSubscriptions} />
|
|
|
|
<form
|
|
method="POST"
|
|
action={actions.notification.preferences.update}
|
|
class="rounded-lg border border-zinc-800 bg-zinc-900 p-4 shadow-sm"
|
|
>
|
|
{
|
|
notificationPreferenceFields.map((field) => (
|
|
<label class="flex items-center justify-between rounded-md p-2 transition-colors duration-200 hover:bg-zinc-800">
|
|
<span class="flex items-center text-zinc-300">
|
|
<Icon name={field.icon} class="mr-2 size-5 text-zinc-400" />
|
|
{field.label}
|
|
</span>
|
|
<input
|
|
type="checkbox"
|
|
name={field.id}
|
|
checked={notificationPreferences?.[field.id]}
|
|
class="size-4 rounded border-zinc-700 bg-zinc-800 text-blue-600 focus:ring-blue-600"
|
|
/>
|
|
</label>
|
|
))
|
|
}
|
|
|
|
<div
|
|
class="mt-4 flex items-center justify-between rounded-md p-2 transition-colors duration-200 hover:bg-zinc-800"
|
|
>
|
|
<span class="flex items-center text-zinc-300">
|
|
<Icon name="ri:award-line" class="mr-2 size-5 text-zinc-400" />
|
|
Notify me when my karma changes by at least
|
|
</span>
|
|
<div class="flex items-center gap-2">
|
|
<input
|
|
type="number"
|
|
name="karmaNotificationThreshold"
|
|
value={notificationPreferences?.karmaNotificationThreshold}
|
|
min="1"
|
|
class="w-20 rounded border border-zinc-700 bg-zinc-800 px-2 py-1 text-zinc-200 focus:border-blue-600 focus:ring-1 focus:ring-blue-600 focus:outline-none"
|
|
/>
|
|
<span class="text-zinc-400">points</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-4 flex justify-end">
|
|
<Button type="submit" label="Save" icon="ri:save-line" color="success" />
|
|
</div>
|
|
</form>
|
|
|
|
<div
|
|
class="relative isolate mt-3 overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900 p-6 shadow-sm"
|
|
>
|
|
<div aria-hidden="true" class="pointer-events-none absolute inset-0 -z-10 overflow-hidden">
|
|
<div
|
|
class="absolute top-0 -left-16 h-full w-1/3 bg-gradient-to-r from-zinc-500/20 to-transparent opacity-50 blur-xl"
|
|
>
|
|
</div>
|
|
<div
|
|
class="absolute top-0 -right-16 h-full w-1/3 bg-gradient-to-l from-zinc-500/20 to-transparent opacity-50 blur-xl"
|
|
>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mb-4 flex items-center gap-3">
|
|
<div class="rounded-md bg-zinc-800 p-2">
|
|
<Icon name="ri:rss-line" class="size-6 text-zinc-300" />
|
|
</div>
|
|
<h3 class="font-title text-xl font-bold text-zinc-200">RSS feeds available</h3>
|
|
</div>
|
|
|
|
<div class="space-y-4">
|
|
<div>
|
|
<p class="mb-1 text-sm text-zinc-400">
|
|
Subscribe to receive your notifications in your favorite RSS reader.
|
|
</p>
|
|
<div class="flex items-center gap-2">
|
|
<input
|
|
type="text"
|
|
readonly
|
|
value={`${Astro.url.origin}/feeds/user/${user.feedId}/notifications.xml`}
|
|
class="flex-1 rounded border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-200 select-all"
|
|
/>
|
|
<CopyButton
|
|
copyText={`${Astro.url.origin}/feeds/user/${user.feedId}/notifications.xml`}
|
|
color="white"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<a
|
|
href="/feeds"
|
|
class="flex items-center justify-between rounded-lg border border-zinc-700/50 bg-zinc-800/30 p-3"
|
|
>
|
|
<div>
|
|
<h4 class="text-sm font-semibold text-zinc-300">Public RSS feeds</h4>
|
|
<p class="text-xs text-zinc-500">
|
|
Don't require an account to subscribe. Includes service comments and events.
|
|
</p>
|
|
</div>
|
|
<Button
|
|
as="span"
|
|
label="Browse all"
|
|
icon="ri:arrow-right-line"
|
|
variant="faded"
|
|
color="white"
|
|
class="pointer-events-none"
|
|
/>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</BaseLayout>
|
|
|
|
<script>
|
|
document.addEventListener('sse:new-notification', () => {
|
|
document.querySelectorAll<HTMLElement>('[data-new-notification-banner]').forEach((banner) => {
|
|
banner.style.display = ''
|
|
})
|
|
})
|
|
</script>
|