feat(frontend): notifications
This commit is contained in:
		
							parent
							
								
									f99d10ecf0
								
							
						
					
					
						commit
						dd9d35249c
					
				
					 9 changed files with 129 additions and 6 deletions
				
			
		|  | @ -112,3 +112,12 @@ export enum ClearableField { | |||
| 	Flags = "FLAGS", | ||||
| 	CustomPreferences = "CUSTOM_PREFERENCES", | ||||
| } | ||||
| 
 | ||||
| export type Notification = { | ||||
| 	id: string; | ||||
| 	type: "NOTICE" | "WARNING" | "SUSPENSION"; | ||||
| 	message?: string; | ||||
| 	localization_key?: string; | ||||
| 	localization_params: Record<string, string>; | ||||
| 	acknowledged: boolean; | ||||
| }; | ||||
|  |  | |||
|  | @ -13,13 +13,21 @@ | |||
| 	import Logo from "$components/Logo.svelte"; | ||||
| 	import { t } from "$lib/i18n"; | ||||
| 
 | ||||
| 	type Props = { user: MeUser | null; meta: Meta }; | ||||
| 	let { user, meta }: Props = $props(); | ||||
| 	type Props = { user: MeUser | null; meta: Meta; unreadNotifications?: boolean }; | ||||
| 	let { user, meta, unreadNotifications }: Props = $props(); | ||||
| 
 | ||||
| 	let isOpen = $state(true); | ||||
| 	const toggleMenu = () => (isOpen = !isOpen); | ||||
| </script> | ||||
| 
 | ||||
