Implemented notifications, simplified alerts overview.

This commit is contained in:
DebaucheryLibrarian 2024-05-27 03:06:54 +02:00
parent 342ba6191e
commit 9e18fb4455
8 changed files with 417 additions and 84 deletions

View File

@ -0,0 +1,4 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path d="M5 1v1.155l-2.619 0.368 0.17 1.211-2.551 0.732 3.308 11.535 10.189-2.921 0.558-0.079h1.945v-12h-11zM3.929 14.879l-2.808-9.793 1.558-0.447 1.373 9.766 2.997-0.421-3.119 0.894zM4.822 13.382l-1.418-10.088 1.595-0.224v9.93h2.543l-2.721 0.382zM6 12v-10h9v2.5l-0.707-0.707-4.293 3.793-2.293-1.793-0.914 0.914 3.207 3.707 5-5.48v7.066h-9z"></path>
</svg>

After

Width:  |  Height:  |  Size: 488 B

View File

@ -86,21 +86,90 @@
<VDropdown <VDropdown
:triggers="['click']" :triggers="['click']"
:prevent-overflow="true" :prevent-overflow="true"
class="notifs-trigger"
>
<button
type="button"
class="notifs-button"
:class="{ unseen: unseen > 0 }"
> >
<Icon <Icon
icon="bell2" icon="bell2"
class="notifs-bell" class="notifs-bell"
:class="{ unread: notifications.some((notif) => !notif.isSeen) }"
/> />
<span
v-if="unseen > 0"
class="notifs-unseen"
>{{ unseen }}</span>
</button>
<template #popper> <template #popper>
<div class="menu"> <div class="menu">
<div class="notifs-header">
<h4 class="notifs-heading">Notifications</h4>
<div
class="notifs-actions"
>
<Icon
icon="stack-check"
@click="markAllSeen"
/>
<Icon
v-close-popper
icon="plus3"
@click="showAlertDialog = true"
/>
</div>
</div>
<ul class="notifs nolist"> <ul class="notifs nolist">
<li <li
v-for="notif in notifications" v-for="notif in notifications"
:key="notif.id" :key="notif.id"
class="notif" class="notif"
>{{ notif.scene.title }}</li> :class="{ unseen: !notif.isSeen }"
@click="markSeen(notif)"
>
<a
:href="`/scene/${notif.scene.id}/${notif.scene.slug}`"
target="_blank"
class="notif-body notif-link nolink"
>
<span class="notif-details">
New
<span
v-if="notif.matchedTags.length > 0"
class="notif-tags"
>{{ notif.matchedTags.map((tag) => tag.name).join(', ') }}</span>
scene
<span
v-if="notif.matchedActors.length > 0"
class="notif-actors"
>with {{ notif.matchedActors.map((actor) => actor.name).join(', ') }}</span>
<span
v-if="notif.matchedEntity"
class="notif-entities"
>for {{ notif.matchedEntity.name }}</span>
<span
v-if="notif.matchedExpressions.length > 0"
class="notif-entities"
>matching {{ notif.matchedExpressions.map((match) => match.expression).join(', ') }}</span>
</span>
<span class="notif-scene">
<span class="notif-date">{{ formatDate(notif.scene.effectiveDate, 'MMM d') }}</span>
<span class="notif-title ellipsis">{{ notif.scene.title }}</span>
</span>
</a>
</li>
</ul> </ul>
</div> </div>
</template> </template>
@ -184,6 +253,11 @@
v-if="showSettings" v-if="showSettings"
@close="showSettings = false" @close="showSettings = false"
/> />
<AlertDialog
v-if="showAlertDialog"
@close="showAlertDialog = false"
/>
</header> </header>
</template> </template>
@ -195,22 +269,26 @@ import {
} from 'vue'; } from 'vue';
import navigate from '#/src/navigate.js'; import navigate from '#/src/navigate.js';
import { del } from '#/src/api.js'; import { get, patch, del } from '#/src/api.js';
import { formatDate } from '#/utils/format.js';
// import getPath from '#/src/get-path.js';
import Settings from '#/components/settings/settings.vue'; import Settings from '#/components/settings/settings.vue';
import AlertDialog from '#/components/alerts/create.vue';
import logo from '../../assets/img/logo.svg?raw'; // eslint-disable-line import/no-unresolved import logo from '../../assets/img/logo.svg?raw'; // eslint-disable-line import/no-unresolved
const pageContext = inject('pageContext'); const pageContext = inject('pageContext');
const user = pageContext.user; const user = pageContext.user;
const notifications = pageContext.notifications; const notifications = ref(pageContext.notifications.notifications);
const unseen = ref(pageContext.notifications.unseen);
const done = ref(true);
const query = ref(pageContext.urlParsed.search.q || ''); const query = ref(pageContext.urlParsed.search.q || '');
const allowLogin = pageContext.env.allowLogin; const allowLogin = pageContext.env.allowLogin;
const searchFocused = ref(false); const searchFocused = ref(false);
const showSettings = ref(false); const showSettings = ref(false);
const showAlertDialog = ref(false);
console.log(notifications);
const activePage = computed(() => pageContext.urlParsed.pathname.split('/')[1]); const activePage = computed(() => pageContext.urlParsed.pathname.split('/')[1]);
const currentPath = `${pageContext.urlParsed.pathnameOriginal}${pageContext.urlParsed.searchOriginal || ''}`; const currentPath = `${pageContext.urlParsed.pathnameOriginal}${pageContext.urlParsed.searchOriginal || ''}`;
@ -219,6 +297,41 @@ function search() {
navigate('/search', { q: query.value }, { redirect: true }); navigate('/search', { q: query.value }, { redirect: true });
} }
async function fetchNotifications() {
const res = await get(`/users/${user?.id}/notifications`);
notifications.value = res.notifications;
unseen.value = res.unseen;
}
async function markSeen(notif) {
if (notif.isSeen || !done.value) {
return;
}
done.value = false;
await patch(`/users/${user?.id}/notifications/${notif.id}`, {
seen: true,
});
await fetchNotifications();
done.value = true;
}
async function markAllSeen() {
done.value = false;
await patch(`/users/${user?.id}/notifications`, {
seen: true,
});
await fetchNotifications();
done.value = true;
}
async function logout() { async function logout() {
await del('/session'); await del('/session');
navigate('/login', null, { redirect: true }); navigate('/login', null, { redirect: true });
@ -346,6 +459,10 @@ function blurSearch(event) {
font-size: 0; font-size: 0;
cursor: pointer; cursor: pointer;
.button-submit {
margin-left: 1rem;
}
&:hover .avatar { &:hover .avatar {
box-shadow: 0 0 3px var(--shadow-weak-10); box-shadow: 0 0 3px var(--shadow-weak-10);
} }
@ -358,29 +475,156 @@ function blurSearch(event) {
object-fit: cover; object-fit: cover;
} }
.notifs-bell { .notifs-trigger {
padding: 0 1.25rem;
height: 100%; height: 100%;
fill: var(--shadow); }
.notifs-button {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 0 1.25rem;
background: none;
border: none;
&:hover, &:hover,
&.unread { &.unseen {
cursor: pointer; cursor: pointer;
.icon {
fill: var(--primary); fill: var(--primary);
} }
}
}
.notifs-bell {
margin-bottom: .1rem;
}
.notifs-unseen {
bottom: 0;
font-size: .65rem;
font-weight: bold;
color: var(--primary);
}
.notifs-bell {
fill: var(--shadow);
} }
.notifs { .notifs {
overflow: auto; width: 30rem;
height: 20rem;
max-width: 100%;
max-height: 100%;
overflow-y: auto;
overflow-x: hidden;
}
.notifs-header {
display: flex;
justify-content: space-between;
box-shadow: inset 0 0 3px var(--shadow-weak-30);
.icon {
fill: var(--shadow-strong-10);
&:hover {
fill: var(--primary);
cursor: pointer;
}
}
}
.notifs-heading {
font-size: 1rem;
padding: .75rem 1rem;
margin: 0;
font-weight: normal;
}
.notifs-actions {
align-items: stretch;
.icon {
height: 100%;
padding: 0 .75rem;
&:last-child {
padding-right: 1rem;
}
}
} }
.notif { .notif {
display: flex; display: flex;
padding: .5rem 1rem; align-items: center;
width: 100%;
overflow: hidden;
&:not(:last-child) { &:not(:last-child) {
border-bottom: solid 1px var(--shadow-weak-30); border-bottom: solid 1px var(--shadow-weak-40);
} }
&:before {
width: 2.5rem;
flex-shrink: 0;
content: '•';
color: var(--shadow-weak-20);
text-align: center;
}
&.unseen:before {
color: var(--primary);
}
&.unseen:hover:before {
content: '✓';
}
}
.notif-body {
display: flex;
flex-direction: column;
flex-grow: 1;
overflow: hidden;
box-sizing: border-box;
font-size: .9rem;
}
.notif-details {
padding: .5rem 0 .15rem 0;
box-sizing: border-box;
color: var(--shadow-strong);
font-weight: bold;
}
.notif-link {
overflow: hidden;
}
.notif-scene {
display: flex;
padding: .15rem 0 .5rem 0;
}
.notif-date {
flex-shrink: 0;
color: var(--shadow-strong-10);
&:after {
content: '•';
padding: 0 .5rem;
color: var(--shadow-weak-20);
}
}
.notif-thumb {
width: 5rem;
height: 3rem;
object-fit: cover;
} }
.login { .login {

37
package-lock.json generated
View File

@ -29,6 +29,7 @@
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"date-fns": "^3.0.0", "date-fns": "^3.0.0",
"error-stack-parser": "^2.1.4", "error-stack-parser": "^2.1.4",
"escape-string-regexp": "^5.0.0",
"express": "^4.18.2", "express": "^4.18.2",
"express-promise-router": "^4.1.1", "express-promise-router": "^4.1.1",
"express-query-boolean": "^2.0.0", "express-query-boolean": "^2.0.0",
@ -4379,6 +4380,15 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/chalk/node_modules/escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
"integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
"dev": true,
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/chokidar": { "node_modules/chokidar": {
"version": "3.5.3", "version": "3.5.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
@ -5065,12 +5075,14 @@
"integrity": "sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw==" "integrity": "sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw=="
}, },
"node_modules/escape-string-regexp": { "node_modules/escape-string-regexp": {
"version": "1.0.5", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
"integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
"dev": true,
"engines": { "engines": {
"node": ">=0.8.0" "node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/eslint": { "node_modules/eslint": {
@ -13355,6 +13367,14 @@
"ansi-styles": "^3.2.1", "ansi-styles": "^3.2.1",
"escape-string-regexp": "^1.0.5", "escape-string-regexp": "^1.0.5",
"supports-color": "^5.3.0" "supports-color": "^5.3.0"
},
"dependencies": {
"escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
"integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
"dev": true
}
} }
}, },
"chokidar": { "chokidar": {
@ -13881,10 +13901,9 @@
"integrity": "sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw==" "integrity": "sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw=="
}, },
"escape-string-regexp": { "escape-string-regexp": {
"version": "1.0.5", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
"integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="
"dev": true
}, },
"eslint": { "eslint": {
"version": "8.56.0", "version": "8.56.0",

View File

@ -29,6 +29,7 @@
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"date-fns": "^3.0.0", "date-fns": "^3.0.0",
"error-stack-parser": "^2.1.4", "error-stack-parser": "^2.1.4",
"escape-string-regexp": "^5.0.0",
"express": "^4.18.2", "express": "^4.18.2",
"express-promise-router": "^4.1.1", "express-promise-router": "^4.1.1",
"express-query-boolean": "^2.0.0", "express-query-boolean": "^2.0.0",

View File

@ -83,33 +83,57 @@
</button> </button>
</div> </div>
<table class="alerts"> <ul class="alerts nolist">
<tbody> <li
<tr class="alerts-headers">
<th class="alerts-header">Actors</th>
<th class="alerts-header">Tags</th>
<th class="alerts-header">Entities</th>
<th class="alerts-header">Matches</th>
</tr>
<tr
v-for="alert in alerts" v-for="alert in alerts"
:key="`alert-${alert.id}`" :key="`alert-${alert.id}`"
class="alert" class="alert"
> >
<td class="alert-field">{{ alert.actors.map((actor) => actor.name).join(', ') }}</td> <div class="alert-details">
<td class="alert-field">{{ alert.tags.map((tag) => tag.name).join(alert.and.tags ? ' + ' : ' | ') }}</td> <span
<td class="alert-field">{{ alert.entities.map((entity) => entity.name).join(', ') }}</td> v-if="alert.tags.length > 0"
<td class="alert-field">{{ alert.matches.map((match) => match.expression).join(', ') }}</td> class="alert-tags"
<td class="alert-actions noselect"> ><a
v-for="tag in alert.tags"
:key="`tag-${alert.id}-${tag.id}`"
:href="`/tag/${tag.slug}`"
class="link"
>{{ tag.name }}</a>&nbsp;</span>
<span
v-if="alert.actors.length > 0"
class="alert-actors"
>with <a
v-for="actor in alert.actors"
:key="`actor-${alert.id}-${actor.id}`"
:href="`/actor/${actor.id}/${actor.slug}`"
class="link"
>{{ actor.name }}</a>&nbsp;</span>
<span
v-if="alert.entities.length > 0"
class="alert-entities"
>for <a
v-for="entity in alert.entities"
:key="`entity-${alert.id}-${entity.id}`"
:href="`/${entity.type}/${entity.slug}`"
class="link"
>{{ entity.name }}</a>&nbsp;</span>
<span
v-if="alert.matches.length > 0"
class="alert-entities"
>matching {{ alert.matches.map((match) => match.expression).join(', ') }}</span>
</div>
<div class="alert-actions">
<Icon <Icon
icon="bin" icon="bin"
@click="removeAlert(alert)" @click="removeAlert(alert)"
/> />
</td> </div>
</tr> </li>
</tbody> </ul>
</table>
<AlertDialog <AlertDialog
v-if="showAlertDialog" v-if="showAlertDialog"
@ -283,34 +307,35 @@ async function removeAlert(alert) {
.alerts { .alerts {
width: 100%; width: 100%;
height: 0;
} }
.alert { .alert {
&:nth-child(odd) { display: flex;
background: var(--shadow-weak-40); align-items: stretch;
&:not(:last-child) {
border-bottom: solid 1px var(--shadow-weak-40);
} }
&:hover { &:hover {
background: var(--shadow-weak-30); background: var(--shadow-weak-40);
} }
} }
.alerts-header { .alert-details {
text-align: left; padding: .5rem 1rem;
} flex-grow: 1;
line-height: 1.5;
.alerts-header, .link {
.alert-field { color: inherit;
padding: .25rem .5rem; }
} }
.alert-actions { .alert-actions {
height: 100%;
.icon { .icon {
height: 100%; height: 100%;
padding: 0 .25rem; padding: 0 .5rem;
fill: var(--shadow); fill: var(--shadow);
&:hover { &:hover {

View File

@ -11,8 +11,6 @@ export async function onBeforeRender(pageContext) {
: [], : [],
]); ]);
console.log(pageContext.notifications);
if (!profile) { if (!profile) {
throw render(404, `Cannot find user '${pageContext.routeParams.username}'.`); throw render(404, `Cannot find user '${pageContext.routeParams.username}'.`);
} }

View File

@ -1,4 +1,7 @@
import escapeRegexp from 'escape-string-regexp';
import { knexOwner as knex } from './knex.js'; import { knexOwner as knex } from './knex.js';
import { fetchScenesById } from './scenes.js';
import promiseProps from '../utils/promise-props.js'; import promiseProps from '../utils/promise-props.js';
import { HttpError } from './errors.js'; import { HttpError } from './errors.js';
@ -29,7 +32,7 @@ function curateAlert(alert, context = {}) {
id: entity.entity_id, id: entity.entity_id,
name: entity.entity_name, name: entity.entity_name,
slug: entity.entity_slug, slug: entity.entity_slug,
type: entity.type, type: entity.entity_type,
})) || [], })) || [],
matches: context.matches?.map((match) => ({ matches: context.matches?.map((match) => ({
id: match.id, id: match.id,
@ -118,7 +121,9 @@ export async function createAlert(alert, reqUser) {
alert.matches?.length > 0 && knex('alerts_matches').insert(alert.matches.map((match) => ({ alert.matches?.length > 0 && knex('alerts_matches').insert(alert.matches.map((match) => ({
alert_id: alertId, alert_id: alertId,
property: match.property, property: match.property,
expression: match.expression, expression: /\/.*\//.test(match.expression)
? match.expression.slice(1, -1)
: escapeRegexp(match.expression),
}))), }))),
alert.stashes?.length > 0 && knex('alerts_stashes').insert(alert.stashes.map((stashId) => ({ alert.stashes?.length > 0 && knex('alerts_stashes').insert(alert.stashes.map((stashId) => ({
alert_id: alertId, alert_id: alertId,
@ -141,25 +146,60 @@ export async function removeAlert(alertId, reqUser) {
} }
export async function fetchNotifications(reqUser, options = {}) { export async function fetchNotifications(reqUser, options = {}) {
if (!reqUser) {
return [];
}
const notifications = await knex('notifications') const notifications = await knex('notifications')
.select('notifications.*', 'alerts.id as alert_id', 'scenes.title as scene_title') .select(
.leftJoin('releases as scenes', 'scenes.id', 'notifications.scene_id') 'notifications.*',
'alerts.id as alert_id',
knex.raw('coalesce(array_agg(alerts_actors.actor_id) filter (where alerts_actors.id is not null), \'{}\') as alert_actors'),
knex.raw('coalesce(array_agg(alerts_tags.tag_id) filter (where alerts_tags.id is not null), \'{}\') as alert_tags'),
knex.raw('coalesce(array_agg(alerts_entities.entity_id) filter (where alerts_entities.id is not null), \'{}\') as alert_entities'),
knex.raw('coalesce(json_agg(alerts_matches) filter (where alerts_matches.id is not null), \'[]\') as alert_matches'),
)
.leftJoin('alerts', 'alerts.id', 'notifications.alert_id') .leftJoin('alerts', 'alerts.id', 'notifications.alert_id')
.leftJoin('alerts_actors', 'alerts_actors.alert_id', 'alerts.id')
.leftJoin('alerts_tags', 'alerts_tags.alert_id', 'alerts.id')
.leftJoin('alerts_entities', 'alerts_entities.alert_id', 'alerts.id')
.leftJoin('alerts_matches', 'alerts_matches.alert_id', 'alerts.id')
.where('notifications.user_id', reqUser.id) .where('notifications.user_id', reqUser.id)
.limit(options.limit || 10) .groupBy('notifications.id', 'alerts.id')
.orderBy('created_at', 'desc'); .orderBy('created_at', 'desc');
return notifications.map((notification) => ({ const scenes = await fetchScenesById(notifications.map((notification) => notification.scene_id));
const unseen = notifications.filter((notification) => !notification.seen).length;
const curatedNotifications = notifications
.slice(0, options.limit || 10)
.map((notification) => {
const scene = scenes.find((sceneX) => sceneX.id === notification.scene_id);
return {
id: notification.id, id: notification.id,
sceneId: notification.scene_id, sceneId: notification.scene_id,
scene: { scene,
id: notification.scene_id,
title: notification.scene_title,
},
alertId: notification.alert_id, alertId: notification.alert_id,
matchedActors: scene.actors.filter((actor) => notification.alert_actors.includes(actor.id)),
matchedTags: scene.tags.filter((tag) => notification.alert_tags.includes(tag.id)),
matchedEntity: [scene.channel, scene.network].find((entity) => notification.alert_entities.includes(entity?.id)) || null,
matchedExpressions: notification.alert_matches
.filter((match) => new RegExp(match.expression, 'ui').test(scene[match.property]))
.map((match) => ({
id: match.id,
property: match.property,
expression: match.expression,
})),
isSeen: notification.seen, isSeen: notification.seen,
createdAt: notification.created_at, createdAt: notification.created_at,
})); };
});
return {
notifications: curatedNotifications,
unseen,
};
} }
export async function updateNotification(notificationId, updatedNotification, reqUser) { export async function updateNotification(notificationId, updatedNotification, reqUser) {

View File

@ -47,6 +47,7 @@ import {
fetchAlertsApi, fetchAlertsApi,
createAlertApi, createAlertApi,
removeAlertApi, removeAlertApi,
fetchNotificationsApi,
updateNotificationApi, updateNotificationApi,
updateNotificationsApi, updateNotificationsApi,
} from './alerts.js'; } from './alerts.js';
@ -129,6 +130,7 @@ export default async function initServer() {
router.get('/api/users/:userId', fetchUserApi); router.get('/api/users/:userId', fetchUserApi);
router.post('/api/users', signupApi); router.post('/api/users', signupApi);
router.get('/api/users/:userId/notifications', fetchNotificationsApi);
router.patch('/api/users/:userId/notifications', updateNotificationsApi); router.patch('/api/users/:userId/notifications', updateNotificationsApi);
router.patch('/api/users/:userId/notifications/:notificationId', updateNotificationApi); router.patch('/api/users/:userId/notifications/:notificationId', updateNotificationApi);