Added remaining elements to alert dialog.

This commit is contained in:
DebaucheryLibrarian 2024-05-19 05:07:35 +02:00
parent 715e5ac58a
commit 014758241c
22 changed files with 1210 additions and 30 deletions

4
assets/img/icons/link2.svg Executable file
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 11c0.275 0.275 0.739 0.261 1.030-0.030l4.939-4.939c0.292-0.292 0.305-0.755 0.030-1.030s-0.739-0.261-1.030 0.030l-4.939 4.939c-0.292 0.292-0.305 0.755-0.030 1.030zM7.451 10.549c0.071 0.141 0.109 0.299 0.109 0.464 0 0.275-0.105 0.532-0.297 0.724l-2.553 2.553c-0.191 0.191-0.448 0.297-0.724 0.297s-0.532-0.105-0.724-0.297l-1.553-1.553c-0.191-0.191-0.297-0.448-0.297-0.724s0.105-0.532 0.297-0.724l2.553-2.553c0.191-0.191 0.448-0.297 0.724-0.297 0.165 0 0.323 0.038 0.464 0.109l1.021-1.021c-0.435-0.335-0.96-0.502-1.485-0.502-0.625 0-1.25 0.237-1.724 0.711l-2.553 2.553c-0.948 0.948-0.948 2.499 0 3.447l1.553 1.553c0.474 0.474 1.099 0.711 1.724 0.711s1.25-0.237 1.724-0.711l2.553-2.553c0.872-0.872 0.941-2.255 0.209-3.209l-1.021 1.021zM15.289 2.264l-1.553-1.553c-0.474-0.474-1.099-0.711-1.724-0.711s-1.25 0.237-1.724 0.711l-2.553 2.553c-0.872 0.872-0.941 2.255-0.208 3.208l1.021-1.021c-0.071-0.141-0.109-0.299-0.109-0.464 0-0.275 0.105-0.532 0.297-0.724l2.553-2.553c0.191-0.191 0.448-0.297 0.724-0.297s0.532 0.105 0.724 0.297l1.553 1.553c0.191 0.191 0.297 0.448 0.297 0.724s-0.105 0.532-0.297 0.724l-2.553 2.553c-0.191 0.191-0.448 0.297-0.724 0.297-0.165 0-0.323-0.038-0.464-0.109l-1.021 1.021c0.435 0.335 0.96 0.502 1.485 0.502 0.625 0 1.25-0.237 1.724-0.711l2.553-2.553c0.948-0.948 0.948-2.499 0-3.447z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

6
assets/img/icons/link3.svg Executable file
View File

@ -0,0 +1,6 @@
<!-- 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="M4.5 16c-2.481 0-4.5-2.019-4.5-4.5s2.019-4.5 4.5-4.5c0.552 0 1 0.448 1 1s-0.448 1-1 1c-1.378 0-2.5 1.121-2.5 2.5s1.122 2.5 2.5 2.5 2.5-1.121 2.5-2.5c0-0.552 0.448-1 1-1s1 0.448 1 1c0 2.481-2.019 4.5-4.5 4.5z"></path>
<path d="M11.5 9c-0.552 0-1-0.448-1-1s0.448-1 1-1c1.379 0 2.5-1.122 2.5-2.5s-1.121-2.5-2.5-2.5-2.5 1.122-2.5 2.5c0 0.552-0.448 1-1 1s-1-0.448-1-1c0-2.481 2.019-4.5 4.5-4.5s4.5 2.019 4.5 4.5-2.019 4.5-4.5 4.5z"></path>
<path d="M6 11c-0.256 0-0.512-0.098-0.707-0.293-0.391-0.39-0.391-1.024 0-1.414l4-4c0.39-0.391 1.024-0.391 1.414 0s0.391 1.024 0 1.414l-4 4c-0.195 0.195-0.451 0.293-0.707 0.293z"></path>
</svg>

After

Width:  |  Height:  |  Size: 768 B

6
assets/img/icons/link4.svg Executable file
View File

@ -0,0 +1,6 @@
<!-- 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="M8.586 10.086l-3.365 3.365c-0.354 0.354-0.829 0.549-1.337 0.549s-0.982-0.195-1.335-0.548c-0.354-0.354-0.548-0.828-0.548-1.336s0.195-0.983 0.549-1.337l3.365-3.365-1.414-1.414-3.365 3.365c-1.513 1.513-1.514 3.989-0.001 5.501 0.756 0.756 1.753 1.134 2.75 1.134s1.995-0.378 2.752-1.135l3.365-3.365-1.414-1.414z"></path>
<path d="M10.086 8.586l3.365-3.365c0.354-0.354 0.549-0.829 0.549-1.337s-0.195-0.982-0.548-1.335c-0.354-0.354-0.828-0.548-1.336-0.548s-0.983 0.195-1.337 0.549l-3.365 3.365-1.414-1.414 3.365-3.365c1.513-1.513 3.989-1.514 5.501-0.001 0.756 0.756 1.134 1.753 1.134 2.75s-0.378 1.995-1.135 2.752l-3.365 3.365-1.414-1.414z"></path>
<path d="M6.5 10.5l-1-1 4-4 1 1-4 4z"></path>
</svg>

After

Width:  |  Height:  |  Size: 835 B

6
assets/img/icons/link5.svg Executable file
View File

@ -0,0 +1,6 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="17" height="16" viewBox="0 0 17 16">
<path d="M7 12h-3c-2.206 0-4-1.794-4-4s1.794-4 4-4h3c0.552 0 1 0.448 1 1s-0.448 1-1 1h-3c-1.103 0-2 0.897-2 2s0.897 2 2 2h3c0.552 0 1 0.448 1 1s-0.448 1-1 1z"></path>
<path d="M13 12h-3c-0.552 0-1-0.448-1-1s0.448-1 1-1h3c1.103 0 2-0.897 2-2s-0.897-2-2-2h-3c-0.552 0-1-0.448-1-1s0.448-1 1-1h3c2.206 0 4 1.794 4 4s-1.794 4-4 4z"></path>
<path d="M11 9h-5c-0.552 0-1-0.448-1-1s0.448-1 1-1h5c0.552 0 1 0.448 1 1s-0.448 1-1 1z"></path>
</svg>

After

Width:  |  Height:  |  Size: 569 B

11
assets/img/icons/unlink.svg Executable file
View File

@ -0,0 +1,11 @@
<!-- 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="M6.879 9.934c-0.208 0-0.416-0.079-0.575-0.238-1.486-1.486-1.486-3.905 0-5.392l3-3c0.72-0.72 1.678-1.117 2.696-1.117s1.976 0.397 2.696 1.117c1.486 1.487 1.486 3.905 0 5.392l-1.371 1.371c-0.317 0.317-0.832 0.317-1.149 0s-0.317-0.832 0-1.149l1.371-1.371c0.853-0.853 0.853-2.241 0-3.094-0.413-0.413-0.963-0.641-1.547-0.641s-1.134 0.228-1.547 0.641l-3 3c-0.853 0.853-0.853 2.241 0 3.094 0.317 0.317 0.317 0.832 0 1.149-0.159 0.159-0.367 0.238-0.575 0.238z"></path>
<path d="M4 15.813c-1.018 0-1.976-0.397-2.696-1.117-1.486-1.486-1.486-3.905 0-5.392l1.371-1.371c0.317-0.317 0.832-0.317 1.149 0s0.317 0.832 0 1.149l-1.371 1.371c-0.853 0.853-0.853 2.241 0 3.094 0.413 0.413 0.962 0.641 1.547 0.641s1.134-0.228 1.547-0.641l3-3c0.853-0.853 0.853-2.241 0-3.094-0.317-0.317-0.317-0.832 0-1.149s0.832-0.317 1.149 0c1.486 1.486 1.486 3.905 0 5.392l-3 3c-0.72 0.72-1.678 1.117-2.696 1.117z"></path>
<path d="M14.5 13c-0.128 0-0.256-0.049-0.354-0.146l-2-2c-0.195-0.195-0.195-0.512 0-0.707s0.512-0.195 0.707 0l2 2c0.195 0.195 0.195 0.512 0 0.707-0.098 0.098-0.226 0.146-0.354 0.146z"></path>
<path d="M15.5 10h-2c-0.276 0-0.5-0.224-0.5-0.5s0.224-0.5 0.5-0.5h2c0.276 0 0.5 0.224 0.5 0.5s-0.224 0.5-0.5 0.5z"></path>
<path d="M11.5 14c-0.276 0-0.5-0.224-0.5-0.5v-2c0-0.276 0.224-0.5 0.5-0.5s0.5 0.224 0.5 0.5v2c0 0.276-0.224 0.5-0.5 0.5z"></path>
<path d="M2.5 7h-2c-0.276 0-0.5-0.224-0.5-0.5s0.224-0.5 0.5-0.5h2c0.276 0 0.5 0.224 0.5 0.5s-0.224 0.5-0.5 0.5z"></path>
<path d="M3.5 6c-0.128 0-0.256-0.049-0.354-0.146l-2-2c-0.195-0.195-0.195-0.512 0-0.707s0.512-0.195 0.707 0l2 2c0.195 0.195 0.195 0.512 0 0.707-0.098 0.098-0.226 0.146-0.354 0.146z"></path>
<path d="M4.5 5c-0.276 0-0.5-0.224-0.5-0.5v-2c0-0.276 0.224-0.5 0.5-0.5s0.5 0.224 0.5 0.5v2c0 0.276-0.224 0.5-0.5 0.5z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

4
assets/img/icons/unlink2.svg Executable file
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="M7.451 10.549c0.071 0.141 0.109 0.299 0.109 0.464 0 0.275-0.105 0.532-0.297 0.724l-2.553 2.553c-0.191 0.191-0.448 0.297-0.724 0.297s-0.532-0.105-0.724-0.297l-1.553-1.553c-0.191-0.191-0.297-0.448-0.297-0.724s0.105-0.532 0.297-0.724l2.553-2.553c0.191-0.191 0.448-0.297 0.724-0.297 0.165 0 0.323 0.038 0.464 0.109l1.021-1.021c-0.435-0.335-0.96-0.502-1.485-0.502-0.625 0-1.25 0.237-1.724 0.711l-2.553 2.553c-0.948 0.948-0.948 2.499 0 3.447l1.553 1.553c0.474 0.474 1.099 0.711 1.724 0.711s1.25-0.237 1.724-0.711l2.553-2.553c0.872-0.872 0.941-2.255 0.209-3.209l-1.021 1.021zM15.289 2.264l-1.553-1.553c-0.474-0.474-1.099-0.711-1.724-0.711s-1.25 0.237-1.724 0.711l-2.553 2.553c-0.872 0.872-0.941 2.255-0.208 3.208l1.021-1.021c-0.071-0.141-0.109-0.299-0.109-0.464 0-0.275 0.105-0.532 0.297-0.724l2.553-2.553c0.191-0.191 0.448-0.297 0.724-0.297s0.532 0.105 0.724 0.297l1.553 1.553c0.191 0.191 0.297 0.448 0.297 0.724s-0.105 0.532-0.297 0.724l-2.553 2.553c-0.191 0.191-0.448 0.297-0.724 0.297-0.165 0-0.323-0.038-0.464-0.109l-1.021 1.021c0.435 0.335 0.96 0.502 1.485 0.502 0.625 0 1.25-0.237 1.724-0.711l2.553-2.553c0.948-0.948 0.948-2.499 0-3.447zM3.646 4.354l-3-3 0.707-0.707 3 3zM6 0h1v3h-1zM0 6h3v1h-3zM12.354 11.646l3 3-0.707 0.707-3-3zM9 13h1v3h-1zM13 9h3v1h-3z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

11
assets/img/icons/unlink3.svg Executable file
View File

