From 9e18fb4455a5c7c5925467a27ffc2f73932d23dc Mon Sep 17 00:00:00 2001 From: DebaucheryLibrarian Date: Mon, 27 May 2024 03:06:54 +0200 Subject: [PATCH] Implemented notifications, simplified alerts overview. --- assets/img/icons/stack-check.svg | 4 + components/header/header.vue | 280 +++++++++++++++++++++-- package-lock.json | 37 ++- package.json | 1 + pages/users/@username/+Page.vue | 103 +++++---- pages/users/@username/+onBeforeRender.js | 2 - src/alerts.js | 72 ++++-- src/web/server.js | 2 + 8 files changed, 417 insertions(+), 84 deletions(-) create mode 100755 assets/img/icons/stack-check.svg diff --git a/assets/img/icons/stack-check.svg b/assets/img/icons/stack-check.svg new file mode 100755 index 0000000..694dc9c --- /dev/null +++ b/assets/img/icons/stack-check.svg @@ -0,0 +1,4 @@ + + + + diff --git a/components/header/header.vue b/components/header/header.vue index fc57bad..3c9aa60 100644 --- a/components/header/header.vue +++ b/components/header/header.vue @@ -86,21 +86,90 @@ - + @@ -184,6 +253,11 @@ v-if="showSettings" @close="showSettings = false" /> + + @@ -195,22 +269,26 @@ import { } from 'vue'; 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 AlertDialog from '#/components/alerts/create.vue'; import logo from '../../assets/img/logo.svg?raw'; // eslint-disable-line import/no-unresolved const pageContext = inject('pageContext'); 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 allowLogin = pageContext.env.allowLogin; const searchFocused = ref(false); const showSettings = ref(false); - -console.log(notifications); +const showAlertDialog = ref(false); const activePage = computed(() => pageContext.urlParsed.pathname.split('/')[1]); const currentPath = `${pageContext.urlParsed.pathnameOriginal}${pageContext.urlParsed.searchOriginal || ''}`; @@ -219,6 +297,41 @@ function search() { 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() { await del('/session'); navigate('/login', null, { redirect: true }); @@ -346,6 +459,10 @@ function blurSearch(event) { font-size: 0; cursor: pointer; + .button-submit { + margin-left: 1rem; + } + &:hover .avatar { box-shadow: 0 0 3px var(--shadow-weak-10); } @@ -358,29 +475,156 @@ function blurSearch(event) { object-fit: cover; } -.notifs-bell { - padding: 0 1.25rem; +.notifs-trigger { 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, - &.unread { + &.unseen { cursor: pointer; - fill: var(--primary); + + .icon { + 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 { - 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 { display: flex; - padding: .5rem 1rem; + align-items: center; + width: 100%; + overflow: hidden; &: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 { diff --git a/package-lock.json b/package-lock.json index 8b748b6..017ffba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "cross-env": "^7.0.3", "date-fns": "^3.0.0", "error-stack-parser": "^2.1.4", + "escape-string-regexp": "^5.0.0", "express": "^4.18.2", "express-promise-router": "^4.1.1", "express-query-boolean": "^2.0.0", @@ -4379,6 +4380,15 @@ "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": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -5065,12 +5075,14 @@ "integrity": "sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw==" }, "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, + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", "engines": { - "node": ">=0.8.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/eslint": { @@ -13355,6 +13367,14 @@ "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "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": { @@ -13881,10 +13901,9 @@ "integrity": "sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw==" }, "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 + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==" }, "eslint": { "version": "8.56.0", diff --git a/package.json b/package.json index cf0ff64..cfc528b 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "cross-env": "^7.0.3", "date-fns": "^3.0.0", "error-stack-parser": "^2.1.4", + "escape-string-regexp": "^5.0.0", "express": "^4.18.2", "express-promise-router": "^4.1.1", "express-query-boolean": "^2.0.0", diff --git a/pages/users/@username/+Page.vue b/pages/users/@username/+Page.vue index 7b3b972..fc47cf4 100644 --- a/pages/users/@username/+Page.vue +++ b/pages/users/@username/+Page.vue @@ -83,33 +83,57 @@ - - - - - - - - + - - - - - - - -
ActorsTagsEntitiesMatches
{{ alert.actors.map((actor) => actor.name).join(', ') }}{{ alert.tags.map((tag) => tag.name).join(alert.and.tags ? ' + ' : ' | ') }}{{ alert.entities.map((entity) => entity.name).join(', ') }}{{ alert.matches.map((match) => match.expression).join(', ') }} - -
+ with {{ actor.name }}  + + for {{ entity.name }}  + + matching {{ alert.matches.map((match) => match.expression).join(', ') }} + + +
+ +
+ + ({ 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_id: alertId, 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_id: alertId, @@ -141,25 +146,60 @@ export async function removeAlert(alertId, reqUser) { } export async function fetchNotifications(reqUser, options = {}) { + if (!reqUser) { + return []; + } + const notifications = await knex('notifications') - .select('notifications.*', 'alerts.id as alert_id', 'scenes.title as scene_title') - .leftJoin('releases as scenes', 'scenes.id', 'notifications.scene_id') + .select( + '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_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) - .limit(options.limit || 10) + .groupBy('notifications.id', 'alerts.id') .orderBy('created_at', 'desc'); - return notifications.map((notification) => ({ - id: notification.id, - sceneId: notification.scene_id, - scene: { - id: notification.scene_id, - title: notification.scene_title, - }, - alertId: notification.alert_id, - isSeen: notification.seen, - createdAt: notification.created_at, - })); + 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, + sceneId: notification.scene_id, + scene, + 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, + createdAt: notification.created_at, + }; + }); + + return { + notifications: curatedNotifications, + unseen, + }; } export async function updateNotification(notificationId, updatedNotification, reqUser) { diff --git a/src/web/server.js b/src/web/server.js index 0d3c125..1a0d280 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -47,6 +47,7 @@ import { fetchAlertsApi, createAlertApi, removeAlertApi, + fetchNotificationsApi, updateNotificationApi, updateNotificationsApi, } from './alerts.js'; @@ -129,6 +130,7 @@ export default async function initServer() { router.get('/api/users/:userId', fetchUserApi); 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/:notificationId', updateNotificationApi);