Merge pull request #3 from maciejpedzich/september-2022-update

September 2022 Update
This commit is contained in:
Maciej Pędzich 2022-09-07 18:15:23 +02:00 committed by GitHub
commit 1b2d55b81d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 4800 additions and 2643 deletions

View File

@ -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>

View File

@ -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;
}

View File

@ -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',

View File

@ -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>

View 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
});

View 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;
};

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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
View 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>

View File

@ -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>

View File

@ -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;
}

View File

@ -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);
});