Compare commits

...

18 Commits

Author SHA1 Message Date
DebaucheryLibrarian 91ebcace0a 1.193.0 2021-05-15 02:51:59 +02:00
DebaucheryLibrarian 846b860c06 Hiding scene photos and trailers from guests. 2021-05-15 02:51:52 +02:00
DebaucheryLibrarian 83ed793e39 Added dedicated notifications page. 2021-05-09 00:23:10 +02:00
DebaucheryLibrarian 3f55b90ab8 Adding alerted scene to stashes. 2021-04-29 01:45:01 +02:00
DebaucheryLibrarian 4806b0aa41 Improved notifications design. 2021-04-27 04:41:22 +02:00
DebaucheryLibrarian 3b91493995 Using Tippy.js for directive tooltips. 2021-04-27 03:56:38 +02:00
DebaucheryLibrarian 8bf9fff7dc Triggering notifications for children of alert entities. Showing icons in alert entity search to distinguish networks and channels. 2021-04-26 00:48:31 +02:00
DebaucheryLibrarian eed563e06f Updating video player when switching scene page. 2021-04-25 04:20:38 +02:00
DebaucheryLibrarian fc1c2fc2f3 Added notification clear, improved notification styling. 2021-04-25 03:08:50 +02:00
DebaucheryLibrarian f8a3bf6a64 Updated scene URLs in Vixen scraper. 2021-04-22 19:49:11 +02:00
DebaucheryLibrarian c5e74c33b7 Improved alert notifications. 2021-04-22 19:44:23 +02:00
DebaucheryLibrarian 95f3b1c03a Added rudimentary notifications for set alerts. 2021-04-17 01:10:45 +02:00
DebaucheryLibrarian 0773a8019c Updated release search regex to exclude underscores. 2021-04-15 16:56:44 +02:00
DebaucheryLibrarian 1116e09af5 Added Discord link to footer. 2021-04-15 16:11:16 +02:00
DebaucheryLibrarian 52e215d3bc Added tag poster 2021-04-11 15:50:02 +02:00
DebaucheryLibrarian 7f25846d55 List alerts in profile 2021-04-05 00:48:03 +02:00
DebaucheryLibrarian d36e52d5d1 Added row level security to alert tables. Added alerts to user query. 2021-04-04 22:52:54 +02:00
DebaucheryLibrarian da0cbced15 Added alert dialog. Fixed image rotation EXIT data being discarded. 2021-04-04 21:52:19 +02:00
98 changed files with 2706 additions and 99 deletions

View File

@ -0,0 +1,440 @@
<template>
<Dialog
title="Add alert"
@close="$emit('close')"
>
<form
class="dialog-body"
@submit.prevent="addAlert"
>
<div class="dialog-section">
<h3 class="dialog-heading">
When<span class="dialog-description">All to appear in the same scene</span>
</h3>
<div class="alert-section">
<h4
v-if="actors.length > 1"
class="alert-heading"
>Actors</h4>
<h4
v-else
class="alert-heading"
>Actor</h4>
<ul class="actors nolist">
<li
v-for="actor in actors"
:key="`actor-${actor.id}`"
class="actor"
>
<ActorPreview :actor="actor" />
<Icon
icon="cross3"
class="remove"
@click.native="removeActor(actor)"
/>
</li>
<Tooltip>
<li class="actor placeholder"><template v-if="actors.length === 0">Any actor</template>
<Icon
icon="plus3"
class="add"
/>
</li>
<template v-slot:tooltip>
<Search
content="actors"
@select="actor => addActor(actor)"
/>
</template>
</Tooltip>
</ul>
</div>
<div class="alert-section">
<h4
v-if="actors.length > 1"
class="alert-heading"
>Do</h4>
<h4
v-else
class="alert-heading"
>Does</h4>
<ul class="tags nolist">
<li
v-for="tag in tags"
:key="`tag-${tag.id}`"
class="tag"
>{{ tag.name }}
<Icon
icon="cross3"
class="remove"
@click.native="removeTag(tag)"
/>
</li>
<Tooltip>
<li class="tag placeholder"><template v-if="tags.length === 0">Any type of scene</template>
<Icon
icon="plus3"
class="add"
/>
</li>
<template v-slot:tooltip>
<Search
content="tags"
:defaults="['anal', 'blowbang', 'mfm', 'dp', 'gangbang', 'airtight']"
@select="tag => addTag(tag)"
/>
</template>
</Tooltip>
</ul>
</div>
<div class="alert-section">
<h4 class="alert-heading">For</h4>
<div class="entities">
<div
v-if="entity"
class="entity"
>
<Entity :entity="entity" />
<Icon
icon="cross3"
class="remove"
@click.native="removeEntity(entity)"
/>
</div>
<Tooltip v-if="!entity">
<div class="entity placeholder">
Any channel
<Icon
icon="plus3"
class="add"
/>
</div>
<template v-slot:tooltip>
<Search
label="Search channels"
content="entities"
@select="entity => addEntity(entity)"
/>
</template>
</Tooltip>
</div>
</div>
</div>
<div class="dialog-section">
<h3 class="dialog-heading">Then</h3>
<label class="alert-label">
<Checkbox
:checked="notify"
@change="checked => notify = checked"
/>Notify me in traxxx
</label>
<!--
<label class="alert-label">
<Checkbox
:checked="email"
@change="checked => email = checked"
/>Send me an e-mail
</label>
-->
<div class="stashes-container">
<ul class="stashes nolist">
<li
v-for="stash in stashes"
:key="`stash-${stash.id}`"
class="stash"
>{{ stash.name }}
<Icon
icon="cross3"
class="remove"
@click.native="removeStash(stash)"
/>
</li>
<Tooltip>
<li class="stash placeholder">
Add to stash
<Icon
icon="plus3"
class="add"
/>
</li>
<template v-slot:tooltip>
<Search
content="stashes"
@select="stash => addStash(stash)"
/>
</template>
</Tooltip>
</ul>
</div>
</div>
<div class="dialog-actions right">
<button
type="submit"
class="button button-primary"
>Add alert</button>
</div>
</form>
</Dialog>
</template>
<script>
import ActorPreview from '../actors/preview.vue';
import Entity from '../entities/tile.vue';
import Checkbox from '../form/checkbox.vue';
import Search from './search.vue';
async function addAlert() {
await this.$store.dispatch('addAlert', {
actors: this.actors.map(actor => actor.id),
tags: this.tags.map(tag => tag.id),
entity: this.entity?.id,
notify: this.notify,
email: this.email,
stashes: this.stashes.map(stash => stash.id),
});
this.$emit('close', true);
}
function addActor(actor) {
if (!this.actors.some(selectedActor => selectedActor.id === actor.id)) {
this.actors = this.actors.concat(actor);
}
this.events.emit('blur');
}
function addEntity(entity) {
this.entity = entity;
this.events.emit('blur');
}
function addTag(tag) {
if (!this.tags.some(selectedTag => selectedTag.id === tag.id)) {
this.tags = this.tags.concat(tag);
}
this.events.emit('blur');
}
function removeActor(actor) {
this.actors = this.actors.filter(listedActor => listedActor.id !== actor.id);
}
function removeEntity() {
this.entity = null;
}
function removeTag(tag) {
this.tags = this.tags.filter(listedTag => listedTag.id !== tag.id);
}
function addStash(stash) {
if (!this.stashes.some(selectedStash => selectedStash.id === stash.id)) {
this.stashes = this.stashes.concat(stash);
}
this.events.emit('blur');
}
function removeStash(stash) {
this.stashes = this.stashes.filter(listedStash => listedStash.id !== stash.id);
}
export default {
components: {
ActorPreview,
Checkbox,
Entity,
Search,
},
data() {
return {
actors: [],
tags: [],
entity: null,
notify: true,
email: false,
stashes: [],
availableStashes: this.$store.state.auth.user.stashes,
};
},
emits: ['close'],
methods: {
addActor,
addAlert,
addEntity,
addTag,
addStash,
removeActor,
removeEntity,
removeTag,
removeStash,
},
};
</script>
<style lang="scss" scoped>
.dialog-section {
width: 30rem;
max-width: 100%;
&:first-child {
border-bottom: solid 1px var(--shadow-hint);
margin: 0 0 1rem 0;
}
}
.dialog-heading {
display: flex;
justify-content: space-between;
align-items: center;
margin: 0 0 .25rem 0;
color: var(--primary);
}
.dialog-description {
color: var(--shadow);
font-size: .9rem;
font-weight: normal;
}
.alert-heading {
margin: .75rem 0 .25rem 0;
}
.actors,
.entities,
.tags {
display: flex;
align-items: center;
flex-wrap: wrap;
font-size: 0;
}
.actors > .actor,
.entity,
.tag,
.stash {
position: relative;
font-size: 1rem;
margin: 0 .5rem .5rem 0;
}
.entity .tile {
width: 10rem;
height: 2.5rem;
}
.tag:not(.placeholder),
.stash:not(.placeholder) {
padding: .5rem .75rem;
border: solid 1px var(--shadow-hint);
margin: 0 .75rem 0 0;
font-size: .9rem;
font-weight: bold;
}
.stashes {
margin: 0 0 0 .25rem;
color: var(--text);
}
.remove {
width: 1rem;
height: 1rem;
position: absolute;
top: -.35rem;
right: -.35rem;
z-index: 1;
border: solid 1px var(--darken-hint);
border-radius: 50%;
background: var(--background);
fill: var(--shadow-weak);
box-shadow: 0 0 1px var(--shadow);
&:hover {
fill: var(--text-light);
background: var(--primary);
border: solid 1px var(--primary);
cursor: pointer;
}
}
.placeholder {
display: inline-flex;
align-items: center;
color: var(--shadow-strong);
padding: .75rem 0;
margin: 0 0 .5rem 0;
font-size: 1rem;
.add {
fill: var(--shadow);
margin: 0 0 0 .5rem;
}
&:hover {
color: var(--primary);
cursor: pointer;
.add {
fill: var(--primary);
}
}
}
.alert-label {
display: flex;
align-items: center;
padding: .5rem 0;
margin: 0 0 .25rem 0;
cursor: pointer;
}
.stashes-heading {
display: flex;
align-items: center;
margin: 0 0 .5rem 0;
font-size: 1rem;
.alert-label {
display: inline-block;
margin: 0;
}
}
.tooltip-container {
display: inline-block;
}
.check-container {
display: inline-block;
margin: 0 .5rem 0 0;
}
</style>

View File

@ -0,0 +1,41 @@
<template>
<Dialog
title="Remove alert"
@close="$emit('close', false)"
>
<form
class="dialog-body"
@submit.prevent="removeAlert"
>
Are you sure you want to remove alert?
<div class="dialog-actions right">
<button
type="submit"
class="button button-primary"
>Remove</button>
</div>
</form>
</Dialog>
</template>
<script>
async function removeAlert() {
await this.$store.dispatch('removeAlert', this.alert.id);
this.$emit('close', true);
}
export default {
props: {
alert: {
type: Object,
default: null,
},
},
emits: ['close'],
methods: {
removeAlert,
},
};
</script>

View File

@ -0,0 +1,145 @@
<template>
<div>
<input
ref="input"
v-model="query"
:placeholder="label || `Search ${content}`"
class="input"
@input="search"
>
<ul
v-if="results.length > 0"
class="nolist"
>
<li
v-for="result in results"
:key="`result-${result.id}`"
class="result"
@click="selectResult(result)"
>
<Icon
v-if="result.type === 'network'"
v-tooltip="'Network'"
icon="device_hub"
/>
<Icon
v-if="result.type === 'channel'"
v-tooltip="'Channel'"
icon="tv"
/>
<img
v-if="result.avatar"
:src="getPath(result.avatar)"
class="avatar"
>{{ result.name }}
</li>
</ul>
</div>
</template>
<script>
async function search() {
if (this.content === 'actors') {
this.results = await this.$store.dispatch('searchActors', {
query: this.query,
minLength: 1,
limit: 10,
});
}
if (this.content === 'entities') {
this.results = await this.$store.dispatch('searchEntities', {
query: this.query,
limit: 10,
});
}
if (this.content === 'tags') {
this.results = await this.$store.dispatch('searchTags', {
query: this.query,
minLength: 1,
limit: 10,
});
}
if (this.content === 'stashes') {
this.results = this.$store.state.auth.user.stashes.filter(stash => new RegExp(this.query).test(stash.name));
}
}
function selectResult(item) {
this.query = null;
this.results = [];
this.$emit('select', item);
}
async function mounted() {
this.$refs.input.focus();
if (this.defaults.length > 0 && this.content === 'tags') {
this.results = await this.$store.dispatch('fetchTags', {
slugs: this.defaults,
});
}
if (this.content === 'stashes') {
this.results = this.$store.state.auth.user.stashes;
}
}
export default {
props: {
content: {
type: String,
default: null,
},
defaults: {
type: Array,
default: () => [],
},
label: {
type: String,
default: null,
},
},
data() {
return {
query: null,
results: [],
};
},
emits: ['select'],
mounted,
methods: {
search,
selectResult,
},
};
</script>
<style lang="scss" scoped>
.result {
display: flex;
align-items: center;
padding: .5rem;
&:hover {
color: var(--primary);
cursor: pointer;
}
}
.avatar {
height: 2rem;
margin: 0 .5rem 0 0;
}
.icon {
fill: var(--shadow);
margin: -.1rem .75rem 0 0;
}
</style>

View File

