migu/common/components/Search.svelte
2024-09-28 22:00:43 -07:00

610 lines
20 KiB
Svelte

<script context='module'>
const badgeKeys = ['title', 'search', 'genre', 'tag', 'season', 'year', 'format', 'status', 'sort', 'hideSubs', 'hideMyAnime', 'hideStatus']
const badgeDisplayNames = { title: BookUser, search: Type, genre: Drama, tag: Hash, season: CalendarRange, year: Leaf, format: Tv, status: MonitorPlay, sort: ArrowDownWideNarrow, hideMyAnime: SlidersHorizontal, hideSubs: Mic }
const sortOptions = { TITLE_ROMAJI: 'Title', START_DATE_DESC: 'Release Date', SCORE_DESC: 'Score', POPULARITY_DESC: 'Popularity', UPDATED_AT_DESC: 'Date Updated', UPDATED_TIME_DESC: 'Last Updated', STARTED_ON_DESC: 'Start Date', FINISHED_ON_DESC: 'Completed Date', PROGRESS_DESC: 'Your Progress', USER_SCORE_DESC: 'Your Score' }
export function searchCleanup (search, badge) {
return Object.fromEntries(Object.entries(search).map((entry) => {
return (!badge || badgeKeys.includes(entry[0])) && entry
}).filter(a => a?.[1]))
}
</script>
<script>
import { traceAnime } from '@/modules/anime.js'
import { settings } from '@/modules/settings.js'
import { click } from '@/modules/click.js'
import { page } from '@/App.svelte'
import { toast } from 'svelte-sonner'
import Helper from '@/modules/helper.js'
import { MagnifyingGlass, Image } from 'svelte-radix'
import { BookUser, Type, Drama, Leaf, CalendarRange, MonitorPlay, Tv, ArrowDownWideNarrow, Filter, FilterX, Tags, Hash, SlidersHorizontal, Mic, Grid3X3, Grid2X2 } from 'lucide-svelte'
export let search
let searchTextInput = {
title: null,
genre: null,
tag: null
}
let form
const genreList = [
'Action',
'Adventure',
'Comedy',
'Drama',
'Ecchi',
'Fantasy',
'Horror',
'Mahou Shoujo',
'Mecha',
'Music',
'Mystery',
'Psychological',
'Romance',
'Sci-Fi',
'Slice of Life',
'Sports',
'Supernatural',
'Thriller'
]
const tagList = [
'Chuunibyou',
'Demons',
'Food',
'Heterosexual',
'Isekai',
'Iyashikei',
'Josei',
'Magic',
'Yuri',
'Love Triangle',
'Female Harem',
'Male Harem',
'Mixed Gender Harem',
'Arranged Marriage',
'Marriage',
'Martial Arts',
'Military',
'Nudity',
'Parody',
'Reincarnation',
'Satire',
'School',
'Seinen',
'Shoujo',
'Shounen',
'Slavery',
'Space',
'Super Power',
'Superhero',
'Teens\' Love',
'Unrequited Love',
'Vampire',
'Kids',
'Gender Bending',
'Body Swapping',
'Boys\' Love',
'Cute Boys Doing Cute Things',
'Cute Girls Doing Cute Things',
'Acting',
'Afterlife',
'Age Gap',
'Age Regression',
'Aliens',
'Alternate Universe',
'Amnesia',
'Angels',
'Anti-Hero',
'Archery',
'Artificial Intelligence',
'Assassins',
'Asexual',
'Augmented Reality',
'Band',
'Bar',
'Battle Royale',
'Board Game',
'Boarding School',
'Bullying',
'Calligraphy',
'CGI',
'Classic Literature',
'College',
'Cosplay',
'Crime',
'Crossdressing',
'Cult',
'Dancing',
'Death Game',
'Desert',
'Disability',
'Drawing',
'Dragons',
'Dungeon',
'Elf',
'Espionage',
'Fairy',
'Femboy',
'Female Protagonist',
'Fashion',
'Foreign',
'Full CGI',
'Fugitive',
'Gambling',
'Ghost',
'Gods',
'Goblin',
'Guns',
'Gyaru',
'Hikikomori',
'Historical',
'Homeless',
'Idol',
'Inn',
'Kaiju',
'Konbini',
'Kuudere',
'Language Barrier',
'Makeup',
'Maids',
'Male Protagonist',
'Matriarchy',
'Matchmaking',
'Mermaid',
'Monster Boy',
'Monster Girl',
'Natural Disaster',
'Necromancy',
'Ninja',
'Nun',
'Office',
'Office Lady',
'Omegaverse',
'Orphan',
'Outdoor',
'Photography',
'Pirates',
'Polyamorous',
'Post-Apocalyptic',
'Primarily Adult Cast',
'Primarily Female Cast',
'Primarily Male Cast',
'Primarily Teen Cast',
'Prison',
'Rakugo',
'Restaurant',
'Robots',
'Rural',
'Samurai',
'School Club',
'Shapeshifting',
'Shrine Maiden',
'Skeleton',
'Slapstick',
'Snowscape',
'Space',
'Spearplay',
'Succubus',
'Surreal Comedy',
'Survival',
'Swordplay',
'Teacher',
'Time Loop',
'Time Manipulation',
'Time Skip',
'Transgender',
'Tsundere',
'Twins',
'Urban',
'Urban Fantasy',
'Video Games',
'Villainess',
'Virtual World',
'VTuber',
'War',
'Werewolf',
'Witch',
'Work',
'Writing',
'Wuxia',
'Yakuza',
'Yandere',
'Youkai',
'Zombie'
]
let filteredTags = []
$: {
const searchInput = (searchTextInput.tag ? searchTextInput.tag.toLowerCase() : null)
filteredTags = tagList.filter(tag =>
(!search.tag || !search.tag.includes(tag)) && (!searchInput ||
tag.toLowerCase().includes(searchInput))
).slice(0, 20)
}
$: sanitisedSearch = Object.entries(searchCleanup(search, true)).flatMap(
([key, value]) => {
if (Array.isArray(value)) {
return value.map((item) => ({ key, value: item }))
} else {
return [{ key, value }]
}
}
)
function searchClear() {
search = {
title: '',
search: '',
genre: '',
tag: '',
season: '',
year: null,
format: '',
status: '',
sort: '',
hideSubs: false,
hideMyAnime: false,
hideStatus: ''
}
searchTextInput.title.focus()
form.dispatchEvent(new Event('input', { bubbles: true }))
$page = 'search'
}
function getSortDisplayName(value) {
return sortOptions[value] || value
}
function removeBadge(badge) {
if (badge.key === 'title') {
delete search.load
delete search.disableHide
delete search.userList
delete search.continueWatching
delete search.completedList
if (Helper.isUserSort(search)) {
search.sort = ''
}
} else if ((badge.key === 'genre' || badge.key === 'tag') && !search.userList) {
delete search.title
} else if (badge.key === 'hideMyAnime') {
delete search.hideStatus
}
if (Array.isArray(search[badge.key])) {
search[badge.key] = search[badge.key].filter(
(item) => item !== badge.value
)
if (search[badge.key].length === 0) {
search[badge.key] = ''
}
} else {
search[badge.key] = ''
}
form.dispatchEvent(new Event('input', { bubbles: true }))
}
function toggleHideMyAnime() {
search.hideMyAnime = !search.hideMyAnime
search.hideStatus = search.hideMyAnime ? ['CURRENT', 'COMPLETED', 'DROPPED', 'PAUSED', 'REPEATING'] : ''
form.dispatchEvent(new Event('input', { bubbles: true }))
}
function toggleSubs() {
search.hideSubs = !search.hideSubs
form.dispatchEvent(new Event('input', { bubbles: true }))
}
function filterTags(event, type, trigger) {
const list = type === 'tag' ? tagList : genreList
const searchKey = type === 'tag' ? 'tag' : 'genre'
const inputValue = event.target.value
let bestMatch = list.find(item => item.toLowerCase() === inputValue.toLowerCase())
if ((trigger === 'keydown' && (event.key === 'Enter' || event.code === 'Enter')) || (trigger === 'input' && bestMatch)) {
if (!bestMatch || inputValue.endsWith('*')) {
bestMatch = (inputValue.endsWith('*') && inputValue.slice(0, -1)) || list.find(item => item.toLowerCase().startsWith(inputValue.toLowerCase())) || list.find(item => item.toLowerCase().endsWith(inputValue.toLowerCase()))
}
if (bestMatch && (!search[searchKey] || !search[searchKey].includes(bestMatch))) {
search[searchKey] = search[searchKey] ? [...search[searchKey], bestMatch] : [bestMatch]
searchTextInput[searchKey] = null
setTimeout(() => {
form.dispatchEvent(new Event('input', {bubbles: true}))
}, 0);
}
}
}
function clearTags() { // cannot specify genre and tag filtering with user specific sorting options when using alternative authentication.
if (!Helper.isAniAuth() && Helper.isUserSort(search)) {
search.genre = ''
search.tag = ''
}
}
function handleFile({ target }) {
const { files } = target
if (files?.[0]) {
toast.promise(traceAnime(files[0]), {
description: 'You can also paste an URL to an image.',
loading: 'Looking up anime for image...',
success: 'Found anime for image!',
error:
'Couldn\'t find anime for specified image! Try to remove black bars, or use a more detailed image.'
})
target.value = null
}
}
function changeCardMode(type) {
$settings.cards = type
form.dispatchEvent(new Event('input', { bubbles: true }))
}
</script>
<form class='container-fluid py-20 px-md-50 bg-dark pb-0 position-sticky top-0 search-container z-40' on:input bind:this={form}>
<div class='row'>
<div class='col-lg col-4 p-10 d-flex flex-column justify-content-end'>
<div class='pb-10 font-size-24 font-weight-semi-bold d-flex'>
<Type class='mr-10' size='3rem' />
<div>Title</div>
</div>
<div class='input-group'>
<div class='input-group-prepend'>
<MagnifyingGlass size='2.75rem' class='input-group-text bg-dark-light pr-0' />
</div>
<input
bind:this={searchTextInput.title}
type='search'
class='form-control bg-dark-light border-left-0 text-capitalize'
autocomplete='off'
bind:value={search.search}
data-option='search'
disabled={search.disableSearch}
placeholder='Any'/>
</div>
</div>
<div class='col-lg col-4 p-10 d-flex flex-column justify-content-end'>
<div class='pb-10 font-size-24 font-weight-semi-bold d-flex'>
<Drama class='mr-10' size='3rem' />
<div>Genres</div>
</div>
<div class='input-group'>
<input
id='genre'
type='search'
title={(!Helper.isAniAuth() && Helper.isUserSort(search)) ? 'Cannot use with sort: ' + sortOptions[search.sort] : ''}
class='form-control bg-dark-light border-left-0 text-capitalize'
autocomplete='off'
bind:value={searchTextInput.genre}
on:keydown={(event) => filterTags(event, 'genre', 'keydown')}
on:input={(event) => filterTags(event, 'genre', 'input')}
data-option='search'
disabled={search.disableSearch || (!Helper.isAniAuth() && Helper.isUserSort(search))}
placeholder='Any'
list='search-genre'/>
</div>
<datalist id='search-genre'>
{#each genreList as genre}
{#if !search.genre || !search.genre.includes(genre) }
<option>{genre}</option>
{/if}
{/each}
</datalist>
</div>
<div class='col-lg col-4 p-10 d-flex flex-column justify-content-end'>
<div class='pb-10 font-size-24 font-weight-semi-bold d-flex'>
<Hash class='mr-10' size='3rem' />
<div>Tags</div>
</div>
<div class='input-group'>
<input
id='tag'
type='search'
title={(!Helper.isAniAuth() && Helper.isUserSort(search)) ? 'Cannot use with sort: ' + sortOptions[search.sort] : ''}
class='form-control bg-dark-light border-left-0 text-capitalize'
autocomplete='off'
bind:value={searchTextInput.tag}
on:keydown={(event) => filterTags(event, 'tag', 'keydown')}
on:input={(event) => filterTags(event, 'tag', 'input')}
data-option='search'
disabled={search.disableSearch || (!Helper.isAniAuth() && Helper.isUserSort(search))}
placeholder='Any'
list='search-tag'/>
</div>
<datalist id='search-tag'>
{#each filteredTags as tag}
<option>{tag}</option>
{/each}
</datalist>
</div>
<div class='col-lg col-4 p-10 d-flex flex-column justify-content-end'>
<div class='pb-10 font-size-24 font-weight-semi-bold d-flex'>
<CalendarRange class='mr-10' size='3rem' />
<div>Season</div>
</div>
<div class='input-group'>
<select class='form-control bg-dark-light border-right-dark' required bind:value={search.season} disabled={search.disableSearch}>
<option value selected>Any</option>
<option value='WINTER'>Winter</option>
<option value='SPRING'>Spring</option>
<option value='SUMMER'>Summer</option>
<option value='FALL'>Fall</option>
</select>
<datalist id='search-year'>
{#each Array(new Date().getFullYear() - 1940 + 2) as _, i}
{@const year = new Date().getFullYear() + 2 - i}
<option>{year}</option>
{/each}
</datalist>
<input type='number' inputmode='numeric' pattern='[0-9]*' placeholder='Any' min='1940' max='2100' list='search-year' class='bg-dark-light form-control' disabled={search.disableSearch} bind:value={search.year} />
</div>
</div>
<div class='col p-10 d-flex flex-column justify-content-end'>
<div class='pb-10 font-size-24 font-weight-semi-bold d-flex'>
<Tv class='mr-10' size='3rem' />
<div>Format</div>
</div>
<div class='input-group'>
<select class='form-control bg-dark-light' required bind:value={search.format} disabled={search.disableSearch}>
<option value selected>Any</option>
<option value='TV'>TV Show</option>
<option value='MOVIE'>Movie</option>
<option value='TV_SHORT'>TV Short</option>
<option value='OVA'>OVA</option>
<option value='ONA'>ONA</option>
</select>
</div>
</div>
<div class='col p-10 d-flex flex-column justify-content-end'>
<div class='pb-10 font-size-24 font-weight-semi-bold d-flex'>
<MonitorPlay class='mr-10' size='3rem' />
<div>Status</div>
</div>
<div class='input-group'>
<select class='form-control bg-dark-light' required bind:value={search.status} disabled={search.disableSearch}>
<option value selected>Any</option>
<option value='RELEASING'>Releasing</option>
<option value='FINISHED'>Finished</option>
<option value='NOT_YET_RELEASED'>Not Yet Released</option>
<option value='CANCELLED'>Cancelled</option>
</select>
</div>
</div>
<div class='col p-10 d-flex flex-column justify-content-end'>
<div class='pb-10 font-size-24 font-weight-semi-bold d-flex'>
<ArrowDownWideNarrow class='mr-10' size='3rem' />
<div>Sort</div>
</div>
<div class='input-group'>
<select class='form-control bg-dark-light' required bind:value={search.sort} on:change={clearTags} disabled={search.disableSearch}>
<option value selected>Trending</option>
<option value='POPULARITY_DESC'>Popularity</option>
<option value='TITLE_ROMAJI'>Title</option>
<option value='SCORE_DESC'>Score</option>
<option value='START_DATE_DESC'>Release Date</option>
<option value='UPDATED_AT_DESC'>Updated Date</option>
{#if search.userList && search.title && !search.missedList}
{#if search.completedList}
<option value='FINISHED_ON_DESC'>Completed Date</option>
{/if}
{#if !search.planningList}
<option value='STARTED_ON_DESC'>Start Date</option>
{/if}
<option value='UPDATED_TIME_DESC'>Last Updated</option>
{#if !search.completedList && !search.planningList}
<option value='PROGRESS_DESC'>Your Progress</option>
{/if}
{#if search.completedList || search.droppedList}
<option value='USER_SCORE_DESC'>Your Score</option>
{/if}
{/if}
</select>
</div>
</div>
<div class='col-auto p-10 d-flex'>
<div class='align-self-end'>
<button
class='btn btn-square bg-dark-light px-5 align-self-end border-0'
type='button'
title='Hide My Anime'
use:click={toggleHideMyAnime}
disabled={search.disableHide || search.disableSearch || !Helper.isAuthorized()}
class:text-primary={search.hideMyAnime}>
<label for='hide-my-anime' class='pointer mb-0 d-flex align-items-center justify-content-center'>
<SlidersHorizontal size='1.625rem' />
</label>
</button>
</div>
</div>
<div class='col-auto p-10 d-flex'>
<div class='align-self-end'>
<button
class='btn btn-square bg-dark-light px-5 align-self-end border-0'
type='button'
title='Dubbed Audio'
use:click={toggleSubs}
disabled={search.disableSearch}
class:text-primary={search.hideSubs}>
<label for='hide-subs' class='pointer mb-0 d-flex align-items-center justify-content-center'>
<Mic size='1.625rem' />
</label>
</button>
</div>
</div>
<input type='file' class='d-none' id='search-image' accept='image/*' on:input|preventDefault|stopPropagation={handleFile} />
<div class='col-auto p-10 d-flex'>
<div class='align-self-end'>
<button class='btn btn-square bg-dark-light px-5 align-self-end border-0' type='button' title='Image Search'>
<label for='search-image' class='pointer mb-0 d-flex align-items-center justify-content-center'>
<Image size='1.625rem' />
</label>
</button>
</div>
</div>
<div class='col-auto p-10 d-flex'>
<div class='align-self-end'>
<button class='btn btn-square bg-dark-light d-flex align-items-center justify-content-center px-5 align-self-end border-0' type='button' use:click={searchClear} disabled={sanitisedSearch.length <= 0} class:text-danger={!!sanitisedSearch?.length || search.disableSearch || search.clearNext}>
{#if !!sanitisedSearch?.length || search.disableSearch || search.clearNext}
<FilterX size='1.625rem' />
{:else}
<Filter size='1.625rem' />
{/if}
</button>
</div>
</div>
</div>
<div class='w-full px-10 pt-10 h-50 d-flex flex-colum align-items-center'>
<form>
<div role="button" tabindex="0">
{#if sanitisedSearch?.length}
{@const filteredBadges = sanitisedSearch.filter(badge => badge.key !== 'hideStatus' && (search.userList || badge.key !== 'title'))}
<div class='d-flex flex-row align-items-center'>
{#if filteredBadges.length > 0}
<Tags class='text-dark-light mr-20' size='3rem' />
{/if}
{#each badgeKeys as key}
{@const matchingBadges = filteredBadges.filter(badge => badge.key === key)}
{#each matchingBadges as badge}
{#if badge.key === key && (badge.key !== 'hideStatus' && (search.userList || badge.key !== 'title')) }
<div class='badge bg-light border-0 py-5 px-10 text-capitalize mr-20 text-white text-nowrap d-flex align-items-center'>
<svelte:component this={badgeDisplayNames[badge.key]} class='mr-5' size='1.8rem' />
<div class='font-size-12'>{badge.key === 'sort' ? getSortDisplayName(badge.value) : (badge.key === 'hideMyAnime' ? 'Hide My Anime' : badge.key === 'hideSubs' ? 'Dubbed' : ('' + badge.value).replace(/_/g, ' ').toLowerCase())}</div>
<button on:click={() => removeBadge(badge)} class='pointer bg-transparent border-0 text-white font-size-12 position-relative ml-10 pt-0' title='Remove Filter' type='button'>x</button>
</div>
{/if}
{/each}
{/each}
</div>
{/if}
</div>
</form>
<span class='mr-10 filled ml-auto text-dark-light pointer' class:text-muted={$settings.cards === 'small'} use:click={() => changeCardMode('small')}><Grid3X3 size='2.25rem' /></span>
<span class='text-dark-light pointer' class:text-muted={$settings.cards === 'full'} use:click={() => changeCardMode('full')}><Grid2X2 size='2.25rem' /></span>
</div>
</form>
<style>
.input-group,
.container-fluid button, .pointer {
transition: scale 0.2s ease;
}
.input-group:hover, .pointer:hover {
scale: 1.08;
}
.container-fluid button:hover {
scale: 1.20;
}
input:not(:focus):invalid {
box-shadow: 0 0 0 0.2rem var(--danger-color) !important;
}
select.form-control:invalid {
color: var(--dm-input-placeholder-text-color);
}
.font-size-30 {
font-size: 3rem !important;
}
</style>