mirror of
https://github.com/maciejpedzich/spotifyplaylistarchive.com.git
synced 2024-09-20 02:26:20 +02:00
Create a playlist search bar component
This commit is contained in:
parent
97c8a55ba3
commit
fff9c66b05
152
src/components/vue/PlaylistSearchInput.vue
Normal file
152
src/components/vue/PlaylistSearchInput.vue
Normal file
@ -0,0 +1,152 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { search } from 'fast-fuzzy';
|
||||
import { debounce } from 'debounce';
|
||||
import { getPlaylistIdFromUrl } from '../../utils/getPlaylistIdFromUrl';
|
||||
|
||||
interface PlaylistEntry {
|
||||
name: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
const searchTerm = ref('');
|
||||
const searchHistory = ref<PlaylistEntry[]>(
|
||||
JSON.parse(localStorage.getItem('searchHistory') || '[]')
|
||||
);
|
||||
|
||||
const playlists = ref<PlaylistEntry[]>([]);
|
||||
const searchSuggestions = ref<PlaylistEntry[]>(searchHistory.value);
|
||||
|
||||
const isFetchingPlaylists = ref(true);
|
||||
const errorOccurred = ref(false);
|
||||
const canShowSuggestions = ref(false);
|
||||
|
||||
const fetchAvailablePlaylists = async () => {
|
||||
try {
|
||||
isFetchingPlaylists.value = true;
|
||||
errorOccurred.value = false;
|
||||
|
||||
const readmeFileContent = await (
|
||||
await fetch(
|
||||
'https://raw.githubusercontent.com/mackorone/spotify-playlist-archive/main/README.md'
|
||||
)
|
||||
).text();
|
||||
|
||||
const [, playlistLinksMdList] = readmeFileContent.split(
|
||||
/## Playlists \\\([0-9]*\\\)\n\n/gm
|
||||
);
|
||||
|
||||
const playlistEntries: PlaylistEntry[] = playlistLinksMdList
|
||||
.replaceAll('- [', '')
|
||||
.replaceAll('\\', '')
|
||||
.replaceAll('](', '')
|
||||
.replaceAll('.md)', '')
|
||||
.split('\n')
|
||||
.map((textEntry) => {
|
||||
const [name, id] = textEntry.split('/playlists/pretty/');
|
||||
|
||||
return { name, id };
|
||||
});
|
||||
|
||||
playlists.value = playlistEntries;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
errorOccurred.value = true;
|
||||
} finally {
|
||||
isFetchingPlaylists.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const showSearchHistory = () => (searchSuggestions.value = searchHistory.value);
|
||||
|
||||
const findMatchingPlaylists = debounce(async (name: string) => {
|
||||
try {
|
||||
if (name.length === 0) return showSearchHistory();
|
||||
else if (name.length < 3) return (searchSuggestions.value = []);
|
||||
|
||||
// Assume the name is a valid playlist URL
|
||||
const playlistId = getPlaylistIdFromUrl(name);
|
||||
const githubResponse = await fetch(
|
||||
`https://raw.githubusercontent.com/mackorone/spotify-playlist-archive/main/playlists/pretty/${playlistId}.json`
|
||||
);
|
||||
|
||||
if (!githubResponse.ok) throw new Error('Entry does not exist');
|
||||
|
||||
const playlist = await githubResponse.json();
|
||||
|
||||
return (searchSuggestions.value = [
|
||||
{ id: playlistId, name: playlist.original_name }
|
||||
]);
|
||||
} catch (error) {
|
||||
const noopErrorMessages = ['Invalid playlist URL', 'Entry does not exist'];
|
||||
|
||||
if (noopErrorMessages.includes((error as Error).message)) {
|
||||
return (searchSuggestions.value = []);
|
||||
}
|
||||
|
||||
const matches = search(name, playlists.value, {
|
||||
keySelector: (obj) => obj.name
|
||||
});
|
||||
|
||||
searchSuggestions.value = matches.slice(0, 5);
|
||||
}
|
||||
}, 250);
|
||||
|
||||
const saveMatchToHistory = (match: PlaylistEntry) => {
|
||||
const isNewHistoryEntry = searchHistory.value.every(
|
||||
(entry) => entry.id !== match.id
|
||||
);
|
||||
|
||||
if (isNewHistoryEntry) {
|
||||
searchHistory.value = [match, ...searchHistory.value].slice(0, 5);
|
||||
localStorage.setItem('searchHistory', JSON.stringify(searchHistory.value));
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(fetchAvailablePlaylists);
|
||||
|
||||
watch(searchTerm, (newSearchTerm) => findMatchingPlaylists(newSearchTerm));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<i
|
||||
v-if="isFetchingPlaylists"
|
||||
class="fa-solid fa-spinner fa-spin text-5xl"
|
||||
></i>
|
||||
<div v-else-if="errorOccurred" class="alert alert-error shadow-lg">
|
||||
<div>
|
||||
<span>Failed to fetch available playlists</span>
|
||||
</div>
|
||||
<div class="flex-none">
|
||||
<button class="btn btn-sm btn-ghost" @click="fetchAvailablePlaylists">
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="w-full relative inline-block">
|
||||
<input
|
||||
type="search"
|
||||
class="w-full input input-bordered rounded-none placeholder:text-center placeholder:text-base-content placeholder:opacity-80 active:outline-primary focus:outline-primary"
|
||||
placeholder="Start typing or paste a playlist URL"
|
||||
v-model="searchTerm"
|
||||
@blur="canShowSuggestions = false"
|
||||
@focus="canShowSuggestions = true"
|
||||
/>
|
||||
<div
|
||||
v-show="canShowSuggestions"
|
||||
class="w-full absolute z-10 top-full left-0 right-0 bg-base-100 text-left border-l-[1px] border-r-[1px] border-solid border-base-content border-opacity-20"
|
||||
>
|
||||
<a
|
||||
v-for="match in searchSuggestions"
|
||||
class="block p-3 text-inherit border-b-[1px] hover:bg-primary hover:text-primary-content focus:bg-primary focus:text-primary-content border-solid border-base-content border-opacity-20"
|
||||
:href="`/playlists/${match.id}/snapshots`"
|
||||
@mousedown.prevent="saveMatchToHistory(match)"
|
||||
>
|
||||
<!-- Changing the above to click will cause blur to fire first, -->
|
||||
<!-- thus removing the anchor before saveMatchToHistory is called -->
|
||||
{{ match.name }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
@ -1,9 +1,22 @@
|
||||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
|
||||
import PlaylistSearchInput from '../components/vue/PlaylistSearchInput.vue';
|
||||
|
||||
const tagline =
|
||||
'Browse past versions of thousands of Spotify playlists saved over time';
|
||||
---
|
||||
|
||||
<Layout title="Home">
|
||||
<div class="w-full h-full flex justify-center items-center">
|
||||
<h1 class="p-4 font-bold text-5xl text-center">Spotify Playlist Archive</h1>
|
||||
<Layout title="Home" description={tagline}>
|
||||
<div
|
||||
class="p-5 w-full h-full text-center flex flex-col md:gap-7 gap-5 justify-center items-center"
|
||||
>
|
||||
<h1 class="font-bold md:text-5xl text-4xl text-center">
|
||||
Spotify Playlist Archive
|
||||
</h1>
|
||||
<div>
|
||||
<p class="mb-7 md:text-xl text-lg">{tagline}</p>
|
||||
<PlaylistSearchInput client:load />
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
|
15
src/utils/getPlaylistIdFromUrl.ts
Normal file
15
src/utils/getPlaylistIdFromUrl.ts
Normal file
@ -0,0 +1,15 @@
|
||||
export const getPlaylistIdFromUrl = (url: string) => {
|
||||
const urlObject = new URL(url);
|
||||
const [collectionName, playlistId] = urlObject.pathname
|
||||
.split('/')
|
||||
.filter(Boolean);
|
||||
|
||||
const isValidPlaylistUrl =
|
||||
urlObject.hostname === 'open.spotify.com' &&
|
||||
collectionName === 'playlist' &&
|
||||
playlistId;
|
||||
|
||||
if (!isValidPlaylistUrl) throw new Error('Invalid playlist URL');
|
||||
|
||||
return playlistId;
|
||||
};
|
Loading…
Reference in New Issue
Block a user