Added remaining elements to alert dialog.
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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';
|
||||
|
|
|
@ -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> {{ 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>
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
19
src/api.js
|
@ -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) {
|
||||
|
|
|
@ -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'))
|
||||
|
|
|
@ -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();
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
}
|