Compare commits

...

3 Commits

11 changed files with 529 additions and 108 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

@ -91,7 +91,7 @@
<div class="result-label"> <div class="result-label">
{{ actor.name }} {{ actor.name }}
<template v-if="actor.ageFromBirth || actor.origin?.country">({{ [actor.ageFromBirth, actor.origin?.country?.alpha2].join(', ') }})</template> <template v-if="actor.ageFromBirth || actor.origin?.country">({{ [actor.ageFromBirth, actor.origin?.country?.alpha2].filter(Boolean).join(', ') }})</template>
</div> </div>
</li> </li>
</ul> </ul>

View File

@ -78,69 +78,161 @@
</button> </button>
</form> </form>
<VDropdown <div
v-if="user" v-if="user"
:triggers="['click']" class="userpanel"
:prevent-overflow="true" :class="{ searching: searchFocused }"
> >
<div <VDropdown
class="userpanel" :triggers="['click']"
:class="{ searching: searchFocused }" :prevent-overflow="true"
class="notifs-trigger"
>
<button
type="button"
class="notifs-button"
:class="{ unseen: unseen > 0 }"
>
<Icon
icon="bell2"
class="notifs-bell"
/>
<span
v-if="unseen > 0"
class="notifs-unseen"
>{{ unseen }}</span>
</button>
<template #popper>
<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">
<li
v-for="notif in notifications"
:key="notif.id"
class="notif"
: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>
</div>
</template>
</VDropdown>
<VDropdown
:triggers="['click']"
:prevent-overflow="true"
> >
<img <img
:src="user.avatar" :src="user.avatar"
class="avatar" class="avatar"
> >
</div>
<template #popper> <template #popper>
<div class="menu"> <div class="menu">
<a <a
:href="`/user/${user.username}`" :href="`/user/${user.username}`"
class="menu-header ellipsis" class="menu-header ellipsis"
>{{ user.username }}</a> >{{ user.username }}</a>
<ul class="menu-list nolist"> <ul class="menu-list nolist">
<li class="menu-item"> <li class="menu-item">
<a <a
:href="`/user/${user.username}`" :href="`/user/${user.username}`"
class="menu-button nolink" class="menu-button nolink"
>
<Icon icon="user7" />
Profile
</a>
</li>
<li
v-if="user.primaryStash"
class="menu-item"
> >
<Icon icon="user7" /> <a
Profile :href="`/stash/${user.username}/${user.primaryStash.slug}`"
</a> class="menu-button nolink favorites"
</li> >
<Icon icon="heart7" />
Favorites
</a>
</li>
<li <li
v-if="user.primaryStash" v-close-popper
class="menu-item" class="menu-item menu-button"
> @click="showSettings = true"
<a
:href="`/stash/${user.username}/${user.primaryStash.slug}`"
class="menu-button nolink favorites"
> >
<Icon icon="heart7" /> <Icon icon="equalizer" />
Favorites Settings
</a> </li>
</li>
<li <li
v-close-popper class="menu-button menu-item logout"
class="menu-item menu-button" @click="logout"
@click="showSettings = true" ><Icon icon="exit2" />Log out</li>
> </ul>
<Icon icon="equalizer" /> </div>
Settings </template>
</li> </VDropdown>
</div>
<li
class="menu-button menu-item logout"
@click="logout"
><Icon icon="exit2" />Log out</li>
</ul>
</div>
</template>
</VDropdown>
<div <div
v-else-if="allowLogin" v-else-if="allowLogin"
@ -161,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>
@ -172,19 +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 = 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);
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 || ''}`;
@ -193,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 });
@ -316,10 +455,14 @@ function blurSearch(event) {
height: 100%; height: 100%;
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0 1rem 0 1.5rem; padding: 0 1rem 0 0;
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);
} }
@ -332,6 +475,158 @@ function blurSearch(event) {
object-fit: cover; object-fit: cover;
} }
.notifs-trigger {
height: 100%;
}
.notifs-button {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 0 1.25rem;
background: none;
border: none;
&:hover,
&.unseen {
cursor: pointer;
.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 {
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;
align-items: center;
width: 100%;
overflow: hidden;
&:not(:last-child) {
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 {
display: flex; display: flex;
align-items: center; align-items: center;

41
package-lock.json generated
View File

@ -1,11 +1,11 @@
{ {
"name": "traxxx-web", "name": "traxxx-web",
"version": "0.18.7", "version": "0.19.0",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"version": "0.18.7", "version": "0.19.0",
"dependencies": { "dependencies": {
"@brillout/json-serializer": "^0.5.8", "@brillout/json-serializer": "^0.5.8",
"@dicebear/collection": "^7.0.5", "@dicebear/collection": "^7.0.5",
@ -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",
@ -74,5 +75,5 @@
"postcss-custom-media": "^10.0.2", "postcss-custom-media": "^10.0.2",
"postcss-nesting": "^12.0.2" "postcss-nesting": "^12.0.2"
}, },
"version": "0.18.7" "version": "0.19.0"
} }

View File

@ -83,33 +83,57 @@
</button> </button>
</div> </div>
<table class="alerts"> <ul class="alerts nolist">
<tbody> <li
<tr class="alerts-headers"> v-for="alert in alerts"
<th class="alerts-header">Actors</th> :key="`alert-${alert.id}`"
<th class="alerts-header">Tags</th> class="alert"
<th class="alerts-header">Entities</th> >
<th class="alerts-header">Matches</th> <div class="alert-details">
</tr> <span
v-if="alert.tags.length > 0"
class="alert-tags"
><a
v-for="tag in alert.tags"
:key="`tag-${alert.id}-${tag.id}`"
:href="`/tag/${tag.slug}`"
class="link"
>{{ tag.name }}</a>&nbsp;</span>
<tr <span
v-for="alert in alerts" v-if="alert.actors.length > 0"
:key="`alert-${alert.id}`" class="alert-actors"
class="alert" >with <a
> v-for="actor in alert.actors"
<td class="alert-field">{{ alert.actors.map((actor) => actor.name).join(', ') }}</td> :key="`actor-${alert.id}-${actor.id}`"
<td class="alert-field">{{ alert.tags.map((tag) => tag.name).join(alert.and.tags ? ' + ' : ' | ') }}</td> :href="`/actor/${actor.id}/${actor.slug}`"
<td class="alert-field">{{ alert.entities.map((entity) => entity.name).join(', ') }}</td> class="link"
<td class="alert-field">{{ alert.matches.map((match) => match.expression).join(', ') }}</td> >{{ actor.name }}</a>&nbsp;</span>
<td class="alert-actions noselect">
<Icon <span
icon="bin" v-if="alert.entities.length > 0"
@click="removeAlert(alert)" class="alert-entities"
/> >for <a
</td> v-for="entity in alert.entities"
</tr> :key="`entity-${alert.id}-${entity.id}`"
</tbody> :href="`/${entity.type}/${entity.slug}`"
</table> 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="bin"
@click="removeAlert(alert)"
/>
</div>
</li>
</ul>
<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('out alerts', alerts);
if (!profile) { if (!profile) {
throw render(404, `Cannot find user '${pageContext.routeParams.username}'.`); throw render(404, `Cannot find user '${pageContext.routeParams.username}'.`);
} }

View File

@ -6,5 +6,6 @@ export default {
'urlParsed', 'urlParsed',
'env', 'env',
'user', 'user',
'notifications',
], ],
}; };

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,
@ -140,6 +145,63 @@ export async function removeAlert(alertId, reqUser) {
.delete(); .delete();
} }
export async function fetchNotifications(reqUser, options = {}) {
if (!reqUser) {
return [];
}
const notifications = await knex('notifications')
.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)
.groupBy('notifications.id', 'alerts.id')
.orderBy('created_at', 'desc');
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) { export async function updateNotification(notificationId, updatedNotification, reqUser) {
await knex('notifications') await knex('notifications')
.where('id', notificationId) .where('id', notificationId)

View File

@ -2,6 +2,7 @@ import {
fetchAlerts, fetchAlerts,
createAlert, createAlert,
removeAlert, removeAlert,
fetchNotifications,
updateNotifications, updateNotifications,
updateNotification, updateNotification,
} from '../alerts.js'; } from '../alerts.js';
@ -24,6 +25,14 @@ export async function removeAlertApi(req, res) {
res.status(204).send(); res.status(204).send();
} }
export async function fetchNotificationsApi(req, res) {
const notifications = await fetchNotifications(req.user, {
limit: req.query.limit || 10,
});
res.send(notifications);
}
export async function updateNotificationsApi(req, res) { export async function updateNotificationsApi(req, res) {
await updateNotifications(req.body, req.user); await updateNotifications(req.body, req.user);

View File

@ -47,10 +47,13 @@ import {
fetchAlertsApi, fetchAlertsApi,
createAlertApi, createAlertApi,
removeAlertApi, removeAlertApi,
fetchNotificationsApi,
updateNotificationApi, updateNotificationApi,
updateNotificationsApi, updateNotificationsApi,
} from './alerts.js'; } from './alerts.js';
import { fetchNotifications } from '../alerts.js';
import initLogger from '../logger.js'; import initLogger from '../logger.js';
const logger = initLogger(); const logger = initLogger();
@ -127,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);
@ -164,6 +168,8 @@ export default async function initServer() {
router.get('/api/tags', fetchTagsApi); router.get('/api/tags', fetchTagsApi);
router.get('*', async (req, res, next) => { router.get('*', async (req, res, next) => {
const notifications = await fetchNotifications(req.user, { limit: 20 });
const pageContextInit = { const pageContextInit = {
urlOriginal: req.originalUrl, urlOriginal: req.originalUrl,
urlQuery: req.query, // vike's own query does not apply boolean parser urlQuery: req.query, // vike's own query does not apply boolean parser
@ -185,6 +191,7 @@ export default async function initServer() {
maxAggregateSize: config.database.manticore.maxAggregateSize, maxAggregateSize: config.database.manticore.maxAggregateSize,
media: config.media, media: config.media,
}, },
notifications,
}; };
const pageContext = await renderPage(pageContextInit); const pageContext = await renderPage(pageContextInit);