Added unidentified actors and actor count filters.
This commit is contained in:
@@ -16,6 +16,18 @@ import Pagination from '#/components/pagination/pagination.vue';
|
||||
import { get } from '#/src/api.js';
|
||||
import events from '#/src/events.js';
|
||||
import navigate from '#/src/navigate.js';
|
||||
import { formatRange, parseLetterRange, parseRange } from '#/utils/parse-range.js';
|
||||
|
||||
// sliders need both ends, fill open ends with the slider limits
|
||||
function getSliderRange(rangeString, [defaultMin, defaultMax], parse = parseRange) {
|
||||
const range = parse(rangeString);
|
||||
|
||||
return [range?.min ?? defaultMin, range?.max ?? defaultMax];
|
||||
}
|
||||
|
||||
function formatSliderRange([min, max]) {
|
||||
return formatRange({ min, max });
|
||||
}
|
||||
|
||||
const pageContext = inject('pageContext');
|
||||
const { pageProps, urlParsed, routeParams } = pageContext;
|
||||
@@ -35,18 +47,18 @@ const order = ref(routeParams.order || urlParsed.search.order || 'likes.desc');
|
||||
const filters = ref({
|
||||
gender: urlParsed.search.gender,
|
||||
ageRequired: !!urlParsed.search.age,
|
||||
age: urlParsed.search.age?.split(',').map((age) => Number(age)) || [18, 100],
|
||||
age: getSliderRange(urlParsed.search.age, [18, 100]),
|
||||
dobRequired: !!urlParsed.search.dob,
|
||||
dobType: urlParsed.search.dobt ? ({ bd: 'birthday', dob: 'dob' })[urlParsed.search.dobt] : 'birthday',
|
||||
dob: urlParsed.search.dob || format(subYears(new Date(), 21), 'yyyy-MM-dd'),
|
||||
country: urlParsed.search.c,
|
||||
braSizeRequired: !!urlParsed.search.cup,
|
||||
braSize: urlParsed.search.cup?.split(',') || ['A', 'Z'],
|
||||
braSize: getSliderRange(urlParsed.search.cup, ['A', 'Z'], parseLetterRange),
|
||||
naturalBoobs: urlParsed.search.nb ? urlParsed.search.nb === 'true' : undefined,
|
||||
heightRequired: !!urlParsed.search.height,
|
||||
height: urlParsed.search.height?.split(',').map((height) => Number(height)) || [50, 220],
|
||||
height: getSliderRange(urlParsed.search.height, [50, 220]),
|
||||
weightRequired: !!urlParsed.search.weight,
|
||||
weight: urlParsed.search.weight?.split(',').map((weight) => Number(weight)) || [30, 200],
|
||||
weight: getSliderRange(urlParsed.search.weight, [30, 200]),
|
||||
avatarRequired: !!urlParsed.search.avatar,
|
||||
});
|
||||
|
||||
@@ -85,14 +97,14 @@ async function search(options = {}) {
|
||||
q: q.value || undefined,
|
||||
order: order.value,
|
||||
gender: filters.value.gender || undefined,
|
||||
age: filters.value.ageRequired ? filters.value.age.join(',') : undefined,
|
||||
age: filters.value.ageRequired ? formatSliderRange(filters.value.age) : undefined,
|
||||
dob: filters.value.dobRequired ? filters.value.dob : undefined,
|
||||
dobt: filters.value.dobRequired ? ({ birthday: 'bd', dob: 'dob' })[filters.value.dobType] : undefined,
|
||||
cup: filters.value.braSizeRequired ? filters.value.braSize.join(',') : undefined,
|
||||
cup: filters.value.braSizeRequired ? formatSliderRange(filters.value.braSize) : undefined,
|
||||
c: filters.value.country || undefined,
|
||||
nb: filters.value.naturalBoobs,
|
||||
height: filters.value.heightRequired ? filters.value.height.join(',') : undefined,
|
||||
weight: filters.value.weightRequired ? filters.value.weight.join(',') : undefined,
|
||||
height: filters.value.heightRequired ? formatSliderRange(filters.value.height) : undefined,
|
||||
weight: filters.value.weightRequired ? formatSliderRange(filters.value.weight) : undefined,
|
||||
avatar: filters.value.avatarRequired || undefined,
|
||||
stashId: pageStash?.id,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup>
|
||||
import { nanoid } from 'nanoid';
|
||||
import { ref, useId } from 'vue';
|
||||
|
||||
import { get } from '#/src/api.js';
|
||||
@@ -9,7 +10,12 @@ defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showUnknown: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['actor']);
|
||||
const dropdownId = useId();
|
||||
const actors = ref([]);
|
||||
@@ -49,6 +55,23 @@ function focus() {
|
||||
>
|
||||
|
||||
<ul class="actors nolist">
|
||||
<li
|
||||
v-if="showUnknown"
|
||||
class="actor-unknown"
|
||||
>
|
||||
<span class="actor-label">Unidentified</span>
|
||||
|
||||
<Icon
|
||||
v-for="gender in ['male', 'female', 'transsexual', null]"
|
||||
:key="`actor-unknown-${gender}`"
|
||||
v-close-popper
|
||||
:icon="gender || 'question5'"
|
||||
class="actor-gender"
|
||||
:class="{ [`actor-${gender || 'other'}`]: true }"
|
||||
@click="emit('actor', { name: gender ? `Unknown ${gender}` : 'Unknown', gender, isUnknown: true, id: `${gender || 'other'}_${nanoid()}` })"
|
||||
/>
|
||||
</li>
|
||||
|
||||
<li
|
||||
v-for="actor in actors"
|
||||
:key="`actor-${actor.slug}`"
|
||||
@@ -89,6 +112,41 @@ function focus() {
|
||||
}
|
||||
}
|
||||
|
||||
.actor-unknown {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.actor-label {
|
||||
padding: .25rem .5rem;
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.actor-gender {
|
||||
padding: .5rem .5rem;
|
||||
|
||||
&:hover {
|
||||
background: var(--glass-weak-50);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.actor-male {
|
||||
fill: var(--male);
|
||||
}
|
||||
|
||||
.actor-female {
|
||||
fill: var(--female);
|
||||
}
|
||||
|
||||
.actor-transsexual {
|
||||
fill: var(--trans);
|
||||
}
|
||||
|
||||
.actor-other {
|
||||
fill: var(--glass);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
position: fixed;
|
||||
display: none;
|
||||
|
||||
@@ -33,7 +33,7 @@ const aliasTooltipId = useId();
|
||||
>
|
||||
<div class="avatar-container">
|
||||
<Link
|
||||
:href="`/actor/${actor.id}/${actor.slug}`"
|
||||
:href="actor.isUnknown ? null : `/actor/${actor.id}/${actor.slug}`"
|
||||
class="avatar-link no-link"
|
||||
>
|
||||
<img
|
||||
@@ -101,10 +101,16 @@ const aliasTooltipId = useId();
|
||||
|
||||
<span class="label">
|
||||
<span
|
||||
v-if="actor.name"
|
||||
class="name ellipsis"
|
||||
:style="{ 'font-size': `${Math.max(0.9 + Math.min((17 - actor.name.length), 0) * 0.06, 0.65)}rem` }"
|
||||
>{{ actor.name }}</span>
|
||||
|
||||
<span
|
||||
v-else
|
||||
class="name unknown"
|
||||
>Unknown {{ actor.gender }}</span>
|
||||
|
||||
<img
|
||||
v-if="actor.entity"
|
||||
v-tooltip="{ content: actor.entity.name, ariaId: entityTooltipId }"
|
||||
@@ -179,6 +185,10 @@ const aliasTooltipId = useId();
|
||||
|
||||
.name {
|
||||
padding: 0 .25rem 0 .5rem;
|
||||
|
||||
&.unknown {
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.favicon {
|
||||
|
||||
@@ -46,9 +46,12 @@ watch(() => props.scene, () => {
|
||||
v-for="actor in [...item.value, ...newActors]"
|
||||
:key="`actor-${actor.id}`"
|
||||
class="actor"
|
||||
:class="{ deleted: edits.actors && !edits.actors.some((actorId) => actorId === actor.id) }"
|
||||
:class="{
|
||||
deleted: edits.actors && !edits.actors.some((actorId) => actorId === actor.id),
|
||||
unknown: actor.isUnknown,
|
||||
}"
|
||||
>
|
||||
<span class="actor-name">{{ actor.name }}</span>
|
||||
<span class="actor-name">{{ actor.name || ['Unknown', actor.gender].filter(Boolean).join(' ') }}</span>
|
||||
|
||||
<Icon
|
||||
v-if="edits.actors && !edits.actors.some((actorId) => actorId === actor.id)"
|
||||
@@ -68,6 +71,7 @@ watch(() => props.scene, () => {
|
||||
<li class="new">
|
||||
<ActorSearch
|
||||
:disabled="!editing.has(item.key)"
|
||||
:show-unknown="true"
|
||||
@actor="addActor"
|
||||
>
|
||||
<Icon
|
||||
@@ -131,6 +135,10 @@ watch(() => props.scene, () => {
|
||||
color: var(--glass);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
&.unknown {
|
||||
color: var(--glass-strong-10);
|
||||
}
|
||||
}
|
||||
|
||||
.actor,
|
||||
|
||||
@@ -41,6 +41,17 @@ const total = ref(pageContext.pageProps.revisionTotal);
|
||||
const page = Number(pageContext.routeParams.page) || 1;
|
||||
const limit = Number(pageContext.urlParsed.search.limit) || 100;
|
||||
|
||||
function getUnknownName(itemId) {
|
||||
// unidentified actors are stored as gender_id
|
||||
if (typeof itemId !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const gender = itemId.match(/^(male|female|transsexual)_/)?.[1];
|
||||
|
||||
return ['Unknown', gender].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
const mappedKeys = {
|
||||
actors: actorsById,
|
||||
// tags: tagsById,
|
||||
@@ -64,7 +75,7 @@ const curatedRevisions = computed(() => revisions.value.map((revision) => {
|
||||
if (Array.isArray(value) && mappedKeys[key]) {
|
||||
return [key, value.map((itemId) => ({
|
||||
id: itemId,
|
||||
name: mappedKeys[key].value[itemId]?.name || mappedKeys[key].value[itemId]?.title,
|
||||
name: mappedKeys[key].value[itemId]?.name || mappedKeys[key].value[itemId]?.title || getUnknownName(itemId),
|
||||
modified: revision.deltas.some((delta) => delta.key === key && !delta.value.some((deltaItemId) => deltaItemId === itemId)),
|
||||
}))];
|
||||
}
|
||||
@@ -104,7 +115,7 @@ const curatedRevisions = computed(() => revisions.value.map((revision) => {
|
||||
...delta,
|
||||
value: delta.value.map((itemId) => ({
|
||||
id: itemId,
|
||||
name: mappedKeys[delta.key].value[itemId]?.name || mappedKeys[delta.key].value[itemId]?.title,
|
||||
name: mappedKeys[delta.key].value[itemId]?.name || mappedKeys[delta.key].value[itemId]?.title || getUnknownName(itemId),
|
||||
modified: !revision.base[delta.key].includes(itemId),
|
||||
})),
|
||||
};
|
||||
|
||||
@@ -166,7 +166,7 @@ watch(() => props.scene, () => {
|
||||
.tags-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .75rem;
|
||||
gap: 1.5rem;
|
||||
margin: .5rem 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
<script setup>
|
||||
import {
|
||||
computed,
|
||||
ref,
|
||||
} from 'vue';
|
||||
|
||||
import Range from '#/components/form/range.vue';
|
||||
|
||||
const props = defineProps({
|
||||
filters: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
counts: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update']);
|
||||
|
||||
// beyond this the slider loses too much fidelity for the common small counts, the last step means 'or more'
|
||||
const sliderCap = 10;
|
||||
|
||||
const rows = [
|
||||
{ key: 'group', label: 'Actors', icon: 'users' },
|
||||
{ key: 'females', label: 'Females', icon: 'female' },
|
||||
{ key: 'males', label: 'Males', icon: 'male' },
|
||||
{ key: 'trans', label: 'Trans', icon: 'transsexual' },
|
||||
];
|
||||
|
||||
// values while dragging, before the change is committed and the scenes reload
|
||||
const dragging = ref({});
|
||||
|
||||
const sliders = computed(() => rows.map((row) => {
|
||||
const filter = props.filters[row.key];
|
||||
const buckets = props.counts?.[row.key] || [];
|
||||
const countMax = buckets.at(-1)?.value ?? sliderCap; // buckets are sorted by value
|
||||
// at least 1 to keep the slider usable, and never below the active filter so its handles stay in view
|
||||
const max = Math.max(1, Math.min(countMax, sliderCap), filter?.min ?? 0, filter?.max ?? 0);
|
||||
const value = [filter?.min ?? 0, filter?.max ?? max];
|
||||
|
||||
return {
|
||||
...row,
|
||||
max,
|
||||
isCapped: countMax > max,
|
||||
value,
|
||||
display: dragging.value[row.key] || value,
|
||||
mountKey: `${row.key}-${max}-${value.join('-')}`, // remount when the range or filter changes, the slider only reads its value on mount
|
||||
};
|
||||
}));
|
||||
|
||||
function getEndLabel(slider, value) {
|
||||
return value === slider.max && slider.isCapped ? `${value}+` : value;
|
||||
}
|
||||
|
||||
function updateRange(slider, [min, max], reload) {
|
||||
if (!reload) {
|
||||
dragging.value[slider.key] = [min, max];
|
||||
return;
|
||||
}
|
||||
|
||||
delete dragging.value[slider.key];
|
||||
|
||||
// the top of the slider means 'or more', the full range means no filter
|
||||
const range = {
|
||||
min,
|
||||
max: max === slider.max ? null : max,
|
||||
};
|
||||
|
||||
emit('update', slider.key, min === 0 && range.max === null ? null : range, true);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="filter groups-container">
|
||||
<div
|
||||
v-for="slider in sliders"
|
||||
:key="slider.key"
|
||||
class="filter-row"
|
||||
:title="slider.label"
|
||||
>
|
||||
<Icon
|
||||
:icon="slider.icon"
|
||||
:class="`gender-${slider.key}`"
|
||||
/>
|
||||
|
||||
<Range
|
||||
:key="slider.mountKey"
|
||||
:min="0"
|
||||
:max="slider.max"
|
||||
:value="slider.value"
|
||||
:allow-enable="false"
|
||||
@input="(range) => updateRange(slider, range, false)"
|
||||
@change="(range) => updateRange(slider, range, true)"
|
||||
>
|
||||
<template #start><span class="range-label">{{ slider.display[0] }}</span></template>
|
||||
<template #end><span class="range-label">{{ getEndLabel(slider, slider.display[1]) }}</span></template>
|
||||
</Range>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.groups-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .5rem 0;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0 .25rem;
|
||||
|
||||
.range-container {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.range-label {
|
||||
display: inline-block;
|
||||
min-width: 1.5rem;
|
||||
color: var(--glass-strong-10);
|
||||
font-size: .9rem;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.range-container {
|
||||
padding: .25rem 0;
|
||||
}
|
||||
|
||||
.icon {
|
||||
fill: var(--glass);
|
||||
}
|
||||
|
||||
.gender-females {
|
||||
fill: var(--female);
|
||||
}
|
||||
|
||||
.gender-males {
|
||||
fill: var(--male);
|
||||
}
|
||||
|
||||
.gender-trans {
|
||||
fill: var(--trans);
|
||||
}
|
||||
</style>
|
||||
@@ -12,6 +12,7 @@ import Campaign from '#/components/campaigns/campaign.vue';
|
||||
import ActorsFilter from '#/components/filters/actors.vue';
|
||||
import ChannelsFilter from '#/components/filters/channels.vue';
|
||||
import Filters from '#/components/filters/filters.vue';
|
||||
import GroupsFilter from '#/components/filters/groups.vue';
|
||||
|
||||
import TagsFilter from '#/components/filters/tags.vue';
|
||||
import YearsFilter from '#/components/filters/years.vue';
|
||||
@@ -23,6 +24,7 @@ import { get } from '#/src/api.js';
|
||||
import entityPrefixes from '#/src/entities-prefixes.js';
|
||||
import navigate from '#/src/navigate.js';
|
||||
import { getActorIdentifier, parseActorIdentifier } from '#/src/query.js';
|
||||
import { formatRange, parseRange } from '#/utils/parse-range.js';
|
||||
|
||||
const props = defineProps({
|
||||
showFilters: {
|
||||
@@ -64,6 +66,7 @@ const aggActors = ref(pageProps.aggActors || []);
|
||||
const aggTags = ref(pageProps.aggTags || []);
|
||||
const aggActorTags = ref(pageProps.aggActorTags || []);
|
||||
const aggChannels = ref(pageProps.aggChannels || []);
|
||||
const aggActorCounts = ref(pageProps.aggActorCounts || null);
|
||||
|
||||
const currentPage = ref(Number(routeParams.page));
|
||||
const scope = ref(routeParams.scope || props.defaultScope);
|
||||
@@ -88,6 +91,10 @@ const filters = ref({
|
||||
onlyActorTags: Object.hasOwn(urlParsed.search, 'at'),
|
||||
entity: queryEntity,
|
||||
actors: queryActors,
|
||||
group: parseRange(urlParsed.search.group),
|
||||
females: parseRange(urlParsed.search.females),
|
||||
males: parseRange(urlParsed.search.males),
|
||||
trans: parseRange(urlParsed.search.trans),
|
||||
});
|
||||
|
||||
const sceneCampaign = campaigns?.scenes;
|
||||
@@ -132,6 +139,10 @@ async function search(options = {}) {
|
||||
|
||||
const query = {
|
||||
q: filters.value.search || undefined,
|
||||
group: formatRange(filters.value.group),
|
||||
females: formatRange(filters.value.females),
|
||||
males: formatRange(filters.value.males),
|
||||
trans: formatRange(filters.value.trans),
|
||||
};
|
||||
|
||||
const entity = filters.value.entity || pageEntity;
|
||||
@@ -168,6 +179,7 @@ async function search(options = {}) {
|
||||
aggTags.value = res.aggTags;
|
||||
aggActorTags.value = res.aggActorTags;
|
||||
aggChannels.value = res.aggChannels;
|
||||
aggActorCounts.value = res.aggActorCounts;
|
||||
|
||||
total.value = res.total;
|
||||
loading.value = false;
|
||||
@@ -248,6 +260,12 @@ function setView(newView) {
|
||||
:actors="aggActors"
|
||||
@update="updateFilter"
|
||||
/>
|
||||
|
||||
<GroupsFilter
|
||||
:filters="filters"
|
||||
:counts="aggActorCounts"
|
||||
@update="updateFilter"
|
||||
/>
|
||||
</Filters>
|
||||
</transition>
|
||||
|
||||
|
||||
@@ -92,16 +92,23 @@ const favorited = ref(props.scene.stashes.some((sceneStash) => sceneStash.id ===
|
||||
|
||||
<ul
|
||||
class="row actors nolist"
|
||||
:title="scene.actors.map((actor) => actor.name).join(', ')"
|
||||
:title="scene.actors.map((actor) => actor.name || 'Unknown').join(', ')"
|
||||
>
|
||||
<li
|
||||
v-for="actor in scene.actors"
|
||||
:key="`actor-${scene.id}-${actor.id}`"
|
||||
class="actor"
|
||||
:class="{ unknown: actor.isUnknown }"
|
||||
>
|
||||
<span
|
||||
v-if="actor.isUnknown"
|
||||
class="actor-name"
|
||||
>{{ ['Unknown', actor.gender].filter(Boolean).join(' ') }}</span>
|
||||
|
||||
<Link
|
||||
v-else
|
||||
:href="`/actor/${actor.id}/${actor.slug}`"
|
||||
class="nolink"
|
||||
class="actor-name nolink"
|
||||
>{{ actor.name }}</Link>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -237,7 +244,7 @@ const favorited = ref(props.scene.stashes.some((sceneStash) => sceneStash.id ===
|
||||
color: var(--shadow);
|
||||
}
|
||||
|
||||
.actor:hover,
|
||||
.actor:not(.unknown):hover,
|
||||
.tag:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
@@ -255,6 +262,13 @@ const favorited = ref(props.scene.stashes.some((sceneStash) => sceneStash.id ===
|
||||
}
|
||||
}
|
||||
|
||||
.actor.unknown {
|
||||
.actor-name,
|
||||
&:not(:last-child)::after {
|
||||
color: var(--glass);
|
||||
}
|
||||
}
|
||||
|
||||
.tags {
|
||||
height: 1.25rem;
|
||||
overflow: hidden;
|
||||
|
||||
Reference in New Issue
Block a user