@ -6,7 +6,7 @@
> >
<div <div
class="dialog" class="dialog"
@click.stop @click.stop="events.emit('blur')"
> >
<div <div
v-if="title || $slots.header" v-if="title || $slots.header"
@ -98,14 +98,29 @@ export default {
} }
.body { .body {
display: flex;
flex-direction: column;
flex-grow: 1;
overflow: hidden;
::v-deep(.section) {
padding: 1rem;
}
}
::v-deep(.dialog-body) {
padding: 1rem; padding: 1rem;
flex-grow: 1; flex-grow: 1;
}
::v-deep(.dialog-section:not(:last-child)) {
border-bottom: solid 1px var(--shadow-hint);
overflow: auto; overflow: auto;
} }
::v-deep(.dialog-actions) { ::v-deep(.dialog-actions) {
display: flex; display: flex;
padding: 1rem 0 0 0; margin: 1rem 0 0 0;
&.center { &.center {
justify-content: center; justify-content: center;

View File

@ -47,6 +47,7 @@ export default {
.tile { .tile {
width: 15rem; width: 15rem;
height: 6rem;
} }
&.expanded { &.expanded {

View File

@ -137,7 +137,7 @@ async function fetchEntity(scroll = true) {
this.pageTitle = entity.name; this.pageTitle = entity.name;
if (scroll) { if (scroll && this.$refs.filter?.$el) {
this.$refs.filter.$el.scrollIntoView(); this.$refs.filter.$el.scrollIntoView();
} }
} }
@ -146,9 +146,11 @@ async function mounted() {
await this.fetchEntity(); await this.fetchEntity();
} }
async function route() { async function route(to) {
if (to.name === 'channel' || to.name === 'network' || to.name === 'studio') {
await this.fetchEntity(); await this.fetchEntity();
this.expanded = false; this.expanded = false;
}
} }
export default { export default {

View File

@ -56,7 +56,7 @@ export default {
@import 'theme'; @import 'theme';
.tile { .tile {
height: 6rem; height: 100%;
background: var(--tile); background: var(--tile);
display: flex; display: flex;
flex-shrink: 0; flex-shrink: 0;

View File

@ -3,7 +3,7 @@
title="Filters" title="Filters"
@close="$emit('close')" @close="$emit('close')"
> >
<div class="filters"> <div class="filters dialog-body">
<h3 class="form-heading">Show me</h3> <h3 class="form-heading">Show me</h3>
<ul class="tags nolist"> <ul class="tags nolist">
@ -47,7 +47,7 @@ export default {
}, },
data() { data() {
return { return {
tags: ['anal', 'gay', 'transsexual', 'bisexual', 'pissing'], tags: ['anal', 'gay', 'transsexual', 'bisexual', 'pissing', 'anal prolapse'],
}; };
}, },
computed: { computed: {

View File

@ -6,20 +6,32 @@
:to="{ name: 'stats' }" :to="{ name: 'stats' }"
class="segment footer-link nolink" class="segment footer-link nolink"
>stats</router-link> >stats</router-link>
<a
v-if="config.discord"
:href="config.discord"
target="_blank"
rel="noopener noreferrer"
class="segment footer-link nolink discord"
><Icon icon="discord" /></a>
</footer> </footer>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.footer { .footer {
display: flex;
justify-content: center;
align-items: center;
background: var(--background-dim); background: var(--background-dim);
color: var(--shadow); color: var(--shadow);
font-size: .8rem; font-size: .8rem;
font-weight: bold; font-weight: bold;
text-align: center;
box-shadow: inset -3px 0 3px var(--shadow-hint); box-shadow: inset -3px 0 3px var(--shadow-hint);
} }
.segment { .segment {
display: inline-flex;
align-items: center;
padding: .5rem; padding: .5rem;
&:not(:last-child) { &:not(:last-child) {
@ -32,6 +44,17 @@
&:hover { &:hover {
color: var(--primary); color: var(--primary);
.icon {
fill: var(--primary);
}
}
}
.discord {
.icon {
fill: var(--shadow);
width: 4rem;
} }
} }
</style> </style>

View File

@ -82,8 +82,30 @@
@click.stop="$emit('toggleSidebar')" @click.stop="$emit('toggleSidebar')"
><Icon icon="menu" /></div> ><Icon icon="menu" /></div>
<Tooltip v-if="me">
<div
class="header-button header-notifications"
:class="{ unseen: unseenNotificationsCount > 0 }"
>
<Icon icon="bell2" />
<span
v-if="unseenNotificationsCount > 0"
class="notifications-count"
>{{ unseenNotificationsCount }}</span>
</div>
<template v-slot:tooltip>
<Notifications
:notifications="notifications"
:unseen-count="unseenNotificationsCount"
@check="fetchNotifications"
/>
</template>
</Tooltip>
<Tooltip> <Tooltip>
<div class="header-account"> <div class="header-button header-account">
<div class="account"> <div class="account">
<Icon <Icon
icon="user3-long" icon="user3-long"
@ -97,8 +119,6 @@
</template> </template>
</Tooltip> </Tooltip>
<Search class="search-full" />
<Tooltip <Tooltip
class="search-compact" class="search-compact"
:open="searching" :open="searching"
@ -121,19 +141,34 @@
/> />
</template> </template>
</Tooltip> </Tooltip>
<Search class="search-full" />
</div> </div>
</header> </header>
</template> </template>
<script> <script>
import Menu from './menu.vue'; import Menu from './menu.vue';
import Notifications from './notifications.vue';
import Search from './search.vue'; import Search from './search.vue';
import logo from '../../img/logo.svg'; import logo from '../../img/logo.svg';
function me() {
return this.$store.state.auth.user;
}
async function fetchNotifications() {
const { notifications, unseenCount } = await this.$store.dispatch('fetchNotifications');
this.notifications = notifications;
this.unseenNotificationsCount = unseenCount;
}
export default { export default {
components: { components: {
Menu, Menu,
Notifications,
Search, Search,
}, },
emits: ['toggleSidebar', 'showFilters'], emits: ['toggleSidebar', 'showFilters'],
@ -142,8 +177,20 @@ export default {
logo, logo,
searching: false, searching: false,
showFilters: false, showFilters: false,
notifications: [],
unseenNotificationsCount: 0,
}; };
}, },
computed: {
me,
},
watch: {
me: fetchNotifications,
},
mounted: fetchNotifications,
methods: {
fetchNotifications,
},
}; };
</script> </script>
@ -257,27 +304,13 @@ export default {
} }
} }
.header-toggles { .header-button {
margin: 0 .5rem 0 0; padding: 1rem .75rem;
.icon { .icon {
padding: 1rem .75rem;
fill: var(--shadow); fill: var(--shadow);
&:hover {
fill: var(--shadow-strong);
cursor: pointer;
} }
&.active {
fill: var(--primary);
}
}
}
.header-account {
padding: 1rem;
&:hover { &:hover {
cursor: pointer; cursor: pointer;
@ -285,12 +318,38 @@ export default {
border-color: var(--primary); border-color: var(--primary);
} }
.avatar { .icon {
fill: var(--primary); fill: var(--primary);
} }
} }
} }
.header-account {
padding: 1rem 1.25rem 1rem .75rem;
}
.header-notifications {
position: relative;
padding: 1rem .75rem;
&.unseen .icon {
fill: var(--primary);
}
}
.notifications-count {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
position: absolute;
bottom: .3rem;
left: 0;
color: var(--primary);
font-size: .6rem;
font-weight: bold;
}
.account { .account {
width: 1.25rem; width: 1.25rem;
height: 1.25rem; height: 1.25rem;
@ -364,7 +423,8 @@ export default {
display: none; display: none;
} }
.header-account { .header-account,
.header-notifications {
display: none; display: none;
} }
} }

View File

@ -0,0 +1,350 @@
<template>
<div class="notifications">
<div class="notifications-header">
<h4 class="notifications-title">Notifications</h4>
<div class="notifications-actions">
<Icon
v-if="unseenCount > 0"
v-tooltip="'Mark all as seen'"
icon="checkmark"
@click="checkNotifications"
/>
<Icon
v-tooltip="'Add alert'"
icon="plus3"
@click="showAddAlert = true"
/>
</div>
</div>
<AddAlert
v-if="showAddAlert"
@close="showAddAlert = false"
>Alert</AddAlert>
<div class="notifications-body">
<div
v-if="notifications.length === 0"
class="notifications-empty"
>No notifications</div>
<ul
v-else
class="nolist"
>
<li
v-for="notification in notifications"
:key="`notification-${notification.id}`"
:class="{ unseen: !notification.seen }"
class="notification"
@click="checkNotification(notification.id, true)"
>
<router-link
:to="`/scene/${notification.scene.id}/${notification.scene.slug}`"
class="notification-link"
>
<img
:src="getPath(notification.scene.poster, 'thumbnail')"
class="poster"
>
<div class="notification-body">
<div class="notification-row notification-title">
<img
v-if="notification.scene.entity.type === 'network' || notification.scene.entity.independent"
v-tooltip="notification.scene.entity.name"
:src="`/img/logos/${notification.scene.entity.slug}/favicon_${theme === 'dark' ? 'light' : 'dark'}.png`"
class="notification-favicon"
>
<img
v-else
v-tooltip="notification.scene.entity.name"
:src="`/img/logos/${notification.scene.entity.parent.slug}/favicon_${theme === 'dark' ? 'light' : 'dark'}.png`"
class="notification-favicon"
>
New&nbsp;<ul
v-if="notification.alert?.tags.length > 0"
class="nolist notification-tags"
>
<li
v-for="tag in notification.alert.tags"
:key="`notification-tag-${tag.slug}`"
class="notification-tag"
>{{ tag.name }}</li>&nbsp;
</ul>scene
</div>
<div class="notification-row notification-details">
<span class="notification-date">{{ formatDate(notification.scene.date, 'MMM D') }}</span>
<ul
v-if="notification.scene.actors.length > 0"
class="nolist notification-actors"
>
<li
v-for="actor in notification.scene.actors"
:key="`notification-actor-${actor.slug}`"
class="notification-actor"
>{{ actor.name }}</li>
</ul>
</div>
</div>
<Icon
v-if="!notification.seen"
v-tooltip="'Mark as seen'"
icon="checkmark"
class="notification-check"
@click.prevent.stop="checkNotification(notification.id)"
/>
<Icon
v-if="notification.alert"
v-tooltip="`You set an alert for <strong>${notification.alert.tags.map(tag => tag.name).join(', ') || 'all'}</strong> scenes with <strong>${notification.alert.actors.map(actor => actor.name).join(', ') || 'any actor'}</strong> for <strong>${notification.alert.entity?.name || 'any channel'}</strong>`"
icon="question5"
@click.prevent.stop
/>
</router-link>
</li>
</ul>
</div>
<div @click="$emit('blur')">
<router-link
to="/notifications"
class="notification-link notification-more"
>See all</router-link>
</div>
</div>
</template>
<script>
import AddAlert from '../alerts/add.vue';
async function checkNotifications() {
await this.$store.dispatch('checkNotifications');
this.$emit('check');
}
async function checkNotification(notificationId, blur) {
await this.$store.dispatch('checkNotification', notificationId);
this.$emit('check');
if (blur) {
this.events.emit('blur');
}
}
export default {
components: {
AddAlert,
},
props: {
notifications: {
type: Array,
default: () => [],
},
unseenCount: {
type: Number,
default: 0,
},
},
data() {
return {
showAddAlert: false,
};
},
methods: {
checkNotifications,
checkNotification,
},
};
</script>
<style lang="scss" scoped>
.notifications {
width: 30rem;
max-height: calc(100vh - 5rem);
display: flex;
flex-direction: column;
}
.notifications-header {
display: flex;
justify-content: space-between;
.icon {
padding: .5rem;
fill: var(--shadow);
&:first-child {
padding: .5rem .5rem .5rem 1.5rem;
}
&:last-child {
padding: .5rem 1.5rem .5rem .5rem;
}
&:hover {
fill: var(--primary);
cursor: pointer;
}
}
}
.notifications-title {
display: inline-block;
padding: .5rem 1rem;
margin: 0;
color: var(--shadow);
font-size: 1rem;
font-weight: bold;
}
.notifications-body {
flex-grow: 1;
overflow-y: auto;
box-shadow: 0 0 3px var(--shadow-weak);
}
.notifications-empty {
padding: .5rem 1rem;
color: var(--shadow);
}
.notification {
display: block;
border-right: solid .5rem var(--shadow-touch);
color: var(--text);
&.unseen {
border-right: solid .5rem var(--primary);
}
.icon {
padding: 1.3rem .5rem;
fill: var(--shadow-weak);
&.notification-check {
padding: 1.3rem .5rem 1.3rem 1rem;
}
&:last-child {
padding: 1.3rem 1rem 1.3rem .5rem;
}
&:hover {
fill: var(--primary);
}
}
&:not(:last-child) {
border-bottom: solid 1px var(--shadow-hint);
margin: 0 0 -1px 0;
}
&:hover {
background: var(--shadow-touch);
&:not(.unseen) {
border-right: solid .5rem var(--shadow-weak);
}
}
}
.notification-link {
display: flex;
align-items: stretch;
color: inherit;
text-decoration: none;
}
.notification-body {
flex-grow: 1;
padding: .4rem 0 0 0;
overflow: hidden;
}
.notification-row {
display: flex;
overflow: hidden;
}
.notification-title {
margin: .15rem .5rem .3rem .5rem;
}
.notification-favicon {
width: 1rem;
height: 1rem;
margin: 0 .5rem 0 0;
}
.notification-tags {
white-space: nowrap;
}
.notification-actors {
padding: 0 .5rem;
height: 1.25rem;
display: inline-block;
overflow: hidden;
}
.notification-date {
width: 3rem;
flex-shrink: 0;
padding: .25rem .25rem .35rem .25rem;
border-right: solid 1px var(--shadow-hint);
border-top: solid 1px var(--shadow-hint);
color: var(--shadow-strong);
font-size: .8rem;
text-align: center;
}
.notification-actor,
.notification-tag {
white-space: nowrap;
&:not(:last-child)::after {
content: ',';
padding: 0 .1rem 0 0;
}
}
.notification-actor {
padding: .25rem .15rem .35rem 0;
color: var(--shadow-strong);
font-size: .9rem;
}
.notification-tag {
font-weight: bold;
}
.notification-more {
display: block;
padding: .5rem 1rem;
color: var(--shadow);
text-align: center;
font-size: .9rem;
font-weight: bold;
&:hover {
color: var(--primary);
}
}
.poster {
width: 6rem;
height: 3.6rem;
object-fit: cover;
object-position: center;
}
</style>

View File

@ -51,6 +51,7 @@ export default {
query: this.$route.query ? this.$route.query.q : null, query: this.$route.query ? this.$route.query.q : null,
}; };
}, },
emits: ['search'],
watch: { watch: {
$route: route, $route: route,
searching, searching,
@ -71,8 +72,8 @@ export default {
flex-grow: 1; flex-grow: 1;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
padding: 0 1rem 0 0;
border-left: solid 1px var(--shadow-hint); border-left: solid 1px var(--shadow-hint);
margin: 0 .25rem 0 0;
&.compact { &.compact {
padding: 0; padding: 0;
@ -92,7 +93,7 @@ export default {
.search-input { .search-input {
height: 100%; height: 100%;
width: 100%; width: 100%;
padding: .5rem 0 .5rem .5rem; padding: .5rem 0 .5rem .75rem;
border: none; border: none;
color: var(--text); color: var(--text);
background: var(--background); background: var(--background);
@ -119,14 +120,20 @@ export default {
} }
} }
&:focus::placeholder { &:focus {
&::placeholder {
color: var(--shadow-weak); color: var(--shadow-weak);
} }
& + .search-button:not(:hover) .icon {
fill: var(--shadow);
}
}
} }
.search-button { .search-button {
height: 100%; height: 100%;
padding: 0 1rem; padding: 0 1.25rem 0 1rem;
background: none; background: none;
border: none; border: none;
margin: .3rem 0 0 0; margin: .3rem 0 0 0;
@ -140,7 +147,7 @@ export default {
cursor: pointer; cursor: pointer;
.icon { .icon {
fill: var(--shadow); fill: var(--primary);
} }
} }
} }

View File

@ -144,6 +144,10 @@ export default {
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
grid-gap: .5rem; grid-gap: .5rem;
padding: 1rem 0; padding: 1rem 0;
.tile {
height: 6rem;
}
} }
@media(max-width: $breakpoint2) { @media(max-width: $breakpoint2) {

View File

@ -0,0 +1,76 @@
<template>
<div
ref="page"
class="notifications-container"
>
<h1 class="heading">Notifications</h1>
<ul class="notifications nolist">
<li
v-for="notification in notifications"
:key="notification.id"
>
<SceneTile :release="notification.scene" />
</li>
</ul>
<Pagination
:items-total="totalCount"
:items-per-page="10"
/>
</div>
</template>
<script>
import Pagination from '../pagination/pagination.vue';
import SceneTile from '../releases/scene-tile.vue';
async function fetchNotifications() {
const { notifications, totalCount, unseenCount } = await this.$store.dispatch('fetchNotifications', {
page: this.$route.params.pageNumber,
limit: this.limit,
});
this.notifications = notifications;
this.unseenNotificationsCount = unseenCount;
this.totalCount = totalCount;
this.$emit('scroll');
}
export default {
components: {
Pagination,
SceneTile,
},
data() {
return {
notifications: [],
limit: 10,
totalCount: 0,
unseenNotificationsCount: 0,
};
},
emits: ['scroll'],
watch: {
$route: fetchNotifications,
},
mounted: fetchNotifications,
};
</script>
<style lang="scss" scoped>
.notifications-container {
padding: 1rem;
}
.notifications {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(22rem, 1fr));
grid-gap: .5rem;
}
.pagination {
margin: 1rem 0 0 0;
}
</style>

View File

@ -17,7 +17,6 @@
@pause="playing = false; paused = true;" @pause="playing = false; paused = true;"
/> />
<Player <Player
v-else-if="release.teaser && /^video\//.test(release.teaser.mime)" v-else-if="release.teaser && /^video\//.test(release.teaser.mime)"
:video="release.teaser" :video="release.teaser"
@ -108,6 +107,14 @@
</span> </span>
</a> </a>
</div> </div>
<div
v-if="!me"
class="item-container item-more"
><router-link
:to="{ name: 'signup', query: { ref: $route.path } }"
class="link"
>Sign up</router-link>&nbsp;for more photos, trailers and features!</div>
</div> </div>
</div> </div>
</template> </template>
@ -119,6 +126,10 @@ function sfw() {
return this.$store.state.ui.sfw; return this.$store.state.ui.sfw;
} }
function me() {
return this.$store.state.auth.user;
}
function poster() { function poster() {
if (this.release.poster) { if (this.release.poster) {
return this.getPath(this.release.poster, 'thumbnail'); return this.getPath(this.release.poster, 'thumbnail');
@ -182,6 +193,7 @@ export default {
}; };
}, },
computed: { computed: {
me,
photos, photos,
poster, poster,
sfw, sfw,
@ -192,10 +204,6 @@ export default {
<style lang="scss" scoped> <style lang="scss" scoped>
@import 'breakpoints'; @import 'breakpoints';
.media-container {
backdrop-filter: blur(1rem);
}
.media { .media {
flex-shrink: 0; flex-shrink: 0;
white-space: nowrap; white-space: nowrap;
@ -205,6 +213,7 @@ export default {
.media.center { .media.center {
width: 1200px; width: 1200px;
max-width: 100%; max-width: 100%;
display: flex;
margin: 0 auto; margin: 0 auto;
} }
@ -285,6 +294,22 @@ export default {
background-size: cover; background-size: cover;
} }
.item-more {
height: auto;
flex-grow: 1;
align-items: center;
padding: .5rem 2rem;
color: var(--text-light);
text-shadow: 0 0 3px var(--darken);
font-weight: bold;
font-size: 1rem;
.link {
color: inherit;
text-decoration: underline;
}
}
.trailer-container { .trailer-container {
width: 32rem; width: 32rem;
max-width: 100%; max-width: 100%;
@ -312,6 +337,14 @@ export default {
} }
@media(max-width: $breakpoint-micro) { @media(max-width: $breakpoint-micro) {
.media.center {
flex-direction: column;
}
.item-more {
font-size: .9rem;
}
.media:not(.expanded) .item, .media:not(.expanded) .item,
.trailer-container { .trailer-container {
height: 56vw; /* 16:9 ratio for full-width video */ height: 56vw; /* 16:9 ratio for full-width video */

View File

@ -327,6 +327,10 @@ export default {
.banner { .banner {
background-position: center; background-position: center;
background-size: cover; background-size: cover;
::v-deep .scrollable {
backdrop-filter: blur(1rem);
}
} }
.info { .info {

View File

@ -16,10 +16,17 @@
<div class="scene-footer"> <div class="scene-footer">
<img <img
v-if="scene.entity.parent"
:src="`/img/logos/${scene.entity.parent.slug}/favicon_light.png`" :src="`/img/logos/${scene.entity.parent.slug}/favicon_light.png`"
class="scene-favicon" class="scene-favicon"
> >
<img
v-else
:src="`/img/logos/${scene.entity.slug}/favicon_light.png`"
class="scene-favicon"
>
<span class="scene-title">{{ scene.title }}</span> <span class="scene-title">{{ scene.title }}</span>
</div> </div>
</a> </a>

View File

@ -22,6 +22,8 @@
:src="getPath(release.poster, 'thumbnail')" :src="getPath(release.poster, 'thumbnail')"
:style="{ 'background-image': getBgPath(release.poster, 'lazy') }" :style="{ 'background-image': getBgPath(release.poster, 'lazy') }"
:alt="release.title" :alt="release.title"
:width="release.poster.thumbnailWidth"
:height="release.poster.thumbnailHeight"
class="thumbnail" class="thumbnail"
loading="lazy" loading="lazy"
> >
@ -31,6 +33,8 @@
:src="getPath(release.photos[0], 'thumbnail')" :src="getPath(release.photos[0], 'thumbnail')"
:style="{ 'background-image': getBgPath(release.photos[0], 'lazy') } " :style="{ 'background-image': getBgPath(release.photos[0], 'lazy') } "
:alt="release.title" :alt="release.title"
:width="release.photos[0].thumbnailWidth"
:height="release.photos[0].thumbnailHeight"
class="thumbnail" class="thumbnail"
loading="lazy" loading="lazy"
> >

View File

@ -3,7 +3,10 @@
title="Add stash" title="Add stash"
@close="$emit('close', false)" @close="$emit('close', false)"
> >
<form @submit.prevent="addStash"> <form
class="dialog-body"
@submit.prevent="addStash"
>
<input <input
ref="name" ref="name"
v-model="name" v-model="name"

View File

@ -49,6 +49,7 @@ export default {
<style lang="scss" scoped> <style lang="scss" scoped>
.menu-item { .menu-item {
color: var(--text);
display: block; display: block;
} }

View File

@ -3,7 +3,10 @@
title="Remove stash" title="Remove stash"
@close="$emit('close', false)" @close="$emit('close', false)"
> >
<form @submit.prevent="removeStash"> <form
class="dialog-body"
@submit.prevent="removeStash"
>
Are you sure you want to remove stash "{{ stash.name }}"? Are you sure you want to remove stash "{{ stash.name }}"?
<div class="dialog-actions right"> <div class="dialog-actions right">

View File

@ -78,7 +78,7 @@
<script> <script>
import Actor from '../actors/tile.vue'; import Actor from '../actors/tile.vue';
import Releases from '../releases/releases.vue'; import Releases from '../releases/releases.vue';
import RemoveStash from './remove-stash.vue'; import RemoveStash from './remove.vue';
import Toggle from '../form/toggle.vue'; import Toggle from '../form/toggle.vue';
async function fetchStash() { async function fetchStash() {

View File

@ -117,7 +117,6 @@ function mounted() {
}); });
this.events.on('scroll', () => { this.events.on('scroll', () => {
console.log('scroll!');
this.calculate(); this.calculate();
}); });
} }
@ -156,7 +155,7 @@ export default {
.tooltip-inner { .tooltip-inner {
position: relative; position: relative;
box-shadow: 0 0 3px var(--darken-weak); box-shadow: 0 0 .5rem var(--darken);
} }
.tooltip { .tooltip {

View File

@ -0,0 +1,252 @@
<template>
<div class="alert">
<div class="alert-section alert-header">
<div class="alert-targets">
<Icon
v-if="alert.notify"
icon="bell2"
class="alert-action"
/>
<Icon
v-if="alert.email"
icon="envelop"
class="alert-action"
/>
<Icon
v-if="alert.stashes?.length > 0"
v-tooltip="alert.stashes.map(stash => stash.name).join()"
icon="heart7"
class="alert-action"
/>
</div>
<span class="header-actions noselect">
<Icon
v-if="isMe"
icon="bin"
class="alert-remove"
@click.native="showRemoveAlert = true"
/>
<RemoveAlert
v-if="showRemoveAlert"
:alert="alert"
@close="removeAlert"
/>
</span>
</div>
<div class="alert-triggers">
<div
v-if="alert.actors?.length > 0"
class="alert-section alert-trigger"
>
<h4
v-if="alert.actors.length > 1"
class="alert-heading"
>Actors</h4>
<h4
v-else
class="alert-heading"
>Actor</h4>
<ul class="alert-actors nolist">
<li
v-for="actor in alert.actors"
:key="`actor-${actor.id}`"
class="alert-actor"
>
<ActorPreview
:actor="actor"
:alert="alert"
/>
</li>
</ul>
</div>
<div
v-if="alert.tags?.length > 0"
class="alert-section alert-trigger"
>
<h4
v-if="alert.tags.length > 1"
class="alert-heading"
>Tags</h4>
<h4
v-else
class="alert-heading"
>Tag</h4>
<ul class="alert-tags nolist">
<li
v-for="tag in alert.tags"
:key="`tag-${tag.id}`"
><router-link
:to="`/tag/${tag.slug}`"
class="tag nolink"
>{{ tag.name }}</router-link></li>
</ul>
</div>
<div
v-if="alert.entity"
class="alert-section alert-trigger"
>
<h4 class="alert-heading">Channel</h4>
<Entity
v-if="alert.entity"
:entity="alert.entity"
class="entity"
/>
</div>
</div>
</div>
</template>
<script>
import ActorPreview from '../actors/preview.vue';
import RemoveAlert from '../alerts/remove.vue';
import Entity from '../entities/tile.vue';
async function removeAlert(removed) {
this.showRemoveAlert = false;
if (removed) {
this.$emit('remove');
}
}
export default {
components: {
ActorPreview,
Entity,
RemoveAlert,
},
props: {
alert: {
type: Object,
default: null,
},
isMe: {
type: Boolean,
default: false,
},
},
emits: ['remove'],
data() {
return {
showRemoveAlert: false,
};
},
methods: {
removeAlert,
},
};
</script>
<style lang="scss" scoped>
.alert {
min-width: 0;
height: 100%;
background: var(--background);
box-shadow: 0 0 3px var(--darken-weak);
}
.alert-header {
border-bottom: solid 1px var(--shadow-hint);
}
.alert-section {
display: inline-block;
padding: .5rem;
}
.alert-header {
display: flex;
justify-content: space-between;
align-items: stretch;
padding: 0;
.alert-action {
padding: .5rem;
fill: var(--shadow);
}
}
.alert-triggers {
display: flex;
}
.alert-heading {
display: block;
padding: 0 .5rem .5rem 0;
margin: 0;
color: var(--shadow-strong);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: .9rem;
}
.alert-more {
flex-shrink: 0;
margin: 0 0 0 .5rem;
color: var(--shadow);
font-size: .9rem;
}
.header-actions {
display: flex;
align-items: stretch;
}
.alert-remove {
height: auto;
padding: 0 .5rem 0 .75rem;
fill: var(--shadow);
&:hover {
cursor: pointer;
fill: var(--shadow-strong);
}
}
.alert-triggers {
dislay: flex;
flex-wrap: wrap;
}
.alert-trigger {
flex-shrink: 0;
flex-grow: 1;
}
.alert-actors,
.alert-tags {
display: flex;
grid-gap: .5rem;
}
.tag {
color: var(--shadow-strong);
padding: .5rem;
border: solid 1px var(--shadow-hint);
font-size: .9rem;
font-weight: bold;
&:hover {
cursor: pointer;
border: solid 1px var(--primary);
}
}
.entity {
width: 10rem;
height: 2.5rem;
}
</style>

View File

@ -82,9 +82,9 @@
</template> </template>
<script> <script>
import ActorPreview from './actor-preview.vue'; import ActorPreview from '../actors/preview.vue';
import ScenePreview from './scene-preview.vue'; import ScenePreview from '../releases/scene-preview.vue';
import RemoveStash from '../stashes/remove-stash.vue'; import RemoveStash from '../stashes/remove.vue';
import Toggle from '../form/toggle.vue'; import Toggle from '../form/toggle.vue';
async function publishStash(isPublic) { async function publishStash(isPublic) {

View File

@ -43,18 +43,61 @@
<Icon icon="plus2" /> <Icon icon="plus2" />
</li> </li>
</ul> </ul>
</section>
<AddStash <AddStash
v-if="showAddStash" v-if="showAddStash"
@close="closeAddStash" @close="closeAddStash"
/> />
</section>
<section
v-if="user.alerts?.length > 0"
class="section"
>
<div class="section-header">
<h3 class="section-heading">Alerts</h3>
<Icon
icon="plus3"
class="header-add"
@click="showAddAlert = true"
/>
</div>
<ul class="section-body alerts nolist">
<li
v-for="alert in user.alerts"
:key="`alert-${alert.id}`"
class="alert"
>
<Alert
:alert="alert"
:is-me="isMe"
@remove="() => fetchUser()"
/>
</li>
<li
class="alerts-add"
@click="showAddAlert = true"
>
<Icon icon="plus2" />
</li>
</ul>
<AddAlert
v-if="showAddAlert"
@close="closeAddAlert"
>Alert</AddAlert>
</section>
</div> </div>
</template> </template>
<script> <script>
import Stash from './stash.vue'; import Stash from './stash.vue';
import AddStash from '../stashes/add-stash.vue'; import Alert from './alert.vue';
import AddStash from '../stashes/add.vue';
import AddAlert from '../alerts/add.vue';
async function fetchUser() { async function fetchUser() {
this.user = await this.$store.dispatch('fetchUser', this.$route.params.username); this.user = await this.$store.dispatch('fetchUser', this.$route.params.username);
@ -71,13 +114,23 @@ async function closeAddStash(addedStash) {
} }
} }
async function closeAddAlert(addedAlert) {
this.showAddAlert = false;
if (addedAlert) {
await this.fetchUser();
}
}
async function mounted() { async function mounted() {
await this.fetchUser(); await this.fetchUser();
} }
export default { export default {
components: { components: {
AddAlert,
AddStash, AddStash,
Alert,
Stash, Stash,
}, },
data() { data() {
@ -88,10 +141,12 @@ export default {
isMe: false, isMe: false,
pageTitle: null, pageTitle: null,
showAddStash: false, showAddStash: false,
showAddAlert: false,
}; };
}, },
mounted, mounted,
methods: { methods: {
closeAddAlert,
closeAddStash, closeAddStash,
fetchUser, fetchUser,
}, },
@ -122,7 +177,8 @@ export default {
margin: 0 0 1rem 0; margin: 0 0 1rem 0;
} }
.stashes { .stashes,
.alerts {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
grid-auto-rows: 15fr; grid-auto-rows: 15fr;
@ -162,7 +218,8 @@ export default {
min-width: 0; min-width: 0;
} }
.stashes-add { .stashes-add,
.alerts-add {
height: 100%; height: 100%;
display: flex; display: flex;
align-items: center; align-items: center;
@ -186,7 +243,8 @@ export default {
} }
@media(max-width: $breakpoint-kilo) { @media(max-width: $breakpoint-kilo) {
.stashes { .stashes,
.alerts {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }

View File

@ -6,19 +6,19 @@
class="player video-js vjs-big-play-centered" class="player video-js vjs-big-play-centered"
@playing="$emit('play')" @playing="$emit('play')"
@pause="$emit('pause')" @pause="$emit('pause')"
> />
<source
:src="getPath(video)"
type="video/mp4"
>
</video>
</template> </template>
<script> <script>
import videoJs from 'video.js'; import videoJs from 'video.js';
import 'videojs-vr/dist/videojs-vr.min'; import 'videojs-vr/dist/videojs-vr.min';
function mounted() { function updatePlayer() {
this.player.src(this.getPath(this.video));
this.player.poster(this.poster);
}
function initPlayer() {
this.player = videoJs(this.$refs.player, { this.player = videoJs(this.$refs.player, {
controls: true, controls: true,
inactivityTimeout: 1000, inactivityTimeout: 1000,
@ -29,6 +29,8 @@ function mounted() {
}, },
}, },
}, () => { }, () => {
this.player.src(this.getPath(this.video));
if (this.video.isVr) { if (this.video.isVr) {
this.player.vr({ projection: '180' }); this.player.vr({ projection: '180' });
} }
@ -46,7 +48,17 @@ export default {
default: null, default: null,
}, },
}, },
mounted, data() {
return {
player: null,
};
},
emits: ['play', 'pause'],
watch: {
video: updatePlayer,
poster: updatePlayer,
},
mounted: initPlayer,
}; };
</script> </script>

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">
<title>alarm-add</title>
<path d="M8 2c-3.866 0-7 3.134-7 7s3.134 7 7 7 7-3.134 7-7-3.134-7-7-7zM8 14.625c-3.107 0-5.625-2.518-5.625-5.625s2.518-5.625 5.625-5.625c3.107 0 5.625 2.518 5.625 5.625s-2.518 5.625-5.625 5.625zM14.606 4.487c0.251-0.438 0.394-0.946 0.394-1.487 0-1.657-1.343-3-3-3-0.966 0-1.825 0.457-2.374 1.166 2.061 0.426 3.831 1.644 4.98 3.322v0zM6.374 1.166c-0.549-0.709-1.408-1.166-2.374-1.166-1.657 0-3 1.343-3 3 0 0.541 0.143 1.049 0.394 1.487 1.148-1.678 2.919-2.896 4.98-3.322z"></path>
<path d="M11 8h-2v-2h-2v2h-2v2h2v2h2v-2h2z"></path>
</svg>

After

Width:  |  Height:  |  Size: 696 B

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">
<title>alarm-cancel</title>
<path d="M8 2c-3.866 0-7 3.134-7 7s3.134 7 7 7 7-3.134 7-7-3.134-7-7-7zM8 14.625c-3.107 0-5.625-2.518-5.625-5.625s2.518-5.625 5.625-5.625c3.107 0 5.625 2.518 5.625 5.625s-2.518 5.625-5.625 5.625zM14.606 4.487c0.251-0.438 0.394-0.946 0.394-1.487 0-1.657-1.343-3-3-3-0.966 0-1.825 0.457-2.374 1.166 2.061 0.426 3.831 1.644 4.98 3.322v0zM6.374 1.166c-0.549-0.709-1.408-1.166-2.374-1.166-1.657 0-3 1.343-3 3 0 0.541 0.143 1.049 0.394 1.487 1.148-1.678 2.919-2.896 4.98-3.322z"></path>
<path d="M11.457 11.043l-1.414 1.414-2.043-2.043-2.043 2.043-1.414-1.414 2.043-2.043-2.043-2.043 1.414-1.414 2.043 2.043 2.043-2.043 1.414 1.414-2.043 2.043 2.043 2.043z"></path>
</svg>

After

Width:  |  Height:  |  Size: 826 B

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">
<title>alarm-check</title>
<path d="M8 2c-3.866 0-7 3.134-7 7s3.134 7 7 7 7-3.134 7-7-3.134-7-7-7zM8 14.625c-3.107 0-5.625-2.518-5.625-5.625s2.518-5.625 5.625-5.625c3.107 0 5.625 2.518 5.625 5.625s-2.518 5.625-5.625 5.625zM14.606 4.487c0.251-0.438 0.394-0.946 0.394-1.487 0-1.657-1.343-3-3-3-0.966 0-1.825 0.457-2.374 1.166 2.061 0.426 3.831 1.644 4.98 3.322v0zM6.374 1.166c-0.549-0.709-1.408-1.166-2.374-1.166-1.657 0-3 1.343-3 3 0 0.541 0.143 1.049 0.394 1.487 1.148-1.678 2.919-2.896 4.98-3.322z"></path>
<path d="M11 6.5l-4 4-1.5-1.5-1 1 2.5 2.5 5-5z"></path>
</svg>

After

Width:  |  Height:  |  Size: 702 B

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">
<title>alarm</title>
<path d="M8 2c-3.866 0-7 3.134-7 7s3.134 7 7 7 7-3.134 7-7-3.134-7-7-7zM8 14.625c-3.107 0-5.625-2.518-5.625-5.625s2.518-5.625 5.625-5.625c3.107 0 5.625 2.518 5.625 5.625s-2.518 5.625-5.625 5.625zM14.606 4.487c0.251-0.438 0.394-0.946 0.394-1.487 0-1.657-1.343-3-3-3-0.966 0-1.825 0.457-2.374 1.166 2.061 0.426 3.831 1.644 4.98 3.322v0zM6.374 1.166c-0.549-0.709-1.408-1.166-2.374-1.166-1.657 0-3 1.343-3 3 0 0.541 0.143 1.049 0.394 1.487 1.148-1.678 2.919-2.896 4.98-3.322z"></path>
<path d="M8 9v-4h-1v5h4v-1z"></path>
</svg>

After

Width:  |  Height:  |  Size: 677 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<title>bell-check</title>
<path d="M16 13c-1.657 0-3-1.343-3-3v-4.455c0-2.199-1.718-4.033-4-4.454v-1.091h-2v1.091c-2.282 0.421-4 2.255-4 4.454v4.455c0 1.657-1.343 3-3 3v1h6.712c-0.081 0.178-0.127 0.377-0.127 0.586 0 0.781 0.633 1.414 1.414 1.414s1.414-0.633 1.414-1.414c0-0.209-0.045-0.407-0.127-0.586h6.713v-1zM7 11.559l-2.42-3.201 0.747-0.747 1.674 1.205 3.83-3.392 0.778 0.778-4.608 5.358z"></path>
</svg>

After

Width:  |  Height:  |  Size: 540 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<title>bell-cross</title>
<path d="M16 13c-1.657 0-3-1.343-3-3v-4.455c0-2.199-1.718-4.033-4-4.454v-1.091h-2v1.091c-2.282 0.421-4 2.255-4 4.454v4.455c0 1.657-1.343 3-3 3v1h6.712c-0.081 0.178-0.127 0.377-0.127 0.586 0 0.781 0.633 1.414 1.414 1.414s1.414-0.633 1.414-1.414c0-0.209-0.045-0.407-0.127-0.586h6.713v-1zM11 9.5l-1 1-2-2-2 2-1-1 2-2-2-2 1-1 2 2 2-2 1 1-2 2 2 2z"></path>
</svg>

After

Width:  |  Height:  |  Size: 516 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<title>bell-minus</title>
<path d="M16 13c-1.657 0-3-1.343-3-3v-4.455c0-2.199-1.718-4.033-4-4.454v-1.091h-2v1.091c-2.282 0.421-4 2.255-4 4.454v4.455c0 1.657-1.343 3-3 3v1h6.712c-0.081 0.178-0.127 0.377-0.127 0.586 0 0.781 0.633 1.414 1.414 1.414s1.414-0.633 1.414-1.414c0-0.209-0.045-0.407-0.127-0.586h6.713v-1zM11 9h-6v-2h6v2z"></path>
</svg>

After

Width:  |  Height:  |  Size: 475 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<title>bell-plus</title>
<path d="M16 13c-1.657 0-3-1.343-3-3v-4.455c0-2.199-1.718-4.033-4-4.454v-1.091h-2v1.091c-2.282 0.421-4 2.255-4 4.454v4.455c0 1.657-1.343 3-3 3v1h6.712c-0.081 0.178-0.127 0.377-0.127 0.586 0 0.781 0.633 1.414 1.414 1.414s1.414-0.633 1.414-1.414c0-0.209-0.045-0.407-0.127-0.586h6.713v-1zM11 9h-2v2h-2v-2h-2v-2h2v-2h2v2h2v2z"></path>
</svg>

After

Width:  |  Height:  |  Size: 494 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<title>bell</title>
<path d="M16.023 12.5c0-4.5-4-3.5-4-7 0-0.29-0.028-0.538-0.079-0.749-0.263-1.766-1.44-3.183-2.965-3.615 0.014-0.062 0.021-0.125 0.021-0.191 0-0.52-0.45-0.945-1-0.945s-1 0.425-1 0.945c0 0.065 0.007 0.129 0.021 0.191-1.71 0.484-2.983 2.208-3.020 4.273-0.001 0.030-0.001 0.060-0.001 0.091 0 3.5-4 2.5-4 7 0 1.191 2.665 2.187 6.234 2.439 0.336 0.631 1.001 1.061 1.766 1.061s1.43-0.43 1.766-1.061c3.568-0.251 6.234-1.248 6.234-2.439 0-0.004-0-0.007-0-0.011l0.024 0.011zM12.91 13.345c-0.847 0.226-1.846 0.389-2.918 0.479-0.089-1.022-0.947-1.824-1.992-1.824s-1.903 0.802-1.992 1.824c-1.072-0.090-2.071-0.253-2.918-0.479-1.166-0.311-1.724-0.659-1.928-0.845 0.204-0.186 0.762-0.534 1.928-0.845 1.356-0.362 3.1-0.561 4.91-0.561s3.554 0.199 4.91 0.561c1.166 0.311 1.724 0.659 1.928 0.845-0.204 0.186-0.762 0.534-1.928 0.845z"></path>
</svg>

After

Width:  |  Height:  |  Size: 981 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<title>bell2</title>
<path d="M16 13c-1.657 0-3-1.343-3-3v-4.455c0-2.199-1.718-4.033-4-4.454v-1.091h-2v1.091c-2.282 0.421-4 2.255-4 4.454v4.455c0 1.657-1.343 3-3 3v1h6.712c-0.081 0.178-0.127 0.377-0.127 0.586 0 0.781 0.633 1.414 1.414 1.414s1.414-0.633 1.414-1.414c0-0.209-0.045-0.407-0.127-0.586h6.713v-1z"></path>
</svg>

After

Width:  |  Height:  |  Size: 454 B

View File

@ -0,0 +1,7 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<title>bell3</title>
<path d="M16 13c-1.657 0-3-1.343-3-3v-4.455c0-2.199-1.718-4.033-4-4.454v-1.091h-2v1.091c-2.282 0.421-4 2.255-4 4.454v4.455c0 1.657-1.343 3-3 3v1h6.712c-0.081 0.178-0.127 0.377-0.127 0.586 0 0.781 0.633 1.414 1.414 1.414s1.414-0.633 1.414-1.414c0-0.209-0.045-0.407-0.127-0.586h6.713v-1z"></path>
<path d="M15.483 6c-0.261 0-0.481-0.203-0.498-0.467-0.118-1.787-0.908-3.444-2.226-4.666-0.202-0.188-0.214-0.504-0.027-0.707s0.504-0.214 0.707-0.027c1.506 1.397 2.409 3.291 2.543 5.334 0.018 0.276-0.191 0.514-0.466 0.532-0.011 0.001-0.022 0.001-0.033 0.001z"></path>
<path d="M0.517 6c-0.011 0-0.022-0-0.033-0.001-0.276-0.018-0.484-0.256-0.466-0.532 0.134-2.043 1.038-3.937 2.543-5.334 0.203-0.188 0.519-0.176 0.707 0.027s0.176 0.519-0.027 0.707c-1.318 1.222-2.108 2.879-2.226 4.666-0.017 0.264-0.237 0.467-0.498 0.467z"></path>
</svg>

After

Width:  |  Height:  |  Size: 982 B

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">
<title>calendar-day</title>
<path d="M8 9c-0.553 0-1-0.447-1-1s0.447-1 1-1 1 0.447 1 1-0.447 1-1 1z"></path>
<path d="M14 2v-2h-2v2h-8v-2h-2v2h-2v14h16v-14h-2zM3 15h-2v-2h2v2zM3 12h-2v-2h2v2zM3 9h-2v-2h2v2zM3 6h-2v-2h2v2zM6 15h-2v-2h2v2zM6 12h-2v-2h2v2zM6 9h-2v-2h2v2zM6 6h-2v-2h2v2zM9 15h-2v-2h2v2zM9 12h-2v-2h2v2zM9 9h-2v-2h2v2zM9 6h-2v-2h2v2zM12 15h-2v-2h2v2zM12 12h-2v-2h2v2zM12 9h-2v-2h2v2zM12 6h-2v-2h2v2zM15 15h-2v-2h2v2zM15 12h-2v-2h2v2zM15 9h-2v-2h2v2zM15 6h-2v-2h2v2z"></path>
</svg>

After

Width:  |  Height:  |  Size: 625 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<title>calendar-empty</title>
<path d="M14 2v-2h-2v2h-8v-2h-2v2h-2v14h16v-14h-2zM3 15h-2v-2h2v2zM3 12h-2v-2h2v2zM3 9h-2v-2h2v2zM3 6h-2v-2h2v2zM6 15h-2v-2h2v2zM6 12h-2v-2h2v2zM6 9h-2v-2h2v2zM6 6h-2v-2h2v2zM9 15h-2v-2h2v2zM9 12h-2v-2h2v2zM9 9h-2v-2h2v2zM9 6h-2v-2h2v2zM12 15h-2v-2h2v2zM12 12h-2v-2h2v2zM12 9h-2v-2h2v2zM12 6h-2v-2h2v2zM15 15h-2v-2h2v2zM15 12h-2v-2h2v2zM15 9h-2v-2h2v2zM15 6h-2v-2h2v2z"></path>
</svg>

After

Width:  |  Height:  |  Size: 546 B

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">
<title>calendar-week</title>
<path d="M8 9c-0.553 0-1-0.447-1-1s0.447-1 1-1 1 0.447 1 1-0.447 1-1 1zM12 8c0-0.553-0.447-1-1-1s-1 0.447-1 1 0.447 1 1 1 1-0.447 1-1zM15 8c0-0.553-0.447-1-1-1s-1 0.447-1 1 0.447 1 1 1 1-0.447 1-1zM6 8c0-0.553-0.447-1-1-1s-1 0.447-1 1 0.447 1 1 1 1-0.447 1-1zM3 8c0-0.553-0.447-1-1-1s-1 0.447-1 1 0.447 1 1 1 1-0.447 1-1z"></path>
<path d="M14 2v-2h-2v2h-8v-2h-2v2h-2v14h16v-14h-2zM3 15h-2v-2h2v2zM3 12h-2v-2h2v2zM3 9h-2v-2h2v2zM3 6h-2v-2h2v2zM6 15h-2v-2h2v2zM6 12h-2v-2h2v2zM6 9h-2v-2h2v2zM6 6h-2v-2h2v2zM9 15h-2v-2h2v2zM9 12h-2v-2h2v2zM9 9h-2v-2h2v2zM9 6h-2v-2h2v2zM12 15h-2v-2h2v2zM12 12h-2v-2h2v2zM12 9h-2v-2h2v2zM12 6h-2v-2h2v2zM15 15h-2v-2h2v2zM15 12h-2v-2h2v2zM15 9h-2v-2h2v2zM15 6h-2v-2h2v2z"></path>
</svg>

After

Width:  |  Height:  |  Size: 876 B

View File

@ -0,0 +1,35 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<title>calendar3</title>
<path d="M8 7h1v1h-1v-1z"></path>
<path d="M10 9h1v1h-1v-1z"></path>
<path d="M10 11h1v1h-1v-1z"></path>
<path d="M8 5h1v1h-1v-1z"></path>
<path d="M8 9h1v1h-1v-1z"></path>
<path d="M8 13h1v1h-1v-1z"></path>
<path d="M8 11h1v1h-1v-1z"></path>
<path d="M6 5h1v1h-1v-1z"></path>
<path d="M12 9h1v1h-1v-1z"></path>
<path d="M12 7h1v1h-1v-1z"></path>
<path d="M12 5h1v1h-1v-1z"></path>
<path d="M12 11h1v1h-1v-1z"></path>
<path d="M10 5h1v1h-1v-1z"></path>
<path d="M12 13h1v1h-1v-1z"></path>
<path d="M10 7h1v1h-1v-1z"></path>
<path d="M10 13h1v1h-1v-1z"></path>
<path d="M2 7h1v1h-1v-1z"></path>
<path d="M2 9h1v1h-1v-1z"></path>
<path d="M4 13h1v1h-1v-1z"></path>
<path d="M6 7h1v1h-1v-1z"></path>
<path d="M2 13h1v1h-1v-1z"></path>
<path d="M2 11h1v1h-1v-1z"></path>
<path d="M2 5h1v1h-1v-1z"></path>
<path d="M6 11h1v1h-1v-1z"></path>
<path d="M4 11h1v1h-1v-1z"></path>
<path d="M6 9h1v1h-1v-1z"></path>
<path d="M6 13h1v1h-1v-1z"></path>
<path d="M4 5h1v1h-1v-1z"></path>
<path d="M4 9h1v1h-1v-1z"></path>
<path d="M4 7h1v1h-1v-1z"></path>
<path d="M12 1v-1h-2v1h-5v-1h-2v1h-3v15h15v-15h-3zM10 2h2v1h-2v-1zM3 2h2v1h-2v-1zM14 15h-13v-11h13v11z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<title>calendar5</title>
<path d="M14 2h-1.5v0.5c0 0.551-0.449 1-1 1s-1-0.449-1-1v-0.5h-5v0.5c0 0.551-0.449 1-1 1s-1-0.449-1-1v-0.5h-1.5c-0.55 0-1 0.45-1 1v11c0 0.55 0.45 1 1 1h12c0.55 0 1-0.45 1-1v-11c0-0.55-0.45-1-1-1zM14 13.998c-0.001 0.001-0.001 0.001-0.002 0.002h-11.996c-0.001-0.001-0.001-0.001-0.002-0.002v-8.998h12v8.998zM4.5 3c0.276 0 0.5-0.224 0.5-0.5v-2c0-0.276-0.224-0.5-0.5-0.5s-0.5 0.224-0.5 0.5v2c0 0.276 0.224 0.5 0.5 0.5zM11.5 3c0.276 0 0.5-0.224 0.5-0.5v-2c0-0.276-0.224-0.5-0.5-0.5s-0.5 0.224-0.5 0.5v2c0 0.276 0.224 0.5 0.5 0.5zM9 6h-5v1h4v2h-4v1h4v2h-4v1h5zM11 13h1v-7h-2v1h1zM13.625 15.375h-11.25c-0.55 0-1-0.325-1-0.875v0.5c0 0.55 0.45 1 1 1h11.25c0.55 0 1-0.45 1-1v-0.5c0 0.55-0.45 0.875-1 0.875z"></path>
</svg>

After

Width:  |  Height:  |  Size: 868 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<title>device_hub</title>
<path d="M17.016 15.984h3.984v5.016h-5.016v-3.047l-3.984-4.219-3.984 4.219v3.047h-5.016v-5.016h3.984l4.031-3.984v-3.188q-0.891-0.328-1.453-1.078t-0.563-1.734q0-1.219 0.891-2.109t2.109-0.891 2.109 0.891 0.891 2.109q0 0.984-0.563 1.734t-1.453 1.078v3.188z"></path>
</svg>

After

Width:  |  Height:  |  Size: 427 B

33
assets/img/icons/discord.svg Executable file
View File

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 725.59998 198.3"
version="1.1"
id="svg6"
width="725.59998"
height="198.3">
<metadata
id="metadata12">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs10" />
<path
d="m 105.9,83.2 c -5.7,0 -10.2,4.9 -10.2,11 0,6.1 4.6,11 10.2,11 5.7,0 10.2,-4.9 10.2,-11 0,-6.1 -4.6,-11 -10.2,-11 z m -36.5,0 c -5.7,0 -10.2,4.9 -10.2,11 0,6.1 4.6,11 10.2,11 5.7,0 10.2,-4.9 10.2,-11 0.1,-6.1 -4.5,-11 -10.2,-11 z"
id="path2" />
<path
d="M 154.5,0 H 20.5 C 9.2,0 0,9.2 0,20.5 v 134 C 0,165.8 9.2,175 20.5,175 h 113.4 l -5.3,-18.3 12.8,11.8 12.1,11.1 21.6,18.7 V 20.5 C 175,9.2 165.8,0 154.5,0 Z m -38.6,129.5 c 0,0 -3.6,-4.3 -6.6,-8 13.1,-3.7 18.1,-11.8 18.1,-11.8 -4.1,2.7 -8,4.6 -11.5,5.9 -5,2.1 -9.8,3.4 -14.5,4.3 -9.6,1.8 -18.4,1.3 -25.9,-0.1 -5.7,-1.1 -10.6,-2.6 -14.7,-4.3 -2.3,-0.9 -4.8,-2 -7.3,-3.4 -0.3,-0.2 -0.6,-0.3 -0.9,-0.5 -0.2,-0.1 -0.3,-0.2 -0.4,-0.2 -1.8,-1 -2.8,-1.7 -2.8,-1.7 0,0 4.8,7.9 17.5,11.7 -3,3.8 -6.7,8.2 -6.7,8.2 C 38.1,128.9 29.7,114.5 29.7,114.5 29.7,82.6 44.1,56.7 44.1,56.7 58.5,46 72.1,46.3 72.1,46.3 l 1,1.2 c -18,5.1 -26.2,13 -26.2,13 0,0 2.2,-1.2 5.9,-2.8 10.7,-4.7 19.2,-5.9 22.7,-6.3 0.6,-0.1 1.1,-0.2 1.7,-0.2 6.1,-0.8 13,-1 20.2,-0.2 9.5,1.1 19.7,3.9 30.1,9.5 0,0 -7.9,-7.5 -24.9,-12.6 l 1.4,-1.6 c 0,0 13.7,-0.3 28,10.4 0,0 14.4,25.9 14.4,57.8 0,-0.1 -8.4,14.3 -30.5,15 z m 151,-86.7 H 233.7 V 80.1 L 255.8,100 V 63.8 h 11.8 c 7.5,0 11.2,3.6 11.2,9.4 v 27.7 c 0,5.8 -3.5,9.7 -11.2,9.7 h -34 v 21.1 h 33.2 c 17.8,0.1 34.5,-8.8 34.5,-29.2 V 72.7 C 301.4,51.9 284.7,42.8 266.9,42.8 Z m 174,59.7 V 71.9 c 0,-11 19.8,-13.5 25.8,-2.5 L 485,62 C 477.8,46.2 464.7,41.6 453.8,41.6 436,41.6 418.4,51.9 418.4,71.9 v 30.6 c 0,20.2 17.6,30.3 35,30.3 11.2,0 24.6,-5.5 32,-19.9 l -19.6,-9 c -4.8,12.3 -24.9,9.3 -24.9,-1.4 z M 380.4,76.1 c -6.9,-1.5 -11.5,-4 -11.8,-8.3 0.4,-10.3 16.3,-10.7 25.6,-0.8 l 14.7,-11.3 c -9.2,-11.2 -19.6,-14.2 -30.3,-14.2 -16.3,0 -32.1,9.2 -32.1,26.6 0,16.9 13,26 27.3,28.2 7.3,1 15.4,3.9 15.2,8.9 -0.6,9.5 -20.2,9 -29.1,-1.8 l -14.2,13.3 c 8.3,10.7 19.6,16.1 30.2,16.1 16.3,0 34.4,-9.4 35.1,-26.6 C 412,84.5 396.2,79 380.4,76.1 Z m -67,55.5 h 22.4 V 42.8 H 313.4 Z M 691.1,42.8 H 657.9 V 80.1 L 680,100 V 63.8 h 11.8 c 7.5,0 11.2,3.6 11.2,9.4 v 27.7 c 0,5.8 -3.5,9.7 -11.2,9.7 h -34 v 21.1 h 33.3 c 17.8,0.1 34.5,-8.8 34.5,-29.2 V 72.7 c 0,-20.8 -16.7,-29.9 -34.5,-29.9 z M 528.2,41.6 c -18.4,0 -36.7,10 -36.7,30.5 v 30.3 c 0,20.3 18.4,30.5 36.9,30.5 18.4,0 36.7,-10.2 36.7,-30.5 V 72.1 c 0,-20.4 -18.5,-30.5 -36.9,-30.5 z m 14.4,60.8 c 0,6.4 -7.2,9.7 -14.3,9.7 -7.2,0 -14.4,-3.1 -14.4,-9.7 V 72.1 c 0,-6.5 7,-10 14,-10 7.3,0 14.7,3.1 14.7,10 z M 645.5,72.1 C 645,51.3 630.8,42.9 612.5,42.9 H 577 v 88.8 h 22.7 v -28.2 h 4 l 20.6,28.2 h 28 l -24.2,-30.5 c 10.7,-3.4 17.4,-12.7 17.4,-29.1 z m -32.6,12 H 599.7 V 63.8 h 13.2 c 14.1,0 14.1,20.3 0,20.3 z"
id="path4" />
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<title>earth</title>
<path d="M8 0c-4.418 0-8 3.582-8 8s3.582 8 8 8 8-3.582 8-8-3.582-8-8-8zM8 15c-0.984 0-1.92-0.203-2.769-0.57l3.643-4.098c0.081-0.092 0.126-0.21 0.126-0.332v-1.5c0-0.276-0.224-0.5-0.5-0.5-1.765 0-3.628-1.835-3.646-1.854-0.094-0.094-0.221-0.146-0.354-0.146h-2c-0.276 0-0.5 0.224-0.5 0.5v3c0 0.189 0.107 0.363 0.276 0.447l1.724 0.862v2.936c-1.813-1.265-3-3.366-3-5.745 0-1.074 0.242-2.091 0.674-3h1.826c0.133 0 0.26-0.053 0.354-0.146l2-2c0.094-0.094 0.146-0.221 0.146-0.354v-1.21c0.634-0.189 1.305-0.29 2-0.29 1.1 0 2.141 0.254 3.067 0.706-0.065 0.055-0.128 0.112-0.188 0.172-0.567 0.567-0.879 1.32-0.879 2.121s0.312 1.555 0.879 2.121c0.569 0.569 1.332 0.879 2.119 0.879 0.049 0 0.099-0.001 0.149-0.004 0.216 0.809 0.605 2.917-0.131 5.818-0.007 0.027-0.011 0.055-0.013 0.082-1.271 1.298-3.042 2.104-5.002 2.104z"></path>
</svg>

After

Width:  |  Height:  |  Size: 976 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<title>envelop</title>
<path d="M15 2h-14c-0.55 0-1 0.45-1 1v10c0 0.55 0.45 1 1 1h14c0.55 0 1-0.45 1-1v-10c0-0.55-0.45-1-1-1zM5.831 9.773l-3 2.182c-0.1 0.073-0.216 0.108-0.33 0.108-0.174 0-0.345-0.080-0.455-0.232-0.183-0.251-0.127-0.603 0.124-0.786l3-2.182c0.251-0.183 0.603-0.127 0.786 0.124s0.127 0.603-0.124 0.786zM13.955 11.831c-0.11 0.151-0.282 0.232-0.455 0.232-0.115 0-0.23-0.035-0.33-0.108l-3-2.182c-0.251-0.183-0.307-0.534-0.124-0.786s0.535-0.307 0.786-0.124l3 2.182c0.251 0.183 0.307 0.535 0.124 0.786zM13.831 4.955l-5.5 4c-0.099 0.072-0.215 0.108-0.331 0.108s-0.232-0.036-0.331-0.108l-5.5-4c-0.251-0.183-0.307-0.534-0.124-0.786s0.535-0.307 0.786-0.124l5.169 3.759 5.169-3.759c0.251-0.183 0.603-0.127 0.786 0.124s0.127 0.603-0.124 0.786z"></path>
</svg>

After

Width:  |  Height:  |  Size: 895 B

View File

@ -0,0 +1,8 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<title>envelop2</title>
<path d="M14.998 3c0.001 0.001 0.001 0.001 0.002 0.002v9.996c-0.001 0.001-0.001 0.001-0.002 0.002h-13.996c-0.001-0.001-0.001-0.001-0.002-0.002v-9.996c0.001-0.001 0.001-0.001 0.002-0.002h13.996zM15 2h-14c-0.55 0-1 0.45-1 1v10c0 0.55 0.45 1 1 1h14c0.55 0 1-0.45 1-1v-10c0-0.55-0.45-1-1-1v0z"></path>
<path d="M5.831 9.773l-3 2.182c-0.1 0.073-0.216 0.108-0.33 0.108-0.174 0-0.345-0.080-0.455-0.232-0.183-0.251-0.127-0.603 0.124-0.786l3-2.182c0.251-0.183 0.603-0.127 0.786 0.124s0.127 0.603-0.124 0.786z"></path>
<path d="M13.955 11.831c-0.11 0.151-0.282 0.232-0.455 0.232-0.115 0-0.23-0.035-0.33-0.108l-3-2.182c-0.251-0.183-0.307-0.534-0.124-0.786s0.534-0.307 0.786-0.124l3 2.182c0.251 0.183 0.307 0.535 0.124 0.786z"></path>
<path d="M13.831 4.955l-5.5 4c-0.099 0.072-0.215 0.108-0.331 0.108s-0.232-0.036-0.331-0.108l-5.5-4c-0.251-0.183-0.307-0.534-0.124-0.786s0.535-0.307 0.786-0.124l5.169 3.759 5.169-3.759c0.251-0.183 0.603-0.127 0.786 0.124s0.127 0.603-0.124 0.786v0z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<title>envelop3</title>
<path d="M15 2h-14c-0.55 0-1 0.45-1 1v10c0 0.55 0.45 1 1 1h14c0.55 0 1-0.45 1-1v-10c0-0.55-0.45-1-1-1zM14 4v0.719l-6 3.536-6-3.536v-0.719h12zM2 12v-5.54l6 3.536 6-3.536v5.54h-12z"></path>
</svg>

After

Width:  |  Height:  |  Size: 350 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<title>envelop4</title>
<path d="M14.5 2h-13c-0.825 0-1.5 0.675-1.5 1.5v10c0 0.825 0.675 1.5 1.5 1.5h13c0.825 0 1.5-0.675 1.5-1.5v-10c0-0.825-0.675-1.5-1.5-1.5zM6.23 8.6l-4.23 3.295v-7.838l4.23 4.543zM2.756 4h10.488l-5.244 3.938-5.244-3.938zM6.395 8.777l1.605 1.723 1.605-1.723 3.29 4.223h-9.79l3.29-4.223zM9.77 8.6l4.23-4.543v7.838l-4.23-3.295z"></path>
</svg>

After

Width:  |  Height:  |  Size: 493 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<title>envelop5</title>
<path d="M14.5 2h-13c-0.825 0-1.5 0.675-1.5 1.5v9c0 0.825 0.675 1.5 1.5 1.5h13c0.825 0 1.5-0.675 1.5-1.5v-9c0-0.825-0.675-1.5-1.5-1.5zM14 5l-6 4.5-6-4.5v-1l6 3 6-3v1z"></path>
</svg>

After

Width:  |  Height:  |  Size: 338 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<title>lan2</title>
<path d="M14.5 8h-6.5v-2h1.5c0.276 0 0.5-0.224 0.5-0.5v-4c0-0.276-0.224-0.5-0.5-0.5h-4c-0.276 0-0.5 0.224-0.5 0.5v4c0 0.276 0.224 0.5 0.5 0.5h1.5v2h-6.5c-0.276 0-0.5 0.224-0.5 0.5s0.224 0.5 0.5 0.5h2.5v2h-1.5c-0.276 0-0.5 0.224-0.5 0.5v4c0 0.276 0.224 0.5 0.5 0.5h4c0.276 0 0.5-0.224 0.5-0.5v-4c0-0.276-0.224-0.5-0.5-0.5h-1.5v-2h7v2h-1.5c-0.276 0-0.5 0.224-0.5 0.5v4c0 0.276 0.224 0.5 0.5 0.5h4c0.276 0 0.5-0.224 0.5-0.5v-4c0-0.276-0.224-0.5-0.5-0.5h-1.5v-2h2.5c0.276 0 0.5-0.224 0.5-0.5s-0.224-0.5-0.5-0.5zM6 2h3v3h-3v-3zM5 15h-3v-3h3v3zM13 15h-3v-3h3v3z"></path>
</svg>

After

Width:  |  Height:  |  Size: 723 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<title>notification2</title>
<path d="M8 0c-4.418 0-8 3.582-8 8s3.582 8 8 8 8-3.582 8-8-3.582-8-8-8zM9 13h-2v-2h2v2zM9 9h-2v-6h2v6z"></path>
</svg>

After

Width:  |  Height:  |  Size: 279 B

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<title>sphere</title>
<path d="M7.5 1c-4.142 0-7.5 3.358-7.5 7.5s3.358 7.5 7.5 7.5c4.142 0 7.5-3.358 7.5-7.5s-3.358-7.5-7.5-7.5zM11.744 11c0.134-0.632 0.219-1.303 0.246-2h1.991c-0.052 0.691-0.213 1.361-0.479 2h-1.758zM3.256 6c-0.134 0.632-0.219 1.303-0.246 2h-1.991c0.052-0.691 0.213-1.361 0.479-2h1.758zM10.719 6c0.15 0.64 0.241 1.31 0.27 2h-2.989v-2h2.719zM8 5v-2.927c0.228 0.066 0.454 0.178 0.675 0.334 0.415 0.293 0.813 0.744 1.149 1.304 0.233 0.388 0.434 0.819 0.601 1.289h-2.426zM5.176 3.711c0.336-0.561 0.734-1.012 1.149-1.304 0.222-0.156 0.447-0.268 0.675-0.334v2.927h-2.426c0.168-0.47 0.369-0.901 0.601-1.289zM7 6v2h-2.989c0.029-0.69 0.12-1.36 0.27-2h2.719zM1.498 11c-0.266-0.639-0.427-1.309-0.479-2h1.991c0.028 0.697 0.112 1.368 0.246 2h-1.758zM4.011 9h2.989v2h-2.719c-0.15-0.64-0.241-1.31-0.27-2zM7 12v2.927c-0.228-0.066-0.454-0.178-0.675-0.334-0.415-0.293-0.813-0.744-1.149-1.304-0.233-0.388-0.434-0.819-0.602-1.289h2.426zM9.825 13.289c-0.336 0.561-0.734 1.012-1.149 1.304-0.222 0.156-0.447 0.268-0.675 0.334v-2.927h2.426c-0.168 0.47-0.369 0.901-0.602 1.289zM8 11v-2h2.989c-0.029 0.69-0.12 1.36-0.27 2h-2.719zM11.99 8c-0.028-0.697-0.112-1.368-0.246-2h1.758c0.267 0.639 0.427 1.309 0.479 2h-1.991zM12.979 5h-1.498c-0.291-0.918-0.693-1.723-1.177-2.366 0.665 0.318 1.267 0.744 1.792 1.27 0.336 0.336 0.631 0.702 0.883 1.096zM2.904 3.904c0.526-0.526 1.128-0.952 1.792-1.27-0.483 0.643-0.886 1.448-1.177 2.366h-1.498c0.252-0.394 0.547-0.761 0.883-1.096zM2.021 12h1.498c0.291 0.918 0.693 1.723 1.177 2.366-0.665-0.318-1.267-0.744-1.792-1.27-0.336-0.336-0.631-0.702-0.883-1.096zM12.096 13.096c-0.526 0.526-1.128 0.952-1.792 1.27 0.483-0.643 0.886-1.448 1.177-2.366h1.498c-0.252 0.394-0.547 0.761-0.883 1.096z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -0,0 +1,7 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<title>watch</title>
<path d="M11 3h-0.5l-0.5-3h-5l-0.5 3h-0.5c-1.1 0-2 0.75-2 1.667v6.667c0 0.917 0.9 1.667 2 1.667h0.5l0.5 3h5l0.5-3h0.5c1.1 0 2-0.75 2-1.667v-6.667c0-0.917-0.9-1.667-2-1.667zM12 11.333c0 0.191-0.143 0.338-0.228 0.409-0.197 0.164-0.478 0.258-0.772 0.258h-7c-0.294 0-0.576-0.094-0.772-0.258-0.085-0.071-0.228-0.218-0.228-0.409v-6.667c0-0.191 0.143-0.338 0.228-0.409 0.197-0.164 0.478-0.258 0.772-0.258h7c0.294 0 0.576 0.094 0.772 0.258 0.085 0.071 0.228 0.218 0.228 0.409v6.667z"></path>
<path d="M6.5 6h-2c-0.276 0-0.5 0.224-0.5 0.5s0.224 0.5 0.5 0.5h1.5v1h-1.5c-0.276 0-0.5 0.224-0.5 0.5v2c0 0.276 0.224 0.5 0.5 0.5h2c0.276 0 0.5-0.224 0.5-0.5s-0.224-0.5-0.5-0.5h-1.5v-1h1.5c0.276 0 0.5-0.224 0.5-0.5v-2c0-0.276-0.224-0.5-0.5-0.5z"></path>
<path d="M10.5 6c-0.276 0-0.5 0.224-0.5 0.5v1.5h-1v-1.5c0-0.276-0.224-0.5-0.5-0.5s-0.5 0.224-0.5 0.5v2c0 0.276 0.224 0.5 0.5 0.5h1.5v1.5c0 0.276 0.224 0.5 0.5 0.5s0.5-0.224 0.5-0.5v-4c0-0.276-0.224-0.5-0.5-0.5z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -424,6 +424,94 @@ function initActorActions(store, router) {
}; };
} }
async function searchActors({ _commit }, { query, limit = 20, minLength = 2 }) {
const { actors } = await graphql(`
query SearchActors(
$query: String!
$limit: Int = 20
$minLength: Int = 2
$hasAuth: Boolean!
$userId: Int
) {
actors: searchActors(
search: $query,
minLength: $minLength
first: $limit
) {
id
name
slug
age
ageAtDeath
dateOfBirth
dateOfDeath
gender
aliasFor: actorByAliasFor {
id
name
slug
age
ageAtDeath
dateOfBirth
dateOfDeath
gender
entity {
id
name
slug
}
avatar: avatarMedia {
id
path
thumbnail
lazy
isS3
width
height
comment
credit
}
birthCountry: countryByBirthCountryAlpha2 {
alpha2
name
alias
}
}
entity {
id
name
slug
}
avatar: avatarMedia {
id
path
thumbnail
lazy
isS3
width
height
comment
credit
}
birthCountry: countryByBirthCountryAlpha2 {
alpha2
name
alias
}
${actorStashesFields}
}
}
`, {
query,
limit,
minLength,
hasAuth: !!store.state.auth.user,
userId: store.state.auth.user?.id,
});
return actors.map(actor => curateActor(actor));
}
async function fetchActorReleases({ _commit }, actorId) { async function fetchActorReleases({ _commit }, actorId) {
const releases = await get(`/actors/${actorId}/releases`, { const releases = await get(`/actors/${actorId}/releases`, {
filter: store.state.ui.filter, filter: store.state.ui.filter,
@ -438,6 +526,7 @@ function initActorActions(store, router) {
fetchActorById, fetchActorById,
fetchActors, fetchActors,
fetchActorReleases, fetchActorReleases,
searchActors,
}; };
} }

View File

@ -1,6 +1,6 @@
import { get, post, del } from '../api'; import { get, post, del } from '../api';
function initAuthActions(_store, _router) { function initAuthActions(store, _router) {
async function fetchMe({ commit }) { async function fetchMe({ commit }) {
try { try {
const user = await get('/session'); const user = await get('/session');
@ -18,6 +18,7 @@ function initAuthActions(_store, _router) {
const user = await post('/session', credentials); const user = await post('/session', credentials);
commit('setUser', user); commit('setUser', user);
await store.dispatch('fetchNotifications');
return user; return user;
} }

View File

@ -35,4 +35,5 @@ export default {
'squirting', 'squirting',
'swallowing', 'swallowing',
], ],
discord: 'https://discord.gg/6mjHTYG',
}; };

View File

@ -152,6 +152,32 @@ function curateStash(stash) {
return curatedStash; return curatedStash;
} }
function curateAlert(alert) {
if (!alert) {
return null;
}
const curatedAlert = alert;
if (alert.actors) {
curatedAlert.actors = alert.actors.map(actor => curateActor(actor.actor || actor));
}
if (alert.tags) {
curatedAlert.tags = alert.tags.map(tag => curateTag(tag.tag || tag));
}
if (alert.entity) {
curatedAlert.entity = curateEntity(alert.entity.entity || alert.entity);
}
if (alert.stashes) {
curatedAlert.stashes = alert.stashes.map(stash => curateStash(stash.stash || stash));
}
return curatedAlert;
}
function curateUser(user) { function curateUser(user) {
if (!user) { if (!user) {
return null; return null;
@ -163,13 +189,30 @@ function curateUser(user) {
curatedUser.stashes = user.stashes.map(stash => curateStash(stash.stash || stash)); curatedUser.stashes = user.stashes.map(stash => curateStash(stash.stash || stash));
} }
if (user.alerts) {
curatedUser.alerts = user.alerts.map(alert => curateAlert(alert.alert || alert));
}
return curatedUser; return curatedUser;
} }
function curateNotification(notification) {
const curatedNotification = notification;
curatedNotification.scene = curateRelease(notification.scene);
if (notification.alert) {
curatedNotification.alert = curateAlert(notification.alert.alert || notification.alert);
}
return curatedNotification;
}
export { export {
curateActor, curateActor,
curateEntity, curateEntity,
curateRelease, curateRelease,
curateNotification,
curateTag, curateTag,
curateStash, curateStash,
curateUser, curateUser,

View File

@ -250,6 +250,7 @@ function initEntitiesActions(store, router) {
search: $query, search: $query,
first: $limit first: $limit
) { ) {
id
name name
slug slug
type type

View File

@ -280,9 +280,10 @@ const releasesFragment = `
releasesTagsConnection: { releasesTagsConnection: {
none: { none: {
tag: { tag: {
slug: { or: [
in: $exclude { slug: { in: $exclude } }
} { name: { in: $exclude } }
]
} }
} }
} }
@ -477,6 +478,7 @@ function getIncludedActors(router) {
} }
export { export {
actorFields,
actorStashesFields, actorStashesFields,
releaseActorsFragment, releaseActorsFragment,
releaseFields, releaseFields,

View File

@ -2,6 +2,7 @@ import config from 'config';
import { createApp } from 'vue'; import { createApp } from 'vue';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import mitt from 'mitt'; import mitt from 'mitt';
import tippy from 'tippy.js';
import router from './router'; import router from './router';
import initStore from './store'; import initStore from './store';
@ -11,6 +12,7 @@ import initAuthObservers from './auth/observers';
import { formatDate, formatDuration } from './format'; import { formatDate, formatDuration } from './format';
import '../css/style.scss'; import '../css/style.scss';
import 'tippy.js/dist/tippy.css';
import Container from '../components/container/container.vue'; import Container from '../components/container/container.vue';
import Icon from '../components/icon/icon.vue'; import Icon from '../components/icon/icon.vue';
@ -94,6 +96,9 @@ async function init() {
config, config,
}; };
}, },
computed: {
theme() { return this.$store.state.ui.theme; },
},
watch: { watch: {
pageTitle(title) { pageTitle(title) {
if (title) { if (title) {
@ -120,8 +125,17 @@ async function init() {
app.directive('tooltip', { app.directive('tooltip', {
beforeMount(el, binding) { beforeMount(el, binding) {
// console.log(binding.modifiers); // don't include HTML in native title attribute
el.title = binding.value; // eslint-disable-line no-param-reassign const textEl = document.createElement('div');
textEl.innerHTML = binding.value;
el.title = textEl.textContent; // eslint-disable-line no-param-reassign
tippy(el, {
content: binding.value,
allowHTML: true,
duration: [0, 0],
});
}, },
}); });

View File

@ -10,6 +10,7 @@ import Networks from '../components/networks/networks.vue';
import Actor from '../components/actors/actor.vue'; import Actor from '../components/actors/actor.vue';
import Actors from '../components/actors/actors.vue'; import Actors from '../components/actors/actors.vue';
import Movies from '../components/releases/movies.vue'; import Movies from '../components/releases/movies.vue';
import Notifications from '../components/notifications/notifications.vue';
import Tag from '../components/tags/tag.vue'; import Tag from '../components/tags/tag.vue';
import Tags from '../components/tags/tags.vue'; import Tags from '../components/tags/tags.vue';
import Stash from '../components/stashes/stash.vue'; import Stash from '../components/stashes/stash.vue';
@ -202,6 +203,20 @@ const routes = [
component: Tags, component: Tags,
name: 'tags', name: 'tags',
}, },
{
path: '/notifications',
redirect: {
name: 'notifications',
params: {
pageNumber: 1,
},
},
},
{
path: '/notifications/:pageNumber',
component: Notifications,
name: 'notifications',
},
{ {
path: '/stash/:stashId/:stashSlug?', path: '/stash/:stashId/:stashSlug?',
component: Stash, component: Stash,

View File

@ -211,6 +211,72 @@ function initTagsActions(store, _router) {
return tags.map(tag => curateTag(tag, store.state.ui.sfw)); return tags.map(tag => curateTag(tag, store.state.ui.sfw));
} }
async function searchTags({ _commit }, {
limit = 100,
minLength = 2,
query,
_group,
_priority,
}) {
const { tags } = await graphql(`
query SearchTags(
$query: String!,
$limit: Int = 100
$minLength: Int = 2
) {
tags: searchTags(
search: $query,
first: $limit
minLength: $minLength
) {
id
name
slug
poster: tagsPosterByTagId {
media {
thumbnail
comment
lazy
width
height
thumbnailWidth
thumbnailHeight
entity {
id
name
slug
type
independent
parent {
id
name
slug
type
independent
}
}
sfw: sfwMedia {
thumbnail
comment
lazy
}
}
}
group {
name
slug
}
}
}
`, {
query,
limit,
minLength,
});
return tags.map(tag => curateTag(tag, store.state.ui.sfw));
}
async function fetchTagReleases({ _commit }, tagId) { async function fetchTagReleases({ _commit }, tagId) {
const releases = await get(`/tags/${tagId}/releases`, { const releases = await get(`/tags/${tagId}/releases`, {
filter: store.state.ui.filter, filter: store.state.ui.filter,
@ -225,6 +291,7 @@ function initTagsActions(store, _router) {
fetchTagBySlug, fetchTagBySlug,
fetchTags, fetchTags,
fetchTagReleases, fetchTagReleases,
searchTags,
}; };
} }

View File

@ -1,6 +1,6 @@
import { graphql } from '../api'; import { graphql, patch } from '../api';
import { releaseFields, actorStashesFields } from '../fragments'; import { releaseFields, actorStashesFields } from '../fragments';
import { curateRelease, curateActor } from '../curate'; import { curateRelease, curateActor, curateNotification } from '../curate';
function initUiActions(store, _router) { function initUiActions(store, _router) {
function setTagFilter({ commit }, filter) { function setTagFilter({ commit }, filter) {
@ -29,6 +29,93 @@ function initUiActions(store, _router) {
localStorage.setItem('sfw', sfw); localStorage.setItem('sfw', sfw);
} }
async function fetchNotifications(_context, { page = 1, limit = 10 } = {}) {
if (!store.state.auth.user) {
return [];
}
const { notifications, unseenNotifications } = await graphql(`
query Notifications(
$hasAuth: Boolean!
$userId: Int
$limit: Int = 10
$offset: Int = 0
) {
notifications: notificationsConnection(
first: $limit
offset: $offset
orderBy: CREATED_AT_DESC
) {
nodes {
id
sceneId
userId
seen
createdAt
scene {
${releaseFields}
}
alert {
tags: alertsTags {
tag {
id
name
slug
}
}
actors: alertsActors {
actor {
id
name
slug
}
}
entity: alertsEntityByAlertId {
entity {
id
name
slug
independent
}
}
}
}
totalCount
}
unseenNotifications: notificationsConnection(
filter: { seen: { equalTo: false } }
) {
totalCount
}
}
`, {
hasAuth: !!store.state.auth.user,
userId: store.state.auth.user?.id,
limit,
offset: (page - 1) * limit,
});
const curatedNotifications = notifications.nodes.map(notification => curateNotification(notification));
return {
notifications: curatedNotifications,
totalCount: notifications.totalCount,
unseenCount: unseenNotifications.totalCount,
};
}
async function checkNotification(context, notificationId) {
await patch(`/users/${store.state.auth.user?.id}/notifications/${notificationId}`, {
seen: true,
});
}
async function checkNotifications() {
await patch(`/users/${store.state.auth.user?.id}/notifications`, {
seen: true,
});
}
async function search({ _commit }, { query, limit = 20 }) { async function search({ _commit }, { query, limit = 20 }) {
const res = await graphql(` const res = await graphql(`
query SearchReleases( query SearchReleases(
@ -126,9 +213,11 @@ function initUiActions(store, _router) {
userId: store.state.auth.user?.id, userId: store.state.auth.user?.id,
}); });
console.log(res.results);
return { return {
releases: res.results.map(result => curateRelease(result.release)), releases: res?.results.map(result => curateRelease(result.release)) || [],
actors: res.actors.map(actor => curateActor(actor)), actors: res?.actors.map(actor => curateActor(actor)) || [],
}; };
} }
@ -170,12 +259,15 @@ function initUiActions(store, _router) {
} }
return { return {
checkNotification,
checkNotifications,
search, search,
setTagFilter, setTagFilter,
setRange, setRange,
setBatch, setBatch,
setSfw, setSfw,
setTheme, setTheme,
fetchNotifications,
fetchStats, fetchStats,
}; };
} }

View File

@ -1,4 +1,4 @@
function initUiObservers(store, _router) { async function initUiObservers(store, _router) {
const body = document.querySelector('body'); const body = document.querySelector('body');
body.classList.add(store.state.ui.theme); body.classList.add(store.state.ui.theme);
@ -29,6 +29,8 @@ function initUiObservers(store, _router) {
store.dispatch('setTheme', 'light'); store.dispatch('setTheme', 'light');
} }
}); });
await store.dispatch('fetchNotifications');
} }
export default initUiObservers; export default initUiObservers;

View File

@ -1,5 +1,5 @@
import { graphql } from '../api'; import { graphql, post, del } from '../api';
import { releaseFields } from '../fragments'; import { actorFields, releaseFields } from '../fragments';
import { curateUser } from '../curate'; import { curateUser } from '../curate';
function initUsersActions(store, _router) { function initUsersActions(store, _router) {
@ -55,6 +55,48 @@ function initUsersActions(store, _router) {
} }
} }
} }
alerts {
id
notify
email
stashes: alertsStashes {
stash {
id
name
slug
}
}
tags: alertsTags {
tag {
id
name
slug
}
}
actors: alertsActors {
actor {
${actorFields}
}
}
entity: alertsEntityByAlertId {
entity {
id
name
slug
type
independent
hasLogo
parent {
id
name
slug
type
independent
hasLogo
}
}
}
}
} }
} }
`, { `, {
@ -66,8 +108,18 @@ function initUsersActions(store, _router) {
return curateUser(user); return curateUser(user);
} }
async function addAlert(context, alert) {
return post('/alerts', alert);
}
async function removeAlert(context, alertId) {
return del(`/alerts/${alertId}`);
}
return { return {
addAlert,
fetchUser, fetchUser,
removeAlert,
}; };
} }

View File

@ -1138,6 +1138,136 @@ exports.up = knex => Promise.resolve()
.notNullable() .notNullable()
.defaultTo(knex.fn.now()); .defaultTo(knex.fn.now());
})) }))
.then(() => knex.schema.createTable('alerts', (table) => {
table.increments('id');
table.integer('user_id')
.notNullable()
.references('id')
.inTable('users')
.onDelete('cascade');
table.boolean('notify')
.defaultTo(false);
table.boolean('email')
.defaultTo(false);
table.datetime('created_at')
.notNullable()
.defaultTo(knex.fn.now());
}))
.then(() => knex.schema.createTable('alerts_scenes', (table) => {
table.increments('id');
table.integer('alert_id')
.notNullable()
.references('id')
.inTable('alerts')
.onDelete('cascade');
table.integer('scene_id')
.notNullable()
.references('id')
.inTable('releases')
.onDelete('cascade');
table.unique(['alert_id', 'scene_id']);
}))
.then(() => knex.schema.createTable('alerts_actors', (table) => {
table.increments('id');
table.integer('alert_id')
.notNullable()
.references('id')
.inTable('alerts')
.onDelete('cascade');
table.integer('actor_id')
.notNullable()
.references('id')
.inTable('actors')
.onDelete('cascade');
table.unique(['alert_id', 'actor_id']);
}))
.then(() => knex.schema.createTable('alerts_tags', (table) => {
table.increments('id');
table.integer('alert_id')
.notNullable()
.references('id')
.inTable('alerts')
.onDelete('cascade');
table.integer('tag_id')
.notNullable()
.references('id')
.inTable('tags')
.onDelete('cascade');
table.unique(['alert_id', 'tag_id']);
}))
.then(() => knex.schema.createTable('alerts_entities', (table) => {
table.increments('id');
table.integer('alert_id')
.notNullable()
.unique()
.references('id')
.inTable('alerts')
.onDelete('cascade');
table.integer('entity_id')
.notNullable()
.references('id')
.inTable('entities')
.onDelete('cascade');
}))
.then(() => knex.schema.createTable('alerts_stashes', (table) => {
table.increments('id');
table.integer('alert_id')
.notNullable()
.references('id')
.inTable('alerts')
.onDelete('cascade');
table.integer('stash_id')
.notNullable()
.references('id')
.inTable('stashes')
.onDelete('cascade');
table.unique(['alert_id', 'stash_id']);
}))
.then(() => knex.schema.createTable('notifications', (table) => {
table.increments('id');
table.integer('user_id')
.notNullable()
.references('id')
.inTable('users')
.onDelete('cascade');
table.integer('scene_id')
.references('id')
.inTable('releases')
.onDelete('cascade');
table.integer('alert_id')
.references('id')
.inTable('alerts')
.onDelete('set null');
table.boolean('seen')
.notNullable()
.defaultTo(false);
table.datetime('created_at')
.notNullable()
.defaultTo(knex.fn.now());
}))
// SEARCH // SEARCH
.then(() => { // eslint-disable-line arrow-body-style .then(() => { // eslint-disable-line arrow-body-style
// allow vim fold // allow vim fold
@ -1174,7 +1304,7 @@ exports.up = knex => Promise.resolve()
SELECT releases.id, ranks.rank FROM ( SELECT releases.id, ranks.rank FROM (
SELECT SELECT
releases_search.release_id, releases_search.release_id,
ts_rank(releases_search.document, to_tsquery('english', array_to_string(array(SELECT * FROM regexp_matches(query, '\\w+', 'g')), '|'))) AS rank ts_rank(releases_search.document, to_tsquery('english', array_to_string(array(SELECT * FROM regexp_matches(query, '[A-Za-zÀ-ÖØ-öø-ÿ0-9]+', 'g')), '|'))) AS rank
FROM releases_search FROM releases_search
) ranks ) ranks
LEFT JOIN releases ON releases.id = ranks.release_id LEFT JOIN releases ON releases.id = ranks.release_id
@ -1192,12 +1322,23 @@ exports.up = knex => Promise.resolve()
url ILIKE ('%' || search || '%') url ILIKE ('%' || search || '%')
$$ LANGUAGE SQL STABLE; $$ LANGUAGE SQL STABLE;
CREATE FUNCTION search_actors(search text, min_length numeric DEFAULT 2) RETURNS SETOF actors AS $$ CREATE FUNCTION search_actors(search text, min_length smallint DEFAULT 2) RETURNS SETOF actors AS $$
SELECT * FROM actors SELECT * FROM actors
WHERE length(search) >= min_length WHERE length(search) >= min_length
AND name ILIKE ('%' || TRIM(search) || '%') AND name ILIKE ('%' || TRIM(search) || '%')
$$ LANGUAGE SQL STABLE; $$ LANGUAGE SQL STABLE;
CREATE FUNCTION search_tags(search text, min_length smallint DEFAULT 2, is_primary boolean DEFAULT true) RETURNS SETOF tags AS $$
SELECT * FROM tags
WHERE length(search) >= min_length
AND name ILIKE ('%' || TRIM(search) || '%')
AND CASE
WHEN is_primary
THEN tags.alias_for IS NULL
ELSE true
END
$$ LANGUAGE SQL STABLE;
CREATE FUNCTION actors_tags(actor actors, selectable_tags text[]) RETURNS SETOF tags AS $$ CREATE FUNCTION actors_tags(actor actors, selectable_tags text[]) RETURNS SETOF tags AS $$
SELECT tags.* SELECT tags.*
FROM releases_actors FROM releases_actors
@ -1374,6 +1515,71 @@ exports.up = knex => Promise.resolve()
WHERE stashes.id = stashes_actors.stash_id WHERE stashes.id = stashes_actors.stash_id
AND (stashes.user_id = current_user_id() OR stashes.public) AND (stashes.user_id = current_user_id() OR stashes.public)
)); ));
ALTER TABLE alerts ENABLE ROW LEVEL SECURITY;
ALTER TABLE alerts_tags ENABLE ROW LEVEL SECURITY;
ALTER TABLE alerts_scenes ENABLE ROW LEVEL SECURITY;
ALTER TABLE alerts_actors ENABLE ROW LEVEL SECURITY;
ALTER TABLE alerts_entities ENABLE ROW LEVEL SECURITY;
ALTER TABLE alerts_stashes ENABLE ROW LEVEL SECURITY;
CREATE POLICY alerts_policy_select ON alerts FOR SELECT USING (alerts.user_id = current_user_id());
CREATE POLICY alerts_policy_update ON alerts FOR UPDATE USING (alerts.user_id = current_user_id());
CREATE POLICY alerts_policy_delete ON alerts FOR DELETE USING (alerts.user_id = current_user_id());
CREATE POLICY alerts_policy_insert ON alerts FOR INSERT WITH CHECK (true);
CREATE POLICY alerts_policy ON alerts_scenes
USING (EXISTS (
SELECT *
FROM alerts
WHERE alerts.id = alerts_scenes.alert_id
AND alerts.user_id = current_user_id()
));
CREATE POLICY alerts_policy ON alerts_actors
USING (EXISTS (
SELECT *
FROM alerts
WHERE alerts.id = alerts_actors.alert_id
AND alerts.user_id = current_user_id()
));
CREATE POLICY alerts_policy ON alerts_entities
USING (EXISTS (
SELECT *
FROM alerts
WHERE alerts.id = alerts_entities.alert_id
AND alerts.user_id = current_user_id()
));
CREATE POLICY alerts_policy ON alerts_tags
USING (EXISTS (
SELECT *
FROM alerts
WHERE alerts.id = alerts_tags.alert_id
AND alerts.user_id = current_user_id()
));
CREATE POLICY alerts_policy ON alerts_stashes
USING (EXISTS (
SELECT *
FROM alerts
WHERE alerts.id = alerts_stashes.alert_id
AND alerts.user_id = current_user_id()
));
ALTER TABLE notifications ENABLE ROW LEVEL SECURITY;
CREATE POLICY notifications_policy_select ON notifications FOR SELECT USING (notifications.user_id = current_user_id());
CREATE POLICY notifications_policy_update ON notifications FOR UPDATE USING (notifications.user_id = current_user_id());
CREATE POLICY notifications_policy_delete ON notifications FOR DELETE USING (notifications.user_id = current_user_id());
CREATE POLICY notifications_policy_insert ON notifications FOR INSERT WITH CHECK (true);
ALTER TABLE releases_photos ENABLE ROW LEVEL SECURITY;
CREATE POLICY releases_photos_select ON releases_photos FOR SELECT USING (current_user_id() IS NOT NULL);
ALTER TABLE releases_trailers ENABLE ROW LEVEL SECURITY;
CREATE POLICY releases_trailers_select ON releases_trailers FOR SELECT USING (current_user_id() IS NOT NULL);
`, { `, {
visitor: knex.raw(config.database.query.user), visitor: knex.raw(config.database.query.user),
}); });
@ -1398,6 +1604,8 @@ exports.up = knex => Promise.resolve()
COMMENT ON FUNCTION actors_scenes IS E'@sortable'; COMMENT ON FUNCTION actors_scenes IS E'@sortable';
COMMENT ON FUNCTION tags_scenes IS E'@sortable'; COMMENT ON FUNCTION tags_scenes IS E'@sortable';
COMMENT ON FUNCTION search_releases IS E'@sortable'; COMMENT ON FUNCTION search_releases IS E'@sortable';
COMMENT ON FUNCTION search_actors IS E'@sortable';
COMMENT ON FUNCTION search_tags IS E'@sortable';
`); `);
}); });
@ -1463,11 +1671,20 @@ exports.down = (knex) => { // eslint-disable-line arrow-body-style
DROP TABLE IF EXISTS entities_types CASCADE; DROP TABLE IF EXISTS entities_types CASCADE;
DROP TABLE IF EXISTS entities CASCADE; DROP TABLE IF EXISTS entities CASCADE;
DROP TABLE IF EXISTS notifications CASCADE;
DROP TABLE IF EXISTS stashes_scenes CASCADE; DROP TABLE IF EXISTS stashes_scenes CASCADE;
DROP TABLE IF EXISTS stashes_movies CASCADE; DROP TABLE IF EXISTS stashes_movies CASCADE;
DROP TABLE IF EXISTS stashes_actors CASCADE; DROP TABLE IF EXISTS stashes_actors CASCADE;
DROP TABLE IF EXISTS stashes CASCADE; DROP TABLE IF EXISTS stashes CASCADE;
DROP TABLE IF EXISTS alerts_scenes CASCADE;
DROP TABLE IF EXISTS alerts_actors CASCADE;
DROP TABLE IF EXISTS alerts_tags CASCADE;
DROP TABLE IF EXISTS alerts_entities CASCADE;
DROP TABLE IF EXISTS alerts_stashes CASCADE;
DROP TABLE IF EXISTS alerts CASCADE;
DROP TABLE IF EXISTS users CASCADE; DROP TABLE IF EXISTS users CASCADE;
DROP TABLE IF EXISTS users_roles CASCADE; DROP TABLE IF EXISTS users_roles CASCADE;
@ -1475,6 +1692,7 @@ exports.down = (knex) => { // eslint-disable-line arrow-body-style
DROP FUNCTION IF EXISTS search_sites; DROP FUNCTION IF EXISTS search_sites;
DROP FUNCTION IF EXISTS search_entities; DROP FUNCTION IF EXISTS search_entities;
DROP FUNCTION IF EXISTS search_actors; DROP FUNCTION IF EXISTS search_actors;
DROP FUNCTION IF EXISTS search_tags;
DROP FUNCTION IF EXISTS get_random_sfw_media_id; DROP FUNCTION IF EXISTS get_random_sfw_media_id;
DROP FUNCTION IF EXISTS releases_is_new; DROP FUNCTION IF EXISTS releases_is_new;

35
package-lock.json generated
View File

@ -1,11 +1,11 @@
{ {
"name": "traxxx", "name": "traxxx",
"version": "1.192.2", "version": "1.193.0",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"version": "1.192.2", "version": "1.193.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@casl/ability": "^5.2.2", "@casl/ability": "^5.2.2",
@ -68,6 +68,7 @@
"showdown": "^1.9.1", "showdown": "^1.9.1",
"source-map-support": "^0.5.16", "source-map-support": "^0.5.16",
"template-format": "^1.2.5", "template-format": "^1.2.5",
"tippy.js": "^6.3.1",
"tough-cookie": "^3.0.1", "tough-cookie": "^3.0.1",
"tty-table": "^2.8.12", "tty-table": "^2.8.12",
"tunnel": "0.0.6", "tunnel": "0.0.6",
@ -1398,6 +1399,15 @@
"upath": "^1.1.1" "upath": "^1.1.1"
} }
}, },
"node_modules/@popperjs/core": {
"version": "2.9.2",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.9.2.tgz",
"integrity": "sha512-VZMYa7+fXHdwIq1TDhSXoVmSPEGM/aa+6Aiq3nVVJ9bXr24zScr+NlKFKC3iPljA7ho/GAZr+d2jOf5GIRC30Q==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@tokenizer/token": { "node_modules/@tokenizer/token": {
"version": "0.1.1", "version": "0.1.1",
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.1.1.tgz", "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.1.1.tgz",
@ -13748,6 +13758,14 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/tippy.js": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.1.tgz",
"integrity": "sha512-JnFncCq+rF1dTURupoJ4yPie5Cof978inW6/4S6kmWV7LL9YOSEVMifED3KdrVPEG+Z/TFH2CDNJcQEfaeuQww==",
"dependencies": {
"@popperjs/core": "^2.8.3"
}
},
"node_modules/tmp": { "node_modules/tmp": {
"version": "0.0.33", "version": "0.0.33",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
@ -16755,6 +16773,11 @@
"upath": "^1.1.1" "upath": "^1.1.1"
} }
}, },
"@popperjs/core": {
"version": "2.9.2",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.9.2.tgz",
"integrity": "sha512-VZMYa7+fXHdwIq1TDhSXoVmSPEGM/aa+6Aiq3nVVJ9bXr24zScr+NlKFKC3iPljA7ho/GAZr+d2jOf5GIRC30Q=="
},
"@tokenizer/token": { "@tokenizer/token": {
"version": "0.1.1", "version": "0.1.1",
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.1.1.tgz", "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.1.1.tgz",
@ -26804,6 +26827,14 @@
"resolved": "https://registry.npmjs.org/tildify/-/tildify-2.0.0.tgz", "resolved": "https://registry.npmjs.org/tildify/-/tildify-2.0.0.tgz",
"integrity": "sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw==" "integrity": "sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw=="
}, },
"tippy.js": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.1.tgz",
"integrity": "sha512-JnFncCq+rF1dTURupoJ4yPie5Cof978inW6/4S6kmWV7LL9YOSEVMifED3KdrVPEG+Z/TFH2CDNJcQEfaeuQww==",
"requires": {
"@popperjs/core": "^2.8.3"
}
},
"tmp": { "tmp": {
"version": "0.0.33", "version": "0.0.33",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",

View File

@ -1,6 +1,6 @@
{ {
"name": "traxxx", "name": "traxxx",
"version": "1.192.2", "version": "1.193.0",
"description": "All the latest porn releases in one place", "description": "All the latest porn releases in one place",
"main": "src/app.js", "main": "src/app.js",
"scripts": { "scripts": {
@ -129,6 +129,7 @@
"showdown": "^1.9.1", "showdown": "^1.9.1",
"source-map-support": "^0.5.16", "source-map-support": "^0.5.16",
"template-format": "^1.2.5", "template-format": "^1.2.5",
"tippy.js": "^6.3.1",
"tough-cookie": "^3.0.1", "tough-cookie": "^3.0.1",
"tty-table": "^2.8.12", "tty-table": "^2.8.12",
"tunnel": "0.0.6", "tunnel": "0.0.6",

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@ -2989,7 +2989,7 @@ const sites = [
{ {
name: 'Black Ambush', name: 'Black Ambush',
slug: 'blackambush', slug: 'blackambush',
alias: ['interracial', 'bbc'], tags: ['bbc'],
url: 'https://blackambush.com', url: 'https://blackambush.com',
parent: 'exploitedx', parent: 'exploitedx',
}, },
@ -4554,7 +4554,7 @@ const sites = [
{ {
slug: 'legalporno', slug: 'legalporno',
name: 'LegalPorno', name: 'LegalPorno',
alias: ['clip'], alias: ['clip', 'analvids', 'gonzo'],
url: 'https://www.legalporno.com', url: 'https://www.legalporno.com',
description: 'The Best HD Porn For You!', description: 'The Best HD Porn For You!',
independent: true, independent: true,

View File

@ -631,6 +631,7 @@ const tagMedia = [
['ass-eating', 0, 'Angelica Heart and Leanna Sweet in "ATM Bitches"', 'assholefever'], ['ass-eating', 0, 'Angelica Heart and Leanna Sweet in "ATM Bitches"', 'assholefever'],
['ass-eating', 3, 'Abella Danger and Karma Rx in "Neon Dreaming"', 'brazzers'], ['ass-eating', 3, 'Abella Danger and Karma Rx in "Neon Dreaming"', 'brazzers'],
['atm', 2, 'Jureka Del Mar in "Stretched Out"', 'herlimit'], ['atm', 2, 'Jureka Del Mar in "Stretched Out"', 'herlimit'],
['atm', 'rebel_rhyder_analonly_1', 'Rebel Rhyder in "Advanced Anal With Rebel"', 'analonly'],
['atm', 3, 'Natasha Teen in "Work That Ass!"', 'herlimit'], ['atm', 3, 'Natasha Teen in "Work That Ass!"', 'herlimit'],
['atm', 7, 'Mandy Muse in "Mandy\'s Anal Amusement"', 'bigwettbutts'], ['atm', 7, 'Mandy Muse in "Mandy\'s Anal Amusement"', 'bigwettbutts'],
['atm', 0, 'Roxy Lips in "Under Her Coat"', '21naturals'], ['atm', 0, 'Roxy Lips in "Under Her Coat"', '21naturals'],
@ -762,8 +763,9 @@ const tagMedia = [
['dv-tp', 0, 'Luna Rival in SZ1490'], ['dv-tp', 0, 'Luna Rival in SZ1490'],
['facefucking', 5, 'Mia Moore B', 'throated'], ['facefucking', 5, 'Mia Moore B', 'throated'],
['facefucking', 6, 'Halle Hayes in "Towering Temptress"', '5kporn'], ['facefucking', 6, 'Halle Hayes in "Towering Temptress"', '5kporn'],
['facefucking', 7, 'Anya Olsen and Audrey Snow in "Babysitter Busted Giving A BJ"', 'mommyblowsbest'], ['facefucking', 'adria_rae_throated', 'Adria Rae in "Adria Rae Sucks Cock All Day"', 'throated'],
['facefucking', 1, 'Paige Owens in "Dark Meat 12"', 'evilangel'], ['facefucking', 1, 'Paige Owens in "Dark Meat 12"', 'evilangel'],
['facefucking', 7, 'Anya Olsen and Audrey Snow in "Babysitter Busted Giving A BJ"', 'mommyblowsbest'],
['facefucking', 0, 'Ashly Anderson in "Rough Love"', 'hookuphotshot'], ['facefucking', 0, 'Ashly Anderson in "Rough Love"', 'hookuphotshot'],
['facefucking', 2, 'Jynx Maze', 'throated'], ['facefucking', 2, 'Jynx Maze', 'throated'],
['facefucking', 4, 'Brooklyn Gray in "Throats Fucks 6"', 'evilangel'], ['facefucking', 4, 'Brooklyn Gray in "Throats Fucks 6"', 'evilangel'],
@ -792,7 +794,7 @@ const tagMedia = [
['enhanced-boobs', '23d', 'Lulu Sex Bomb in "Tropical Touch"'], ['enhanced-boobs', '23d', 'Lulu Sex Bomb in "Tropical Touch"'],
['enhanced-boobs', 22, 'Sakura Sena'], ['enhanced-boobs', 22, 'Sakura Sena'],
['enhanced-boobs', 'mareeva_trudy_photodromm_1', 'Mareeva and Trudy', 'photodromm'], ['enhanced-boobs', 'mareeva_trudy_photodromm_1', 'Mareeva and Trudy', 'photodromm'],
['enhanced-boobs', 'lara_frost_legalporno', 'Lara Frost in NRX059', 'legalporno'], ['enhanced-boobs', 'lara_frost_handsonhardcore', 'Lara Frost in "Handyman & Hubby Try To Satisfy Horny Little Ukrainian Nympho"', 'handsonhardcore'],
['enhanced-boobs', 'shawna_lenee_inthecrack_3', 'Shawna Lenee', 'inthecrack'], ['enhanced-boobs', 'shawna_lenee_inthecrack_3', 'Shawna Lenee', 'inthecrack'],
['enhanced-boobs', 16, 'Marsha May in "Once You Go Black 7"', 'julesjordan'], ['enhanced-boobs', 16, 'Marsha May in "Once You Go Black 7"', 'julesjordan'],
['enhanced-boobs', 'azul_hermosa_pornstarslikeitbig', 'Azul Hermosa in "She Likes Rough Quickies"', 'pornstarslikeitbig'], ['enhanced-boobs', 'azul_hermosa_pornstarslikeitbig', 'Azul Hermosa in "She Likes Rough Quickies"', 'pornstarslikeitbig'],

View File

@ -6,7 +6,7 @@
}, },
"rules": { "rules": {
"strict": 0, "strict": 0,
"indent": ["error", "tab"], "indent": "off",
"no-tabs": "off", "no-tabs": "off",
"no-unused-vars": ["error", {"argsIgnorePattern": "^_"}], "no-unused-vars": ["error", {"argsIgnorePattern": "^_"}],
"no-console": 0, "no-console": 0,

160
src/alerts.js Normal file
View File

@ -0,0 +1,160 @@
'use strict';
const knex = require('./knex');
const { HttpError } = require('./errors');
async function addAlert(alert, sessionUser) {
if (!sessionUser) {
throw new HttpError('You are not authenthicated', 401);
}
if (!alert.actors?.length > 0 && !alert.tags?.length > 0 && !alert.entity) {
throw new HttpError('Alert must contain at least one actor, tag or entity', 400);
}
const [alertId] = await knex('alerts')
.insert({
user_id: sessionUser.id,
notify: alert.notify,
email: alert.email,
})
.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.stashes?.length > 0 && knex('alerts_stashes')
.insert(alert.stashes.map(stashId => ({
alert_id: alertId,
stash_id: stashId,
}))),
alert.entity && knex('alerts_entities').insert({
alert_id: alertId,
entity_id: alert.entity,
}),
]);
return alertId;
}
async function removeAlert(alertId) {
await knex('alerts').where('id', alertId).delete();
}
async function notify(scenes) {
const releases = await knex.raw(`
SELECT alerts.id as alert_id, alerts.notify, alerts.email, releases.id as scene_id, users.id as user_id, COALESCE(json_agg(alerts_stashes.stash_id) FILTER (WHERE alerts_stashes.stash_id IS NOT NULL), '[]') as stashes
FROM releases
CROSS JOIN alerts
LEFT JOIN users ON users.id = alerts.user_id
LEFT JOIN alerts_stashes ON alerts_stashes.alert_id = alerts.id
/* match updated IDs from input */
WHERE (releases.id = ANY(:sceneIds))
/* match tags */
AND (NOT EXISTS (SELECT alerts_tags.alert_id
FROM alerts_tags
WHERE alerts_tags.alert_id = alerts.id)
OR (SELECT array_agg(releases_tags.tag_id)
FROM releases_tags
WHERE releases_tags.release_id = releases.id
GROUP BY releases_tags.release_id)
@> (SELECT array_agg(alerts_tags.tag_id)
FROM alerts_tags
WHERE alerts_tags.alert_id = alerts.id
GROUP BY alerts_tags.alert_id))
/* match actors */
AND (NOT EXISTS (SELECT alerts_actors.alert_id
FROM alerts_actors
WHERE alerts_actors.alert_id = alerts.id)
OR (SELECT array_agg(releases_actors.actor_id)
FROM releases_actors
WHERE releases_actors.release_id = releases.id
GROUP BY releases_actors.release_id)
@> (SELECT array_agg(alerts_actors.actor_id)
FROM alerts_actors
WHERE alerts_actors.alert_id = alerts.id
GROUP BY alerts_actors.alert_id))
/* match entity */
AND ((NOT EXISTS (SELECT alerts_entities.entity_id
FROM alerts_entities
WHERE alerts_entities.alert_id = alerts.id))
OR (releases.entity_id
= ANY(array(
/* include children of entities */
WITH RECURSIVE included AS (
SELECT entities.*
FROM alerts_entities
LEFT JOIN entities ON entities.id = alerts_entities.entity_id
WHERE alerts_entities.alert_id = alerts.id
UNION ALL
SELECT entities.*
FROM entities
INNER JOIN included ON included.id = entities.parent_id
)
SELECT included.id
FROM included
GROUP BY included.id
))))
GROUP BY releases.id, users.id, alerts.id;
`, {
sceneIds: scenes.map(scene => scene.id),
});
const notifications = releases.rows
.filter(alert => alert.notify)
.map(notification => ({
user_id: notification.user_id,
alert_id: notification.alert_id,
scene_id: notification.scene_id,
}));
const stashes = releases.rows
.filter(release => release.stashes.length > 0)
.flatMap(release => release.stashes.map(stash => ({
scene_id: release.scene_id,
stash_id: stash,
})));
await Promise.all([
knex('notifications').insert(notifications),
knex('stashes_scenes').insert(stashes),
]);
return releases.rows;
}
async function updateNotification(notificationId, notification, sessionUser) {
await knex('notifications')
.where('user_id', sessionUser.id)
.where('id', notificationId)
.update({
seen: notification.seen,
});
}
async function updateNotifications(notification, sessionUser) {
await knex('notifications')
.where('user_id', sessionUser.id)
.update({
seen: notification.seen,
});
}
module.exports = {
addAlert,
removeAlert,
notify,
updateNotification,
updateNotifications,
};

View File

@ -358,6 +358,7 @@ async function writeThumbnail(image, thumbpath) {
withoutEnlargement: true, withoutEnlargement: true,
}) })
.jpeg({ quality: config.media.thumbnailQuality }) .jpeg({ quality: config.media.thumbnailQuality })
.rotate()
.toFile(path.join(config.media.path, thumbpath)); .toFile(path.join(config.media.path, thumbpath));
} }
@ -368,6 +369,7 @@ async function writeLazy(image, lazypath) {
withoutEnlargement: true, withoutEnlargement: true,
}) })
.jpeg({ quality: config.media.lazyQuality }) .jpeg({ quality: config.media.lazyQuality })
.rotate()
.toFile(path.join(config.media.path, lazypath)); .toFile(path.join(config.media.path, lazypath));
} }
@ -444,8 +446,9 @@ async function storeImageFile(media, hashDir, hashSubDir, filename, filedir, fil
}, },
meta: { meta: {
...media.meta, ...media.meta,
width: info.width, // 6 or 8 implies image is sideways, and size is not inherently adjusted for orientation
height: info.height, width: info.orientation === 6 || info.orientation === 8 ? info.height : info.width,
height: info.orientation === 6 || info.orientation === 8 ? info.width : info.height,
entropy: stats?.entropy || null, entropy: stats?.entropy || null,
sharpness: stats?.sharpness || null, sharpness: stats?.sharpness || null,
}, },

View File

@ -259,7 +259,7 @@ async function fetchLatest(entity, page, options) {
release.tags = await knex('tags') release.tags = await knex('tags')
.select('name') .select('name')
.where('priority', '>', 5) .where('priority', '>', 7)
.orderByRaw('random()') .orderByRaw('random()')
.limit(faker.random.number({ min: 2, max: 15 })) .limit(faker.random.number({ min: 2, max: 15 }))
.pluck('name'); .pluck('name');

View File

@ -142,7 +142,7 @@ function scrapeAll(scenes, site, origin) {
release.title = scene.title; release.title = scene.title;
release.entryId = String(scene.newId); release.entryId = String(scene.newId);
release.url = `${site?.url || origin}${scene.targetUrl}`; release.url = `${site?.url || origin}/videos${scene.targetUrl}`;
release.date = moment.utc(scene.releaseDate).toDate(); release.date = moment.utc(scene.releaseDate).toDate();
release.datePrecision = 'minute'; release.datePrecision = 'minute';
@ -168,7 +168,7 @@ function scrapeUpcoming(scene, site) {
.map(component => `${component.charAt(0).toUpperCase()}${component.slice(1)}`) .map(component => `${component.charAt(0).toUpperCase()}${component.slice(1)}`)
.join(' '); .join(' ');
release.url = `${site.url}${scene.targetUrl}`; release.url = `${site.url}/videos${scene.targetUrl}`;
release.date = moment.utc(scene.releaseDate).toDate(); release.date = moment.utc(scene.releaseDate).toDate();
release.datePrecision = 'minute'; release.datePrecision = 'minute';
@ -302,7 +302,7 @@ async function fetchUpcoming(site) {
async function fetchScene(url, site, baseRelease) { async function fetchScene(url, site, baseRelease) {
const { origin, pathname } = new URL(url); const { origin, pathname } = new URL(url);
const apiUrl = `${origin}/api${pathname}`; const apiUrl = `${origin}/api/${pathname.split('/').slice(-1)[0]}`;
const res = await http.get(apiUrl); const res = await http.get(apiUrl);

View File

@ -13,6 +13,7 @@ const { associateActors, associateDirectors, scrapeActors, toBaseActors } = requ
const { associateReleaseTags } = require('./tags'); const { associateReleaseTags } = require('./tags');
const { curateEntity } = require('./entities'); const { curateEntity } = require('./entities');
const { associateReleaseMedia } = require('./media'); const { associateReleaseMedia } = require('./media');
const { notify } = require('./alerts');
async function curateReleaseEntry(release, batchId, existingRelease, type = 'scene') { async function curateReleaseEntry(release, batchId, existingRelease, type = 'scene') {
const slugBase = release.title const slugBase = release.title
@ -338,6 +339,8 @@ async function storeScenes(releases) {
logger.info(`Stored ${storedReleaseEntries.length} releases`); logger.info(`Stored ${storedReleaseEntries.length} releases`);
await notify(releasesWithId);
return releasesWithId; return releasesWithId;
} }

34
src/web/alerts.js Normal file
View File

@ -0,0 +1,34 @@
'use strict';
const { addAlert, removeAlert, updateNotifications, updateNotification } = require('../alerts');
async function addAlertApi(req, res) {
const alertId = await addAlert(req.body, req.session.user);
res.send({ id: alertId });
}
async function removeAlertApi(req, res) {
await removeAlert(req.params.alertId);
res.status(204).send();
}
async function updateNotificationsApi(req, res) {
await updateNotifications(req.body, req.session.user);
res.status(204).send();
}
async function updateNotificationApi(req, res) {
await updateNotification(req.params.notificationId, req.body, req.session.user);
res.status(204).send();
}
module.exports = {
addAlert: addAlertApi,
removeAlert: removeAlertApi,
updateNotifications: updateNotificationsApi,
updateNotification: updateNotificationApi,
};

View File

@ -55,6 +55,17 @@ const {
updateStash, updateStash,
} = require('./stashes'); } = require('./stashes');
const {
addAlert,
removeAlert,
updateNotifications,
updateNotification,
} = require('./alerts');
function getIp(req) {
return req.headers['x-forwarded-for'] ? req.headers['x-forwarded-for'].split(',')[0] : req.connection.remoteAddress; // See src/ws
}
async function initServer() { async function initServer() {
const app = express(); const app = express();
const router = Router(); const router = Router();
@ -80,12 +91,23 @@ async function initServer() {
next(); next();
}); });
router.use((req, res, next) => {
const ip = getIp(req);
logger.silly(`${ip} (${req.headers['CF-IPCountry'] || 'country N/A'}) requested ${req.originalUrl} as ${req.session.user ? `${req.session.user.username} (${req.session.user.id})` : 'guest'}`);
next();
});
router.get('/api/session', fetchMe); router.get('/api/session', fetchMe);
router.post('/api/session', login); router.post('/api/session', login);
router.delete('/api/session', logout); router.delete('/api/session', logout);
router.post('/api/users', signup); router.post('/api/users', signup);
router.patch('/api/users/:userId/notifications', updateNotifications);
router.patch('/api/users/:userId/notifications/:notificationId', updateNotification);
router.post('/api/stashes', createStash); router.post('/api/stashes', createStash);
router.patch('/api/stashes/:stashId', updateStash); router.patch('/api/stashes/:stashId', updateStash);
router.delete('/api/stashes/:stashId', removeStash); router.delete('/api/stashes/:stashId', removeStash);
@ -98,6 +120,9 @@ async function initServer() {
router.delete('/api/stashes/:stashId/scenes/:sceneId', unstashScene); router.delete('/api/stashes/:stashId/scenes/:sceneId', unstashScene);
router.delete('/api/stashes/:stashId/movies/:movieId', unstashMovie); router.delete('/api/stashes/:stashId/movies/:movieId', unstashMovie);
router.post('/api/alerts', addAlert);
router.delete('/api/alerts/:alertId', removeAlert);
router.get('/api/scenes', fetchScenes); router.get('/api/scenes', fetchScenes);
router.get('/api/scenes/:releaseId', fetchScene); router.get('/api/scenes/:releaseId', fetchScene);
router.get('/api/scenes/:releaseId/poster', fetchScenePoster); router.get('/api/scenes/:releaseId/poster', fetchScenePoster);

View File

@ -39,7 +39,7 @@ module.exports = {
], ],
}, },
{ {
test: /\.scss$/, test: /\.s?css$/,
use: [ use: [
MiniCssExtractPlugin.loader, MiniCssExtractPlugin.loader,
{ {