@ -0,0 +1,11 @@
<!-- 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="M4.5 16c-2.481 0-4.5-2.019-4.5-4.5s2.019-4.5 4.5-4.5c0.552 0 1 0.448 1 1s-0.448 1-1 1c-1.378 0-2.5 1.121-2.5 2.5s1.122 2.5 2.5 2.5 2.5-1.121 2.5-2.5c0-0.552 0.448-1 1-1s1 0.448 1 1c0 2.481-2.019 4.5-4.5 4.5z"></path>
<path d="M11.5 9c-0.552 0-1-0.448-1-1s0.448-1 1-1c1.379 0 2.5-1.122 2.5-2.5s-1.121-2.5-2.5-2.5-2.5 1.122-2.5 2.5c0 0.552-0.448 1-1 1s-1-0.448-1-1c0-2.481 2.019-4.5 4.5-4.5s4.5 2.019 4.5 4.5-2.019 4.5-4.5 4.5z"></path>
<path d="M3.5 4c-0.128 0-0.256-0.049-0.354-0.146l-2-2c-0.195-0.195-0.195-0.512 0-0.707s0.512-0.195 0.707 0l2 2c0.195 0.195 0.195 0.512 0 0.707-0.098 0.098-0.226 0.146-0.354 0.146z"></path>
<path d="M5.5 3c-0.276 0-0.5-0.224-0.5-0.5v-2c0-0.276 0.224-0.5 0.5-0.5s0.5 0.224 0.5 0.5v2c0 0.276-0.224 0.5-0.5 0.5z"></path>
<path d="M2.5 6h-2c-0.276 0-0.5-0.224-0.5-0.5s0.224-0.5 0.5-0.5h2c0.276 0 0.5 0.224 0.5 0.5s-0.224 0.5-0.5 0.5z"></path>
<path d="M14.5 15c-0.128 0-0.256-0.049-0.354-0.146l-2-2c-0.195-0.195-0.195-0.512 0-0.707s0.512-0.195 0.707 0l2 2c0.195 0.195 0.195 0.512 0 0.707-0.098 0.098-0.226 0.146-0.354 0.146z"></path>
<path d="M15.5 11h-2c-0.276 0-0.5-0.224-0.5-0.5s0.224-0.5 0.5-0.5h2c0.276 0 0.5 0.224 0.5 0.5s-0.224 0.5-0.5 0.5z"></path>
<path d="M10.5 16c-0.276 0-0.5-0.224-0.5-0.5v-2c0-0.276 0.224-0.5 0.5-0.5s0.5 0.224 0.5 0.5v2c0 0.276-0.224 0.5-0.5 0.5z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

11
assets/img/icons/unlink4.svg Executable file
View File

@ -0,0 +1,11 @@
<!-- 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="M8.086 10.586l-2.865 2.865c-0.354 0.354-0.829 0.549-1.337 0.549s-0.982-0.195-1.335-0.548c-0.354-0.354-0.548-0.828-0.548-1.336s0.195-0.983 0.549-1.337l2.865-2.865-1.414-1.414-2.865 2.865c-1.513 1.513-1.514 3.989-0.001 5.501 0.756 0.756 1.753 1.134 2.75 1.134s1.995-0.378 2.752-1.135l2.865-2.865-1.414-1.414z"></path>
<path d="M10.586 8.086l2.865-2.865c0.354-0.354 0.549-0.829 0.549-1.337s-0.195-0.982-0.548-1.335c-0.354-0.354-0.828-0.548-1.336-0.548s-0.983 0.195-1.337 0.549l-2.865 2.865-1.414-1.414 2.865-2.865c1.513-1.513 3.989-1.514 5.501-0.001 0.756 0.756 1.134 1.753 1.134 2.75s-0.378 1.995-1.135 2.752l-2.865 2.865-1.414-1.414z"></path>
<path d="M13 10h3v1h-3v-1z"></path>
<path d="M10 13h1v3h-1v-3z"></path>
<path d="M5 0h1v3h-1v-3z"></path>
<path d="M0 5h3v1h-3v-1z"></path>
<path d="M12.604 11.896l2.75 2.75-0.707 0.707-2.75-2.75 0.707-0.707z"></path>
<path d="M1.354 0.646l2.75 2.75-0.707 0.707-2.75-2.75 0.707-0.707z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -14,8 +14,8 @@
>
<img
v-if="actor.avatar"
:src="actor.avatar.isS3 ? `https://cdndev.traxxx.me/${actor.avatar.thumbnail}` : `/media/${actor.avatar.thumbnail}`"
:style="{ 'background-image': actor.avatar.isS3 ? `url(https://cdndev.traxxx.me/${actor.avatar.lazy})` : `url(/media/${actor.avatar.lazy})` }"
:src="getPath(actor.avatar, 'thumbnail')"
:style="{ 'background-image': `url(${getPath(actor.avatar, 'lazy')})` }"
loading="lazy"
class="avatar"
>
@ -73,6 +73,7 @@
<script setup>
import { ref, inject } from 'vue';
import getPath from '#/src/get-path.js';
import { formatDate } from '#/utils/format.js';
import Heart from '#/components/stashes/heart.vue';

View File

@ -0,0 +1,711 @@
<template>
<Dialog
title="New alert"
@close="emit('close')"
>
<form
class="dialog-body"
@submit.prevent="createAlert"
>
<div class="dialog-section">
<div class="section-header">
<h3 class="heading">IF</h3>
</div>
<div class="field actors">
<span
v-tooltip="fieldsAnd ? 'The alert is triggered if all fields are matched.' : 'The alert is triggered if any of the fields are matched.'"
class="field-logic fields-logic noselect"
@click="fieldsAnd = !fieldsAnd"
>
<Icon
v-show="fieldsAnd"
icon="link3"
/>
<Icon
v-show="!fieldsAnd"
icon="unlink3"
/>
</span>
<ul
class="field-items nolist noselect"
:class="{ and: actorAnd, or: !actorAnd }"
>
<li
v-for="(actor, index) in actors"
:key="`actor-${actor.id}`"
class="field-item actor"
>
<div
v-if="index > 0"
class="field-logic item-logic"
@click="actorAnd = !actorAnd"
>{{ actorAnd ? 'AND' : 'OR' }}</div>
<div class="field-tile">
<div class="field-label">{{ actor.name }}</div>
</div>
<Icon
icon="cross2"
class="field-remove"
@click="actors = actors.filter((selectedActor) => selectedActor.id !== actor.id)"
/>
</li>
<li class="field-add">
<VDropdown @show="focusActorInput">
<button
class="button"
type="button"
><Icon icon="plus3" />Add actor</button>
<template #popper>
<input
ref="actorInput"
v-model="actorQuery"
class="input"
@input="searchActors"
>
<ul class="nolist">
<li
v-for="actor in actorResults"
:key="`actor-result-${actor.id}`"
v-close-popper
class="result-item"
@click="selectActor(actor)"
>
<img
v-if="actor.avatar"
class="field-avatar"
:src="getPath(actor.avatar, 'lazy')"
>
<span
v-else
class="field-avatar"
/>
<div class="result-label">
{{ actor.name }}
<template v-if="actor.ageFromBirth || actor.origin?.country">({{ [actor.ageFromBirth, actor.origin?.country?.alpha2].join(', ') }})</template>
</div>
</li>
</ul>
</template>
</VDropdown>
</li>
</ul>
</div>
<div class="field tags">
<span
class="field-logic noselect"
>{{ fieldsAnd ? 'AND' : 'OR' }}</span>
<ul
class="field-items nolist noselect"
:class="{ and: actorAnd, or: !actorAnd }"
>
<li
v-for="(tag, index) in tags"
:key="`tag-${tag.id}`"
class="field-item tag"
>
<div
v-if="index > 0"
class="field-logic item-logic"
@click="tagAnd = !tagAnd"
>{{ tagAnd ? 'AND' : 'OR' }}</div>
<div class="field-tile field-label">{{ tag.name }}</div>
<Icon
icon="cross2"
class="field-remove"
@click="tags = tags.filter((selectedTag) => selectedTag.id !== tag.id)"
/>
</li>
<li class="field-add">
<VDropdown>
<button
type="button"
class="button"
><Icon icon="plus3" />Add tag</button>
<template #popper>
<input
ref="tagInput"
v-model="tagQuery"
class="input"
@input="searchTags"
>
<ul class="nolist">
<li
v-for="tag in tagResults"
:key="`tag-result-${tag.id}`"
v-close-popper
class="result-item result-label"
@click="selectTag(tag)"
>{{ tag.name }}</li>
</ul>
</template>
</VDropdown>
</li>
</ul>
</div>
<div class="field entities">
<span
class="field-logic noselect"
>{{ fieldsAnd ? 'AND' : 'OR' }}</span>
<ul class="field-items nolist noselect">
<li
v-for="(entity, index) in entities"
:key="`entity-${entity.id}`"
class="field-item entity"
>
<div
v-if="index > 0"
v-tooltip.click="{
content: 'Scenes are only associated to one channel, \'AND\' would never match.',
triggers: ['click'],
autoHide: true,
}"
class="field-logic"
>OR</div>
<div class="field-tile field-label">
<Icon :icon="entity.type === 'network' ? 'device_hub' : 'tv'" />
{{ entity.name }}
</div>
<Icon
icon="cross2"
class="field-remove"
@click="entities = entities.filter((selectedEntity) => selectedEntity.id !== entity.id)"
/>
</li>
<li class="field-add">
<VDropdown>
<button
type="button"
class="button"
><Icon icon="plus3" />Add channel</button>
<template #popper>
<input
ref="entityInput"
v-model="entityQuery"
class="input"
@input="searchEntities"
>
<ul class="nolist">
<li
v-for="entity in entityResults"
:key="`entity-result-${entity.id}`"
v-close-popper
class="result-item result-label"
@click="selectEntity(entity)"
>
<Icon :icon="entity.type === 'network' ? 'device_hub' : 'tv'" />
{{ entity.name }}
</li>
</ul>
</template>
</VDropdown>
</li>
</ul>
</div>
<div class="field matches">
<span
class="field-logic noselect"
>{{ fieldsAnd ? 'AND' : 'OR' }}</span>
<ul class="field-items nolist noselect">
<li
v-for="(match, index) in matches"
:key="`match-${match.property}-${match.expression}`"
class="field-item match"
>
<div
v-if="index > 0"
class="field-logic item-logic"
@click="matchAnd = !matchAnd"
>{{ matchAnd ? 'AND' : 'OR' }}</div>
<div class="field-tile field-label">
<strong>{{ match.property }}:</strong>&nbsp;{{ match.expression }}
</div>
<Icon
icon="cross2"
class="field-remove"
@click="matches = matches.filter((selectedEntity, selectedIndex) => selectedIndex !== index)"
/>
</li>
<li class="field-add">
<VDropdown>
<button
type="button"
class="button"
><Icon icon="plus3" />Add expression</button>
<template #popper>
<form @submit.prevent="addMatch">
<select
v-model="matchProperty"
class="input"
>
<option value="title">Title</option>
<option value="description">Description</option>
</select>
<input
v-model="matchExpression"
placeholder="Expression, // for regex"
class="input"
>
</form>
</template>
</VDropdown>
</li>
</ul>
</div>
</div>
<div class="dialog-section then">
<h3 class="heading">THEN</h3>
<label class="field notify">
<span>Notify me in traxxx</span>
<Checkbox
:checked="notify"
@change="(checked) => notify = checked"
/>
</label>
<label class="field email">
<span>E-mail me</span>
<Checkbox
:checked="email"
@change="(checked) => email = checked"
/>
</label>
<div class="stash">
<ul class="field-items nolist noselect">
<li
v-for="stash in stashes"
:key="`stash-${stash.id}`"
class="field-item tag"
>
<div class="field-tile field-label stash">
<Icon
v-if="stash.isPrimary"
class="favorites"
icon="heart7"
/>{{ stash.name }}
</div>
<Icon
icon="cross2"
class="field-remove"
@click="stashes = stashes.filter((selectedStash) => selectedStash.id !== stash.id)"
/>
</li>
<li class="field-add">
<button
v-if="stashes.length === 0"
type="button"
class="button favorites"
@click="selectStash(user.primaryStash)"
><Icon icon="heart7" />Add to favorites</button>
</li>
<li class="field-add">
<VDropdown>
<button
type="button"
class="button field-add"
><Icon icon="folder-heart" />Add to stash</button>
<template #popper>
<ul class="nolist">
<li
v-for="stash in user.stashes"
:key="`stash-result-${stash.id}`"
v-close-popper
class="result-item result-stash result-label"
@click="selectStash(stash)"
>
{{ stash.name }}
</li>
</ul>
</template>
</VDropdown>
</li>
</ul>
</div>
</div>
<div class="dialog-section dialog-actions">
<button
class="button button-submit"
>Set alert</button>
</div>
</form>
</Dialog>
</template>
<script setup>
import { ref, inject } from 'vue';
import { get, post } from '#/src/api.js';
import getPath from '#/src/get-path.js';
import Dialog from '#/components/dialog/dialog.vue';
import Checkbox from '#/components/form/checkbox.vue';
const { user } = inject('pageContext');
const emit = defineEmits(['close']);
const actors = ref([]);
const actorResults = ref([]);
const actorInput = ref(null);
const actorQuery = ref('');
const entities = ref([]);
const entityResults = ref([]);
const entityInput = ref(null);
const entityQuery = ref('');
const tags = ref([]);
const tagResults = ref([]);
const tagInput = ref(null);
const tagQuery = ref('');
const matches = ref([]);
const matchProperty = ref('title');
const matchExpression = ref('');
const fieldsAnd = ref(true);
const actorAnd = ref(true);
const tagAnd = ref(true);
const matchAnd = ref(true);
const notify = ref(true);
const email = ref(false);
const stashes = ref([]);
async function createAlert() {
console.log('creating alert');
await post('/alerts', {
all: fieldsAnd.value,
allActors: actorAnd.value,
allTags: tagAnd.value,
allMatches: matchAnd.value,
actors: actors.value.map((actor) => actor.id),
tags: tags.value.map((tag) => tag.id),
matches: matches.value,
entities: entities.value.map((entity) => entity.id),
notify: notify.value,
email: email.value,
stashes: stashes.value.map((stash) => stash.id),
}, { appendErrorMessage: true });
emit('close', true);
}
async function searchActors() {
const res = await get('/actors', {
q: actorQuery.value,
limit: 10,
});
actorResults.value = res.actors;
}
async function searchEntities() {
const res = await get('/entities', {
query: entityQuery.value,
limit: 10,
});
entityResults.value = res;
}
async function searchTags() {
const res = await get('/tags', {
query: tagQuery.value,
limit: 10,
});
tagResults.value = res;
}
function focusActorInput() {
setTimeout(() => {
console.log(actorInput.value);
actorInput.value.focus();
}, 100);
}
function selectActor(actor) {
actors.value.push(actor);
actorQuery.value = '';
actorResults.value = [];
}
function selectEntity(entity) {
entities.value.push(entity);
entityQuery.value = '';
entityResults.value = [];
}
function selectTag(tag) {
tags.value.push(tag);
tagQuery.value = '';
tagResults.value = [];
}
function addMatch() {
matches.value.push({
property: matchProperty.value,
expression: matchExpression.value,
});
matchProperty.value = 'title';
matchExpression.value = '';
}
function selectStash(stash) {
stashes.value.push(stash);
}
</script>
<style scoped>
.dialog-body {
width: 30rem;
max-width: 100%;
overflow-y: auto;
}
.dialog-section {
margin-bottom: .5rem;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: stretch;
}
.heading {
width: 100%;
color: var(--primary);
box-sizing: border-box;
padding: .5rem 1rem;
margin: 0;
}
.dialog-actions {
display: flex;
justify-content: center;
padding: 1rem;
}
.field {
display: flex;
align-items: stretch;
padding: 0 1rem 0 0;
}
.field-add .button {
font-weight: normal;
}
.field-add:not(:first-child) {
margin-left: .75rem;
}
.fields-logic:hover,
.item-logic:hover {
cursor: pointer;
color: var(--primary);
.icon {
fill: var(--primary);
}
}
.field-logic {
display: flex;
justify-content: center;
align-items: center;
flex-shrink: 0;
width: 3.5rem;
font-size: .9rem;
color: var(--shadow);
&.item-logic {
width: 2.75rem;
}
.icon {
fill: var(--shadow);
}
}
.field-items {
display: flex;
align-items: center;
flex-wrap: wrap;
flex-grow: 1;
gap: .5rem 0;
padding: .5rem 0;
border-bottom: solid 1px var(--shadow-weak-40);
&.or .field-item::before,
&.and .field-item::before {
color: var(--shadow);
padding: 0 .5rem;
}
/*
&.or .field-item:not(:first-child)::before {
content: 'OR';
}
&.and .field-item:not(:first-child)::before {
content: 'AND';
}
*/
}
.field-item {
display: flex;
align-items: center;
flex-shrink: 0;
position: relative;
font-size: .9rem;
}
.field-remove {
width: .75rem;
height: .75rem;
position: absolute;
top: -.5rem;
right: -.5rem;
padding: .2rem;
border-radius: 1rem;
fill: var(--shadow);
background: var(--background);
box-shadow: 0 0 3px var(--shadow-weak-20);
&:hover {
cursor: pointer;
background: var(--error);
fill: var(--text-light);
}
}
.field-tile {
display: flex;
align-items: center;
flex-shrink: 0;
box-shadow: 0 0 3px var(--shadow-weak-30);
}
.field-label {
padding: .5rem .5rem;
font-weight: bold;
}
.field-avatar {
display: inline-block;
width: 1.5rem;
height: 2rem;
object-fit: cover;
object-position: center 0;
margin-right: .5rem;
}
.result-item {
display: flex;
align-items: center;
.field-avatar {
margin: 0;
}
.icon {
margin-right: .5rem;
transform: translateY(-1px);
}
&:hover {
cursor: pointer;
color: var(--primary);
}
}
.result-label {
padding: .25rem .5rem;
}
.then {
.field {
display: flex;
justify-content: space-between;
padding: .5rem 1rem;
border-bottom: solid 1px var(--shadow-weak-40);
}
.field-items {
padding: .5rem 1rem;
gap: .5rem;
}
.field-add {
margin-left: 0;
}
}
.result-stash {
padding: .3rem .5rem .3rem .5rem;
&:first-child {
padding-top: .5rem;
}
}
.field-tile .icon {
margin-right: .5rem;
}
.field-tile.stash {
font-weight: bold;
}
.field.notify,
.field.email {
cursor: pointer;
}
</style>