| {#if user && unreadNotifications} | ||||
| 	<div class="notification-alert text-center py-3 mb-2 px-2"> | ||||
| 		<strong>{$t("nav.unread-notification-text")}</strong> | ||||
| 		<br /> | ||||
| 		<a href="/settings/notifications">{$t("nav.unread-notification-link")}</a> | ||||
| 	</div> | ||||
| {/if} | ||||
| 
 | ||||
| {#if user && user.deleted} | ||||
| 	<div class="deleted-alert text-center py-3 mb-2 px-2"> | ||||
| 		{#if user.suspended} | ||||
|  | @ -87,6 +95,11 @@ | |||
| 		background-color: var(--bs-danger-bg-subtle); | ||||
| 	} | ||||
| 
 | ||||
| 	.notification-alert { | ||||
| 		color: var(--bs-warning-text-emphasis); | ||||
| 		background-color: var(--bs-warning-bg-subtle); | ||||
| 	} | ||||
| 
 | ||||
| 	/* These exact values make it look almost identical to the SVG version, which is what we want */ | ||||
| 	#beta-text { | ||||
| 		font-size: 0.7em; | ||||
|  |  | |||
|  | @ -0,0 +1,43 @@ | |||
| <script lang="ts"> | ||||
| 	import type { Notification } from "$api/models/moderation"; | ||||
| 	import { t } from "$lib/i18n"; | ||||
| 	import InfoCircleFill from "svelte-bootstrap-icons/lib/InfoCircleFill.svelte"; | ||||
| 	import ExclamationTriangleFill from "svelte-bootstrap-icons/lib/ExclamationTriangleFill.svelte"; | ||||
| 	import XOctagonFill from "svelte-bootstrap-icons/lib/XOctagonFill.svelte"; | ||||
| 	import QuestionCircleFill from "svelte-bootstrap-icons/lib/QuestionCircleFill.svelte"; | ||||
| 	import { idTimestamp } from "$lib"; | ||||
| 	import { DateTime } from "luxon"; | ||||
| 
 | ||||
| 	type Props = { notification: Notification }; | ||||
| 	let { notification }: Props = $props(); | ||||
| 
 | ||||
| 	let Icon = $derived.by(() => { | ||||
| 		if (notification.type === "NOTICE") return InfoCircleFill; | ||||
| 		if (notification.type === "WARNING") return ExclamationTriangleFill; | ||||
| 		if (notification.type === "SUSPENSION") return XOctagonFill; | ||||
| 		return QuestionCircleFill; | ||||
| 	}); | ||||
| </script> | ||||
| 
 | ||||
| <div class="card mb-2"> | ||||
| 	<div class="card-body"> | ||||
| 		<div class="d-flex"> | ||||
| 			<div aria-hidden="true"> | ||||
| 				<Icon width={48} height={48} /> | ||||
| 			</div> | ||||
| 			<div class="mx-3"> | ||||
| 				<p class="card-text text-has-newline"> | ||||
| 					{#if notification.localization_key} | ||||
| 						{$t(notification.localization_key, notification.localization_params)} | ||||
| 					{:else} | ||||
| 						{notification.message} | ||||
| 					{/if} | ||||
| 				</p> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	</div> | ||||
| 	<div class="card-footer text-body-secondary"> | ||||
| 		{idTimestamp(notification.id).toLocaleString(DateTime.DATETIME_MED)} | ||||
| 		• <a href="/settings/notifications/ack/{notification.id}">Mark as read</a> | ||||
| 	</div> | ||||
| </div> | ||||
|  | @ -9,7 +9,9 @@ | |||
| 		"reactivate-account-link": "Reactivate account", | ||||
| 		"delete-permanently-link": "I want my account deleted permanently", | ||||
| 		"reactivate-or-delete-link": "I want to reactivate my account or delete all my data", | ||||
| 		"export-link": "I want to export a copy of my data" | ||||
| 		"export-link": "I want to export a copy of my data", | ||||
| 		"unread-notification-text": "You have an unread notification.", | ||||
| 		"unread-notification-link": "Go to your notifications" | ||||
| 	}, | ||||
| 	"avatar-tooltip": "Avatar for {{name}}", | ||||
| 	"profile": { | ||||
|  | @ -329,7 +331,9 @@ | |||
| 	}, | ||||
| 	"alert": { | ||||
| 		"auth-method-remove-success": "Successfully unlinked account!", | ||||
| 		"auth-required": "You must log in to access this page." | ||||
| 		"auth-required": "You must log in to access this page.", | ||||
| 		"notif-ack-successful": "Successfully marked notification as read!", | ||||
| 		"notif-ack-fail": "Could not mark notification as read." | ||||
| 	}, | ||||
| 	"footer": { | ||||
| 		"version": "Version", | ||||
|  | @ -342,5 +346,10 @@ | |||
| 		"changelog": "Changelog", | ||||
| 		"donate": "Donate", | ||||
| 		"about-contact": "About and contact" | ||||
| 	}, | ||||
| 	"notification": { | ||||
| 		"suspension": "Your account has been suspended for the following reason: {{reason}}", | ||||
| 		"warning": "You have been warned for the following reason: {{reason}}", | ||||
| 		"warning-cleared-fields": "You have been warned for the following reason: {{reason}}\n\nAdditionally, the following fields have been cleared from your profile:\n{{clearedFields}}" | ||||
| 	} | ||||
| } | ||||
|  |  | |||
|  | @ -2,16 +2,24 @@ import { clearToken, TOKEN_COOKIE_NAME } from "$lib"; | |||
| import { apiRequest } from "$api"; | ||||
| import ApiError, { ErrorCode } from "$api/error"; | ||||
| import type { Meta, MeUser } from "$api/models"; | ||||
| import type { Notification } from "$api/models/moderation"; | ||||
| import log from "$lib/log"; | ||||
| import type { LayoutServerLoad } from "./$types"; | ||||
| 
 | ||||
| export const load = (async ({ fetch, cookies }) => { | ||||
| 	let token: string | null = null; | ||||
| 	let meUser: MeUser | null = null; | ||||
| 	let unreadNotifications: boolean = false; | ||||
| 	if (cookies.get(TOKEN_COOKIE_NAME)) { | ||||
| 		try { | ||||
| 			meUser = await apiRequest<MeUser>("GET", "/users/@me", { fetch, cookies }); | ||||
| 			token = cookies.get(TOKEN_COOKIE_NAME) || null; | ||||
| 
 | ||||
| 			const notifications = await apiRequest<Notification[]>("GET", "/notifications", { | ||||
| 				fetch, | ||||
| 				cookies, | ||||
| 			}); | ||||
| 			unreadNotifications = notifications.filter((n) => !n.acknowledged).length > 0; | ||||
| 		} catch (e) { | ||||
| 			if (e instanceof ApiError && e.code === ErrorCode.AuthenticationRequired) clearToken(cookies); | ||||
| 			else log.error("Could not fetch /users/@me and token has not expired:", e); | ||||
|  | @ -19,5 +27,5 @@ export const load = (async ({ fetch, cookies }) => { | |||
| 	} | ||||
| 
 | ||||
| 	const meta = await apiRequest<Meta>("GET", "/meta", { fetch, cookies }); | ||||
| 	return { meta, meUser, token }; | ||||
| 	return { meta, meUser, token, unreadNotifications }; | ||||
| }) satisfies LayoutServerLoad; | ||||
|  |  | |||
|  | @ -11,7 +11,7 @@ | |||
| 
 | ||||
| <div class="d-flex flex-column min-vh-100"> | ||||
| 	<div class="flex-grow-1"> | ||||
| 		<Navbar user={data.meUser} meta={data.meta} /> | ||||
| 		<Navbar user={data.meUser} meta={data.meta} unreadNotifications={data.unreadNotifications} /> | ||||
| 		{@render children?.()} | ||||
| 	</div> | ||||
| 	<Footer meta={data.meta} /> | ||||
|  |  | |||
|  | @ -0,0 +1,11 @@ | |||
| import { apiRequest } from "$api"; | ||||
| import type { Notification } from "$api/models/moderation"; | ||||
| import { alertKey } from "$lib"; | ||||
| 
 | ||||
| export const load = async ({ url, fetch, cookies }) => { | ||||
| 	const notifications = await apiRequest<Notification[]>("GET", "/notifications", { | ||||
| 		fetch, | ||||
| 		cookies, | ||||
| 	}); | ||||
| 	return { notifications, alertKey: alertKey(url) }; | ||||
| }; | ||||
|  | @ -0,0 +1,16 @@ | |||
| <script lang="ts"> | ||||
| 	import type { PageData } from "./$types"; | ||||
| 	import Notification from "$components/settings/Notification.svelte"; | ||||
| 	import UrlAlert from "$components/URLAlert.svelte"; | ||||
| 
 | ||||
| 	type Props = { data: PageData }; | ||||
| 	let { data }: Props = $props(); | ||||
| </script> | ||||
| 
 | ||||
| <UrlAlert {data} /> | ||||
| 
 | ||||
| {#each data.notifications as notification (notification.id)} | ||||
| 	<Notification {notification} /> | ||||
| {:else} | ||||
| 	You have no notifications. | ||||
| {/each} | ||||
|  | @ -0,0 +1,14 @@ | |||
| import { redirect } from "@sveltejs/kit"; | ||||
| import type { RequestHandler } from "./$types"; | ||||
| import { fastRequest } from "$api"; | ||||
| import log from "$lib/log"; | ||||
| 
 | ||||
| export const GET: RequestHandler = async ({ params, fetch, cookies }) => { | ||||
| 	try { | ||||
| 		await fastRequest("PUT", `/notifications/${params.id}/ack`, { fetch, cookies }); | ||||
| 	} catch (e) { | ||||
| 		log.error("error acking notification %s:", params.id, e); | ||||
| 		redirect(303, "/settings/notifications?alert=notif-ack-fail"); | ||||
| 	} | ||||
| 	redirect(303, "/settings/notifications?alert=notif-ack-successful"); | ||||
| }; | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue