diff --git a/capacitor/src/capacitor.js b/capacitor/src/capacitor.js index 219b9ffc..92d0335b 100644 --- a/capacitor/src/capacitor.js +++ b/capacitor/src/capacitor.js @@ -85,6 +85,7 @@ IPC.on('dialog', async () => { // schema: miru://key/value const protocolMap = { auth: token => sendToken(token), + malauth: token => sendMalToken(token), anime: id => IPC.emit('open-anime', id), w2g: link => IPC.emit('w2glink', link), schedule: () => IPC.emit('schedule'), @@ -106,6 +107,17 @@ function sendToken (line) { } } +function sendMalToken (line) { + let code = line.split('code=')[1].split('&state')[0] + let state = line.split('&state=')[1] + if (code && state) { + if (code.endsWith('/')) code = code.slice(0, -1) + if (state.endsWith('/')) state = state.slice(0, -1) + if (state.includes('%')) state = decodeURIComponent(state) + IPC.emit('maltoken', code, state) + } +} + App.getLaunchUrl().then(res => { if (location.hash !== '#skipAlLogin') { location.hash = '#skipAlLogin' diff --git a/common/App.svelte b/common/App.svelte index 7169cc3b..a590d10c 100644 --- a/common/App.svelte +++ b/common/App.svelte @@ -6,6 +6,7 @@ import { rss } from './views/TorrentSearch/TorrentModal.svelte' export const page = writable('home') + export const overlay = writable('none') export const view = writable(null) export async function handleAnime (anime) { view.set(null) @@ -55,7 +56,7 @@ import TorrentModal from './views/TorrentSearch/TorrentModal.svelte' import Menubar from './components/Menubar.svelte' import { Toaster } from 'svelte-sonner' - import Logout from './components/Logout.svelte' + import Profiles from './components/Profiles.svelte' import Navbar from './components/Navbar.svelte' setContext('view', view) @@ -63,13 +64,13 @@
- - - -
- - +
+ + + + +
@@ -77,6 +78,7 @@ diff --git a/common/components/Navbar.svelte b/common/components/Navbar.svelte index a690d39f..3f39e987 100644 --- a/common/components/Navbar.svelte +++ b/common/components/Navbar.svelte @@ -1,6 +1,8 @@ diff --git a/common/components/Profiles.svelte b/common/components/Profiles.svelte new file mode 100644 index 00000000..376bf20a --- /dev/null +++ b/common/components/Profiles.svelte @@ -0,0 +1,239 @@ + + + + + {/if} +
+ + diff --git a/common/components/Search.svelte b/common/components/Search.svelte index d0850803..530eaa29 100644 --- a/common/components/Search.svelte +++ b/common/components/Search.svelte @@ -1,9 +1,11 @@ @@ -14,43 +16,328 @@ 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 { Type, Drama, Leaf, MonitorPlay, Tv, ArrowDownWideNarrow, Trash2, Tags, Grid3X3, Grid2X2 } from 'lucide-svelte' + 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 + let searchTextInput = { + title: null, + genre: null, + tag: null + } let form - $: sanitisedSearch = Object.values(searchCleanup(search)) + 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 () { + function searchClear() { search = { + title: '', search: '', genre: '', + tag: '', season: '', year: null, format: '', status: '', - sort: '' + sort: '', + hideSubs: false, + hideMyAnime: false, + hideStatus: '' } - searchTextInput.focus() + searchTextInput.title.focus() form.dispatchEvent(new Event('input', { bubbles: true })) $page = 'search' } - function handleFile ({ target }) { + 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.' + 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) { + + function changeCardMode(type) { $settings.cards = type form.dispatchEvent(new Event('input', { bubbles: true })) } @@ -61,56 +348,81 @@
- Title +
Title
+ placeholder='Any'/>
- Genre +
Genres
- + 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'/> +
+ + {#each genreList as genre} + {#if !search.genre || !search.genre.includes(genre) } + + {/if} + {/each} + +
+
+
+ +
Tags
+
+
+ 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'/>
+ + {#each filteredTags as tag} + + {/each} +
- - Season + +
Season
@@ -148,14 +460,14 @@
- Status +
Status
@@ -163,23 +475,68 @@
- Sort +
Sort
- + - + + + + {#if search.userList && search.title && !search.missedList} + {#if search.completedList} + + {/if} + {#if !search.planningList} + + {/if} + + {#if !search.completedList && !search.planningList} + + {/if} + {#if search.completedList || search.droppedList} + + {/if} + {/if}
+
+
+ +
+
+
+
+ +
+
-
-
- {#if sanitisedSearch?.length} - - {#each sanitisedSearch as badge} - {('' + badge).replace(/_/g, ' ').toLowerCase()} - {/each} - {/if} +
+
+ {#if sanitisedSearch?.length} + {@const filteredBadges = sanitisedSearch.filter(badge => badge.key !== 'hideStatus' && (search.userList || badge.key !== 'title'))} +
+ {#if filteredBadges.length > 0} + + {/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')) } +
+ +
{badge.key === 'sort' ? getSortDisplayName(badge.value) : (badge.key === 'hideMyAnime' ? 'Hide My Anime' : badge.key === 'hideSubs' ? 'Dubbed' : ('' + badge.value).replace(/_/g, ' ').toLowerCase())}
+ +
+ {/if} + {/each} + {/each} +
+ {/if} +
+
changeCardMode('small')}> changeCardMode('full')}>
diff --git a/common/components/Sidebar.svelte b/common/components/Sidebar.svelte index b41d3aae..f69273f9 100644 --- a/common/components/Sidebar.svelte +++ b/common/components/Sidebar.svelte @@ -1,11 +1,11 @@