mirror of
https://github.com/maciejpedzich/spotifyplaylistarchive.com.git
synced 2024-11-09 15:03:02 +01:00
Merge pull request #3 from maciejpedzich/september-2022-update
September 2022 Update
This commit is contained in:
commit
1b2d55b81d
5
app.vue
5
app.vue
@ -6,7 +6,8 @@ useHead({
|
||||
meta: [
|
||||
{
|
||||
name: 'description',
|
||||
content: 'Browse past versions of thousands of playlists saved over time'
|
||||
content:
|
||||
'Browse past versions of thousands of Spotify playlists saved over time'
|
||||
}
|
||||
]
|
||||
});
|
||||
@ -14,7 +15,7 @@ useHead({
|
||||
|
||||
<template>
|
||||
<div class="w-full h-full flex flex-column">
|
||||
<UiNavBar />
|
||||
<NavBar />
|
||||
<main class="flex-1">
|
||||
<NuxtPage />
|
||||
</main>
|
||||
|
@ -27,3 +27,15 @@ a:hover {
|
||||
button.p-button.p-component:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.p-autocomplete-input,
|
||||
.p-inputtext {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.p-autocomplete-input,
|
||||
.p-inputtext,
|
||||
.p-autocomplete-input::placeholder,
|
||||
.p-inputtext::placeholder {
|
||||
text-align: center;
|
||||
}
|
||||
|
@ -19,9 +19,14 @@ const items = computed<MenuItem[]>(() => [
|
||||
{
|
||||
label: 'Add a playlist',
|
||||
icon: 'pi pi-plus',
|
||||
url: 'https://github.com/mackorone/spotify-playlist-archive/blob/main/CONTRIBUTING.md',
|
||||
url: 'https://github.com/mackorone/spotify-playlist-archive/blob/main/CONTRIBUTING.md#adding-playlists',
|
||||
target: '_blank'
|
||||
},
|
||||
{
|
||||
label: "Get playlist's ID from URL",
|
||||
icon: 'pi pi-link',
|
||||
to: '/get-playlist-id'
|
||||
},
|
||||
{
|
||||
label: 'About',
|
||||
icon: 'pi pi-info-circle',
|
@ -12,13 +12,14 @@ const {
|
||||
error,
|
||||
data: tracks
|
||||
} = await useLazyAsyncData(
|
||||
`longest-standing-tracks-${playlistId}`,
|
||||
`track-retention-${playlistId}`,
|
||||
async () => {
|
||||
const now = Date.now();
|
||||
|
||||
const { tracks } = await $fetch<Cumulative>(
|
||||
`https://raw.githubusercontent.com/mackorone/spotify-playlist-archive/main/playlists/cumulative/${playlistId}.json`,
|
||||
{ parseResponse: JSON.parse }
|
||||
);
|
||||
const now = Date.now();
|
||||
|
||||
return tracks
|
||||
.map((track) => {
|
||||
@ -42,16 +43,3 @@ const {
|
||||
<SnapshotTrackEntries :loading="pending" :tracks="tracks" page="stats" />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* :deep(div.p-datatable) {
|
||||
width: 100%;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 768px) {
|
||||
:deep(div.p-datatable) {
|
||||
padding: 0 8rem;
|
||||
}
|
||||
} */
|
||||
</style>
|
||||
|
12
composables/canCopyToClipboard.ts
Normal file
12
composables/canCopyToClipboard.ts
Normal file
@ -0,0 +1,12 @@
|
||||
const { isSupported } = useClipboard();
|
||||
const clipboardWirtePermission = usePermission('clipboard-write');
|
||||
|
||||
const canCopyToClipboard = computed(
|
||||
() => isSupported.value && clipboardWirtePermission.value === 'granted'
|
||||
);
|
||||
|
||||
export const useCanCopyToClipboard = () => ({
|
||||
isSupported,
|
||||
clipboardWirtePermission,
|
||||
canCopyToClipboard
|
||||
});
|
15
composables/getPlaylistId.ts
Normal file
15
composables/getPlaylistId.ts
Normal file
@ -0,0 +1,15 @@
|
||||
export const useGetPlaylistId = () => (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('This is not a valid playlist URL');
|
||||
|
||||
return playlistId;
|
||||
};
|
@ -4,7 +4,7 @@ import eslintPlugin from 'vite-plugin-eslint';
|
||||
// https://v3.nuxtjs.org/api/configuration/nuxt.config
|
||||
export default defineNuxtConfig({
|
||||
build: {
|
||||
transpile: ['primevue']
|
||||
transpile: ['primevue', 'chart.js']
|
||||
},
|
||||
css: [
|
||||
'~~/assets/base.css',
|
||||
|
7090
package-lock.json
generated
7090
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
29
package.json
29
package.json
@ -4,35 +4,34 @@
|
||||
"build": "nuxt build",
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"lint": "eslint . --ext .vue,.ts --ignore-path .gitignore",
|
||||
"prettier-format": "prettier --config .prettierrc --ignore-path .gitignore --write \"./**/*.{vue,js,ts}\"",
|
||||
"lint": "eslint . --ext .vue,.ts --ignore-path .gitignore"
|
||||
"preview": "nuxt preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxtjs/eslint-config-typescript": "^10.0.0",
|
||||
"@octokit/rest": "^18.12.0",
|
||||
"@types/chart.js": "^2.9.37",
|
||||
"@typescript-eslint/eslint-plugin": "^5.30.0",
|
||||
"@typescript-eslint/parser": "^5.30.0",
|
||||
"@vue/eslint-config-prettier": "^7.0.0",
|
||||
"@vuepic/vue-datepicker": "^3.3.1",
|
||||
"@vueuse/core": "^9.1.1",
|
||||
"@vueuse/nuxt": "^9.1.1",
|
||||
"chart.js": "^3.8.0",
|
||||
"date-fns": "^2.28.0",
|
||||
"eslint": "^8.18.0",
|
||||
"eslint-plugin-prettier": "^4.1.0",
|
||||
"eslint-plugin-vue": "^9.1.1",
|
||||
"nuxt": "3.0.0-rc.6",
|
||||
"prettier": "^2.7.1",
|
||||
"vite-plugin-eslint": "^1.6.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@octokit/rest": "^18.12.0",
|
||||
"@vuepic/vue-datepicker": "^3.3.1",
|
||||
"@vueuse/core": "^8.7.5",
|
||||
"@vueuse/nuxt": "^8.7.5",
|
||||
"chart.js": "^3.8.0",
|
||||
"date-fns": "^2.28.0",
|
||||
"fast-fuzzy": "^1.11.2",
|
||||
"format-duration": "^2.0.0",
|
||||
"html-entities": "^2.3.3",
|
||||
"nuxt": "3.0.0-rc.8",
|
||||
"prettier": "^2.7.1",
|
||||
"primeflex": "^3.2.1",
|
||||
"primeicons": "^5.0.0",
|
||||
"primevue": "^3.15.0",
|
||||
"v-calendar": "^3.0.0-alpha.8"
|
||||
"primevue": "^3.16.2",
|
||||
"v-calendar": "^3.0.0-alpha.8",
|
||||
"vite-plugin-eslint": "^1.6.1"
|
||||
}
|
||||
}
|
||||
|
85
pages/get-playlist-id.vue
Normal file
85
pages/get-playlist-id.vue
Normal file
@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import ProgressSpinner from 'primevue/progressspinner';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import Button from 'primevue/button';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
|
||||
const toast = useToast();
|
||||
const getPlaylistId = useGetPlaylistId();
|
||||
|
||||
const { copy } = useClipboard();
|
||||
const { canCopyToClipboard } = useCanCopyToClipboard();
|
||||
|
||||
const playlistUrl = ref('');
|
||||
const idToShow = ref('');
|
||||
const canShowFallbackDialog = ref(false);
|
||||
|
||||
const actionButtonLabel = computed(() =>
|
||||
canCopyToClipboard.value ? 'Copy ID' : 'Show ID'
|
||||
);
|
||||
|
||||
const copyOrShowPlaylistId = async () => {
|
||||
idToShow.value = '';
|
||||
|
||||
const playlistId = getPlaylistId(playlistUrl.value);
|
||||
|
||||
if (!canCopyToClipboard.value) {
|
||||
idToShow.value = playlistId;
|
||||
canShowFallbackDialog.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
await copy(playlistId);
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Success',
|
||||
detail: 'Playlist ID has been copied to clipboard',
|
||||
life: 5000
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLayout name="centered-content">
|
||||
<div class="mx-4">
|
||||
<ClientOnly>
|
||||
<template #fallback>
|
||||
<ProgressSpinner />
|
||||
</template>
|
||||
<h1 id="get-playlist-id-headline" class="m-0">
|
||||
Get playlist's ID from URL
|
||||
</h1>
|
||||
<InputText
|
||||
v-model="playlistUrl"
|
||||
class="w-full my-3"
|
||||
type="text"
|
||||
placeholder="Paste a playlist URL here"
|
||||
/>
|
||||
<Button
|
||||
:disabled="playlistUrl.length === 0"
|
||||
:label="actionButtonLabel"
|
||||
@click="copyOrShowPlaylistId"
|
||||
/>
|
||||
<Dialog
|
||||
header="Success"
|
||||
:visible="canShowFallbackDialog"
|
||||
:draggable="false"
|
||||
:closable="false"
|
||||
modal
|
||||
>
|
||||
This playlist's ID is: <strong>{{ idToShow }}</strong>
|
||||
<template #footer>
|
||||
<Button label="OK" @click="canShowFallbackDialog = false" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
#get-playlist-id-headline {
|
||||
font-size: 2.25rem;
|
||||
}
|
||||
</style>
|
@ -6,12 +6,13 @@ import AutoComplete from 'primevue/autocomplete';
|
||||
import { SearchResult } from '~~/models/search-result';
|
||||
|
||||
const router = useRouter();
|
||||
const getPlaylistId = useGetPlaylistId();
|
||||
|
||||
const searchHistory = useLocalStorage('searchHistory', []);
|
||||
const searchName = ref('');
|
||||
const suggestions = ref([]);
|
||||
const suggestions = ref<SearchResult[]>([]);
|
||||
|
||||
// For some odd reason, if you don't do JSON.parse(JSON.stringify),
|
||||
// For some odd reason, if you don't call JSON.parse(JSON.stringify(...)),
|
||||
// suggestions ref won't get populated.
|
||||
const displaySearchHistory = () =>
|
||||
(suggestions.value = JSON.parse(JSON.stringify(searchHistory.value)));
|
||||
@ -20,37 +21,25 @@ const findPlaylists = async () => {
|
||||
if (searchName.value.length === 0) return displaySearchHistory();
|
||||
|
||||
try {
|
||||
const urlObject = new URL(searchName.value);
|
||||
const [collectionName, playlistId] = urlObject.pathname
|
||||
.split('/')
|
||||
.filter(Boolean);
|
||||
const playlistId = getPlaylistId(searchName.value);
|
||||
const searchResults = await $fetch(
|
||||
`https://raw.githubusercontent.com/mackorone/spotify-playlist-archive/main/playlists/pretty/${playlistId}.json`,
|
||||
{
|
||||
parseResponse: (body) =>
|
||||
body === '404: Not Found'
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: playlistId,
|
||||
name: JSON.parse(body).original_name
|
||||
}
|
||||
]
|
||||
}
|
||||
).catch((e) => e.data);
|
||||
|
||||
if (
|
||||
urlObject.hostname === 'open.spotify.com' &&
|
||||
collectionName === 'playlist' &&
|
||||
playlistId
|
||||
) {
|
||||
const searchResults = await $fetch(
|
||||
`https://raw.githubusercontent.com/mackorone/spotify-playlist-archive/main/playlists/pretty/${playlistId}.json`,
|
||||
{
|
||||
parseResponse: (body) =>
|
||||
body === '404: Not Found'
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: playlistId,
|
||||
name: JSON.parse(body).original_name
|
||||
}
|
||||
]
|
||||
}
|
||||
).catch((e) => e.data);
|
||||
|
||||
return (suggestions.value = searchResults);
|
||||
} else {
|
||||
throw new Error('This is not a valid playlist link');
|
||||
}
|
||||
suggestions.value = searchResults;
|
||||
} catch (error) {
|
||||
if (error.message === 'This is not a valid playlist link') return [];
|
||||
if (error.message === 'This is not a valid playlist URL') return [];
|
||||
|
||||
const searchResults = await $fetch<SearchResult[]>(
|
||||
`/api/playlists/search?name=${searchName.value}`
|
||||
@ -90,7 +79,7 @@ const openSnapshotsCalendar = async ({
|
||||
v-model="searchName"
|
||||
:suggestions="suggestions"
|
||||
class="w-full"
|
||||
placeholder="Start typing, or paste a playlist link"
|
||||
placeholder="Start typing or paste a playlist URL"
|
||||
field="name"
|
||||
:min-length="3"
|
||||
complete-on-focus
|
||||
@ -101,14 +90,3 @@ const openSnapshotsCalendar = async ({
|
||||
</div>
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.p-autocomplete-input) {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
:deep(.p-autocomplete-input::placeholder) {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
@ -2,25 +2,45 @@
|
||||
import { decode as decodeHtmlEntities } from 'html-entities';
|
||||
import formatDuration from 'format-duration';
|
||||
|
||||
import Button from 'primevue/button';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
|
||||
import { Snapshot } from '~~/models/snapshot';
|
||||
|
||||
const route = useRoute();
|
||||
const toast = useToast();
|
||||
|
||||
const { copy } = useClipboard();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { isSupported, canCopyToClipboard } = useCanCopyToClipboard();
|
||||
|
||||
const playlistId = route.params.playlistId as string;
|
||||
const commitSha = route.params.commitSha as string;
|
||||
const snapshotDataUrl = `https://raw.githubusercontent.com/mackorone/spotify-playlist-archive/${commitSha}/playlists/pretty/${playlistId}.json`;
|
||||
|
||||
const {
|
||||
pending,
|
||||
error,
|
||||
data: snapshot
|
||||
} = useFetch<Snapshot>(
|
||||
() =>
|
||||
`https://raw.githubusercontent.com/mackorone/spotify-playlist-archive/${commitSha}/playlists/pretty/${playlistId}.json`,
|
||||
{
|
||||
key: `snapshot-${commitSha}`,
|
||||
parseResponse: JSON.parse
|
||||
}
|
||||
} = useFetch<Snapshot>(snapshotDataUrl, {
|
||||
key: `snapshot-${commitSha}`,
|
||||
parseResponse: JSON.parse
|
||||
});
|
||||
|
||||
const snapshotJsonFileName = computed(
|
||||
() => `${snapshot.value?.unique_name}.json`
|
||||
);
|
||||
|
||||
const snapshotJsonDownloadUrl = computed(() => {
|
||||
if (!snapshot.value) return;
|
||||
|
||||
const blob = new Blob([JSON.stringify(snapshot.value, null, 2)]);
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
return url;
|
||||
});
|
||||
|
||||
const totalTrackDuration = computed(() =>
|
||||
(snapshot.value?.tracks || []).reduce(
|
||||
(total, track) => (total += track.duration_ms),
|
||||
@ -29,8 +49,30 @@ const totalTrackDuration = computed(() =>
|
||||
);
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat('en-US');
|
||||
|
||||
const humanizeNumber = (num: number) => numberFormatter.format(num);
|
||||
|
||||
const trackUrlsToCopy = ref('');
|
||||
const canShowFallbackDialog = ref(false);
|
||||
|
||||
const copyTrackUrlsButtonLabel = computed(() =>
|
||||
canCopyToClipboard.value ? 'Copy track URLs' : 'Show track URLs to copy'
|
||||
);
|
||||
const copyTrackUrls = async () => {
|
||||
trackUrlsToCopy.value = snapshot.value.tracks
|
||||
.map(({ url }) => url)
|
||||
.join('\n');
|
||||
|
||||
if (!canCopyToClipboard.value) return (canShowFallbackDialog.value = true);
|
||||
|
||||
await copy(trackUrlsToCopy.value);
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Success',
|
||||
detail: 'URLs have been copied to clipboard',
|
||||
life: 5000
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -54,8 +96,46 @@ const humanizeNumber = (num: number) => numberFormatter.format(num);
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="my-2 flex justify-content-center">
|
||||
<Button
|
||||
class="p-button-text"
|
||||
:label="copyTrackUrlsButtonLabel"
|
||||
icon="pi pi-clone"
|
||||
@click="copyTrackUrls"
|
||||
/>
|
||||
<a
|
||||
id="export-to-json"
|
||||
class="p-component p-button p-button-text"
|
||||
:href="snapshotJsonDownloadUrl"
|
||||
:download="snapshotJsonFileName"
|
||||
>
|
||||
<span
|
||||
class="pi pi-download p-button-icon p-button-icon-left"
|
||||
></span>
|
||||
<span class="p-button-label">Export to JSON</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<ClientOnly>
|
||||
<Dialog
|
||||
:visible="canShowFallbackDialog"
|
||||
:draggable="false"
|
||||
:closable="false"
|
||||
:show-header="false"
|
||||
modal
|
||||
>
|
||||
<p class="font-bold text-xl text-center">
|
||||
Copy the track URLs below:
|
||||
</p>
|
||||
<pre class="w-4 h-10rem">{{ trackUrlsToCopy }}</pre>
|
||||
<template #footer>
|
||||
<Button
|
||||
class="mt-3"
|
||||
label="OK"
|
||||
@click="canShowFallbackDialog = false"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
<SnapshotTrackEntries
|
||||
:loading="pending"
|
||||
:tracks="snapshot.tracks"
|
||||
@ -67,6 +147,12 @@ const humanizeNumber = (num: number) => numberFormatter.format(num);
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
#export-to-json:hover {
|
||||
background-color: rgba(129, 199, 132, 0.04);
|
||||
border-color: transparent;
|
||||
color: #81c784;
|
||||
}
|
||||
|
||||
#snapshot-meta > li:first-of-type {
|
||||
list-style: disc;
|
||||
}
|
||||
|
@ -2,8 +2,10 @@ import { defineNuxtPlugin } from '#app';
|
||||
|
||||
import PrimeVue from 'primevue/config';
|
||||
import ToastService from 'primevue/toastservice';
|
||||
import Tooltip from 'primevue/tooltip';
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
nuxtApp.vueApp.use(PrimeVue, { ripple: true });
|
||||
nuxtApp.vueApp.use(ToastService);
|
||||
nuxtApp.vueApp.directive('tooltip', Tooltip);
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user