View File

@ -59,6 +59,13 @@ onMounted(() => emit('open'));
font-size: .9rem;
line-height: 1.5;
}
.dialog-error {
padding: .5rem 1rem;
color: var(--text-light);
background: var(--error);
font-weight: bold;
}
</style>
<style scoped>
@ -72,14 +79,19 @@ onMounted(() => emit('open'));
top: 0;
left: 0;
z-index: 900;
box-sizing: border-box;
padding: 1rem;
background: var(--shadow-strong-10);
}
.dialog {
display: flex;
flex-direction: column;
border-radius: .25rem;
box-shadow: 0 0 3px var(--shadow);
overflow: hidden;
max-width: 100%;
max-height: 100%;
margin: 1rem;
}

View File

@ -9,6 +9,7 @@
type="search"
placeholder="Search channel"
class="search input"
@search="search"
>
<Icon
@ -41,6 +42,11 @@
>
<span v-else>{{ network.name }}</span>
<Icon
v-if="query && network.type === 'network'"
icon="device_hub"
/>
</a>
</li>
</ul>
@ -51,7 +57,6 @@
<script setup>
import { ref, inject } from 'vue';
import { get } from '#/src/api.js';
import navigate from '#/src/navigate.js';
const pageContext = inject('pageContext');
@ -94,22 +99,21 @@ const popularNetworks = [
'xempire',
].map((slug) => networksBySlug[slug]).filter(Boolean);
const query = ref(pageContext.urlParsed.search.q || null);
const sections = [
{
!query.value && {
label: 'Popular',
networks: popularNetworks,
},
{
label: 'All network',
label: query.value ? 'Results' : 'All networks',
networks,
},
];
const query = ref(pageContext.urlParsed.search.q || null);
].filter(Boolean);
async function search() {
await get('/entities', { query: query.value });
navigate('/channels', { q: query.value });
navigate('/channels', { q: query.value || undefined }, { redirect: true });
}
</script>
@ -137,7 +141,7 @@ async function search() {
.networks {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
gap: .5rem;
padding: .5rem 1rem;
}
@ -153,12 +157,24 @@ async function search() {
justify-content: center;
align-items: center;
aspect-ratio: 4/1;
position: relative;
padding: 1rem;
border-radius: .5rem;
background: var(--grey-dark-40);
color: var(--text-light);
font-size: 1.25rem;
font-weight: bold;
overflow: hidden;
.icon {
position: absolute;
top: -.25rem;
right: -.25rem;
padding: .4rem .55rem .25rem .25rem;
border-radius: .25rem;
background: var(--highlight-weak-30);
fill: var(--text-light);
}
&:hover {
box-shadow: 0 0 3px var(--shadow);
@ -173,13 +189,13 @@ async function search() {
@media(--small-30) {
.networks {
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
}
}
@media(--small-50) {
.networks {
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
}
}
</style>

View File

@ -1,7 +1,9 @@
import { fetchEntities } from '#/src/entities.js';
export async function onBeforeRender(_pageContext) {
const networks = await fetchEntities({ type: 'primary' });
export async function onBeforeRender(pageContext) {
const networks = await fetchEntities(pageContext.urlParsed.search.q
? { query: pageContext.urlParsed.search.q }
: { type: 'primary' });
return {
pageContext: {

View File

@ -66,6 +66,56 @@
</li>
</ul>
</section>
<section
v-if="profile.id === user?.id"
class="profile-section"
>
<div class="section-header">
<h3 class="heading">Alerts</h3>
<button
class="button"
@click="showAlertDialog = true"
>
<Icon icon="plus3" />
<span class="button-label">New alert</span>
</button>
</div>
<table class="alerts">
<tbody>
<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"
:key="`alert-${alert.id}`"
class="alert"
>
<td class="alert-field">{{ alert.actors.map((actor) => actor.name).join(', ') }}</td>
<td class="alert-field">{{ alert.tags.map((tag) => tag.name).join(alert.and.tags ? ' + ' : ' | ') }}</td>
<td class="alert-field">{{ alert.entities.map((entity) => entity.name).join(', ') }}</td>
<td class="alert-field">{{ alert.matches.map((match) => match.expression).join(', ') }}</td>
<td class="alert-actions noselect">
<Icon
icon="bin"
@click="removeAlert(alert)"
/>
</td>
</tr>
</tbody>
</table>
<AlertDialog
v-if="showAlertDialog"
@close="showAlertDialog = false; reloadAlerts();"
/>
</section>
</div>
</div>
</template>
@ -74,20 +124,23 @@
import { ref, inject } from 'vue';
import { formatDistanceStrict } from 'date-fns';
import { get, post } from '#/src/api.js';
import { get, post, del } from '#/src/api.js';
import StashTile from '#/components/stashes/tile.vue';
import Dialog from '#/components/dialog/dialog.vue';
import AlertDialog from '#/components/alerts/create.vue';
const pageContext = inject('pageContext');
const user = pageContext.user;
const profile = ref(pageContext.pageProps.profile);
const alerts = ref(pageContext.pageProps.alerts);
const stashName = ref(null);
const stashNameInput = ref(null);
const done = ref(true);
const showStashDialog = ref(false);
const showAlertDialog = ref(false);
async function reloadProfile() {
profile.value = await get(`/users/${profile.value.id}`);
@ -100,6 +153,7 @@ async function createStash() {
done.value = false;
try {
await post('/stashes', {
name: stashName.value,
public: false,
@ -107,13 +161,47 @@ async function createStash() {
successFeedback: `Created stash '${stashName.value}'`,
errorFeedback: `Failed to create stash '${stashName.value}'`,
appendErrorMessage: true,
}).finally(() => { done.value = true; });
});
} finally {
done.value = true;
}
showStashDialog.value = false;
stashName.value = null;
await reloadProfile();
}
async function reloadAlerts() {
alerts.value = await get('/alerts');
}
async function removeAlert(alert) {
if (done.value === false) {
return;
}
done.value = false;
const alertLabel = [
...alert.actors.map((actor) => actor.name),
...alert.tags.map((tag) => tag.name),
...alert.entities.map((entity) => entity.name),
...alert.matches.map((match) => match.expression),
].filter(Boolean).join(', ');
try {
await del(`/alerts/${alert.id}`, {
undoFeedback: `Removed alert for '${alertLabel}'`,
errorFeedback: `Failed to remove alert for '${alertLabel}'`,
appendErrorMessage: true,
});
await reloadAlerts();
} finally {
done.value = true;
}
}
</script>
<style scoped>
@ -193,6 +281,45 @@ async function createStash() {
padding: 0 .5rem 1rem .5rem;
}
.alerts {
width: 100%;
height: 0;
}
.alert {
&:nth-child(odd) {
background: var(--shadow-weak-40);
}
&:hover {
background: var(--shadow-weak-30);
}
}
.alerts-header {
text-align: left;
}
.alerts-header,
.alert-field {
padding: .25rem .5rem;
}
.alert-actions {
height: 100%;
.icon {
height: 100%;
padding: 0 .25rem;
fill: var(--shadow);
&:hover {
cursor: pointer;
fill: var(--primary);
}
}
}
.dialog-body {
padding: 1rem;

View File

@ -1,11 +1,17 @@
import { render } from 'vike/abort'; /* eslint-disable-line import/extensions */
import { fetchUser } from '#/src/users.js';
import { fetchAlerts } from '#/src/alerts.js';
export async function onBeforeRender(pageContext) {
const profile = await fetchUser(pageContext.routeParams.username, {}, pageContext.user);
const [profile, alerts] = await Promise.all([
fetchUser(pageContext.routeParams.username, {}, pageContext.user),
pageContext.routeParams.username === pageContext.user?.username
? fetchAlerts(pageContext.user)
: [],
]);
// console.log(profile);
console.log('out alerts', alerts);
if (!profile) {
throw render(404, `Cannot find user '${pageContext.routeParams.username}'.`);
@ -16,6 +22,7 @@ export async function onBeforeRender(pageContext) {
title: profile.username,
pageProps: {
profile, // differentiate from authed 'user'
alerts,
},
},
};

158
src/alerts.js Executable file
View File

@ -0,0 +1,158 @@
import { knexOwner as knex } from './knex.js';
import promiseProps from '../utils/promise-props.js';
import { HttpError } from './errors.js';
function curateAlert(alert, context = {}) {
return {
id: alert.id,
notify: alert.notify,
email: alert.email,
createdAt: alert.created_at,
and: {
fields: alert.all,
actors: alert.all_actors,
tags: alert.all_tags,
entities: alert.all_entities,
matches: alert.all_tags,
},
actors: context.actors?.map((actor) => ({
id: actor.actor_id,
name: actor.actor_name,
slug: actor.actor_slug,
})) || [],
tags: context.tags?.map((tag) => ({
id: tag.tag_id,
name: tag.tag_name,
slug: tag.tag_slug,
})) || [],
entities: context.entities?.map((entity) => ({
id: entity.entity_id,
name: entity.entity_name,
slug: entity.entity_slug,
type: entity.type,
})) || [],
matches: context.matches?.map((match) => ({
id: match.id,
property: match.property,
expression: match.expression,
})) || [],
};
}
export async function fetchAlerts(user) {
const {
alerts,
actors,
tags,
entities,
matches,
} = await promiseProps({
alerts: knex('alerts')
.where('user_id', user.id),
actors: knex('alerts_actors')
.select('alerts_actors.*', 'actors.name as actor_name', 'actors.slug as actor_slug')
.leftJoin('alerts', 'alerts.id', 'alerts_actors.alert_id')
.leftJoin('actors', 'actors.id', 'alerts_actors.actor_id')
.where('alerts.user_id', user.id),
tags: knex('alerts_tags')
.select('alerts_tags.*', 'tags.name as tag_name', 'tags.slug as tag_slug')
.leftJoin('alerts', 'alerts.id', 'alerts_tags.alert_id')
.leftJoin('tags', 'tags.id', 'alerts_tags.tag_id')
.where('alerts.user_id', user.id),
entities: knex('alerts_entities')
.select('alerts_entities.*', 'entities.name as entity_name', 'entities.slug as entity_slug', 'entities.type as entity_type')
.leftJoin('alerts', 'alerts.id', 'alerts_entities.alert_id')
.leftJoin('entities', 'entities.id', 'alerts_entities.entity_id')
.where('alerts.user_id', user.id),
matches: knex('alerts_matches')
.select('alerts_matches.*')
.leftJoin('alerts', 'alerts.id', 'alerts_matches.alert_id')
.where('alerts.user_id', user.id),
});
const curatedAlerts = alerts.map((alert) => curateAlert(alert, {
actors: actors.filter((actor) => actor.alert_id === alert.id),
tags: tags.filter((tag) => tag.alert_id === alert.id),
entities: entities.filter((entity) => entity.alert_id === alert.id),
matches: matches.filter((match) => match.alert_id === alert.id),
}));
return curatedAlerts;
}
export async function createAlert(alert, reqUser) {
if (!reqUser) {
throw new HttpError('You are not authenthicated', 401);
}
if ((!alert.actors || alert.actors.length === 0) && (!alert.tags || alert.tags.length === 0) && (!alert.entities || alert.entities.length === 0) && (!alert.matches || alert.matches.length === 0)) {
throw new HttpError('Alert must contain at least one actor, tag or entity', 400);
}
if (alert.matches?.some((match) => !match.property || !match.expression)) {
throw new HttpError('Match must define a property and an expression', 400);
}
const [{ id: alertId }] = await knex('alerts')
.insert({
user_id: reqUser.id,
notify: alert.notify,
email: alert.email,
all: alert.all,
all_actors: alert.allActors,
all_entities: alert.allEntities,
all_tags: alert.allTags,
all_matches: alert.allMatches,
})
.returning('id');
await Promise.all([
alert.actors?.length > 0 && knex('alerts_actors').insert(alert.actors.map((actorId) => ({
alert_id: alertId,
actor_id: actorId,
}))),
alert.tags?.length > 0 && knex('alerts_tags').insert(alert.tags.map((tagId) => ({
alert_id: alertId,
tag_id: tagId,
}))),
alert.matches?.length > 0 && knex('alerts_matches').insert(alert.matches.map((match) => ({
alert_id: alertId,
property: match.property,
expression: match.expression,
}))),
alert.stashes?.length > 0 && knex('alerts_stashes').insert(alert.stashes.map((stashId) => ({
alert_id: alertId,
stash_id: stashId,
}))),
alert.entities?.length > 0 && knex('alerts_entities').insert(alert.entities.map((entityId) => ({
alert_id: alertId,
entity_id: entityId,
})).slice(0, alert.allEntities ? 1 : Infinity)), // one scene can never match multiple entities in AND mode
]);
return alertId;
}
export async function removeAlert(alertId, reqUser) {
await knex('alerts')
.where('id', alertId)
.where('user_id', reqUser.id)
.delete();
}
export async function updateNotification(notificationId, updatedNotification, reqUser) {
await knex('notifications')
.where('id', notificationId)
.where('user_id', reqUser.id)
.update({
seen: updatedNotification.seen,
});
}
export async function updateNotifications(updatedNotification, reqUser) {
await knex('notifications')
.where('user_id', reqUser.id)
.update({
seen: updatedNotification.seen,
});
}

View File

@ -20,13 +20,24 @@ function getQuery(data) {
}
function showFeedback(isSuccess, options = {}, errorMessage) {
if (!isSuccess && typeof options.errorFeedback) {
if (!isSuccess && (typeof options.errorFeedback === 'string' || options.appendErrorMessage)) {
events.emit('feedback', {
type: 'error',
message: options.appendErrorMessage && errorMessage
? `${options.errorFeedback}: ${errorMessage}`
? `${options.errorFeedback ? `${options.errorFeedback}: ` : ''}${errorMessage}`
: options.errorFeedback,
});
return;
}
if (!isSuccess) {
events.emit('feedback', {
type: 'error',
message: 'Error, please try again',
});
return;
}
if (isSuccess && options.successFeedback) {
@ -34,6 +45,8 @@ function showFeedback(isSuccess, options = {}, errorMessage) {
type: 'success',
message: options.successFeedback,
});
return;
}
if (isSuccess && options.undoFeedback) {
@ -82,6 +95,8 @@ export async function post(path, data, options = {}) {
return body;
}
console.log(body.statusMessage);
showFeedback(false, options, body.statusMessage);
throw new Error(body.statusMessage);
} catch (error) {

View File

@ -32,11 +32,17 @@ function curateTag(tag, context) {
export async function fetchTags(options = {}) {
const [tags, posters] = await Promise.all([
knex('tags')
.select('tags.*', 'tags_posters.media_id as poster_id')
.select('tags.*')
.modify((builder) => {
if (!options.includeAliases) {
builder.whereNull('alias_for');
}
if (options.query) {
builder
.where('name', 'like', `%${options.query}%`)
.orWhere('slug', 'like', `%${options.query}%`);
}
}),
knex('tags_posters')
.select('tags_posters.tag_id', 'media.*', knex.raw('row_to_json(entities) as entity'), knex.raw('row_to_json(parents) as entity_parent'))

37
src/web/alerts.js Executable file
View File

@ -0,0 +1,37 @@
import {
fetchAlerts,
createAlert,
removeAlert,
updateNotifications,
updateNotification,
} from '../alerts.js';
export async function fetchAlertsApi(req, res) {
const alerts = await fetchAlerts(req.user);
res.send(alerts);
}
export async function createAlertApi(req, res) {
const alertId = await createAlert(req.body, req.user);
res.send({ id: alertId });
}
export async function removeAlertApi(req, res) {
await removeAlert(req.params.alertId, req.user);
res.status(204).send();
}
export async function updateNotificationsApi(req, res) {
await updateNotifications(req.body, req.user);
res.status(204).send();
}
export async function updateNotificationApi(req, res) {
await updateNotification(req.params.notificationId, req.body, req.user);
res.status(204).send();
}

View File

@ -1,7 +1,7 @@
import { fetchEntities } from '../entities.js';
export async function fetchEntitiesApi(req, res) {
const entities = await fetchEntities(req.body);
const entities = await fetchEntities(req.query);
res.send(entities);
}

View File

@ -18,6 +18,7 @@ import { fetchScenesApi } from './scenes.js';
import { fetchActorsApi } from './actors.js';
import { fetchMoviesApi } from './movies.js';
import { fetchEntitiesApi } from './entities.js';
import { fetchTagsApi } from './tags.js';
import {
setUserApi,
@ -42,6 +43,14 @@ import {
updateStashApi,
} from './stashes.js';
import {
fetchAlertsApi,
createAlertApi,
removeAlertApi,
updateNotificationApi,
updateNotificationsApi,
} from './alerts.js';
import initLogger from '../logger.js';
const logger = initLogger();
@ -118,6 +127,9 @@ export default async function initServer() {
router.get('/api/users/:userId', fetchUserApi);
router.post('/api/users', signupApi);
router.patch('/api/users/:userId/notifications', updateNotificationsApi);
router.patch('/api/users/:userId/notifications/:notificationId', updateNotificationApi);
// STASHES
router.post('/api/stashes', createStashApi);
router.patch('/api/stashes/:stashId', updateStashApi);
@ -131,6 +143,11 @@ export default async function initServer() {
router.delete('/api/stashes/:stashId/scenes/:sceneId', unstashSceneApi);
router.delete('/api/stashes/:stashId/movies/:movieId', unstashMovieApi);
// ALERTS
router.get('/api/alerts', fetchAlertsApi);
router.post('/api/alerts', createAlertApi);
router.delete('/api/alerts/:alertId', removeAlertApi);
// SCENES
router.get('/api/scenes', fetchScenesApi);
@ -143,6 +160,9 @@ export default async function initServer() {
// ENTITIES
router.get('/api/entities', fetchEntitiesApi);
// TAGS
router.get('/api/tags', fetchTagsApi);
router.get('*', async (req, res, next) => {
const pageContextInit = {
urlOriginal: req.originalUrl,

9
src/web/tags.js Normal file
View File

@ -0,0 +1,9 @@
import { fetchTags } from '../tags.js';
export async function fetchTagsApi(req, res) {
const tags = await fetchTags({
query: req.query.query,
});
res.send(tags);
}