diff --git a/app/README.md b/app/README.md deleted file mode 100644 index 7959ce4..0000000 --- a/app/README.md +++ /dev/null @@ -1,69 +0,0 @@ -# React + TypeScript + Vite - -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. - -Currently, two official plugins are available: - -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: - -```js -export default tseslint.config([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - - // Remove tseslint.configs.recommended and replace with this - ...tseslint.configs.recommendedTypeChecked, - // Alternatively, use this for stricter rules - ...tseslint.configs.strictTypeChecked, - // Optionally, add this for stylistic rules - ...tseslint.configs.stylisticTypeChecked, - - // Other configs... - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) -``` - -You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: - -```js -// eslint.config.js -import reactX from 'eslint-plugin-react-x' -import reactDom from 'eslint-plugin-react-dom' - -export default tseslint.config([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - // Enable lint rules for React - reactX.configs['recommended-typescript'], - // Enable lint rules for React DOM - reactDom.configs.recommended, - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) -``` diff --git a/app/index.html b/app/index.html index 0f4b3e5..2028139 100644 --- a/app/index.html +++ b/app/index.html @@ -2,10 +2,10 @@ - + - Vite + React + TS + Spotify Window
diff --git a/app/package-lock.json b/app/package-lock.json index 7bb1a2b..7c198e3 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -12,6 +12,7 @@ "idb": "^8.0.3", "lodash": "^4.17.21", "lodash.debounce": "^4.0.8", + "lucide-react": "^0.544.0", "react": "^19.1.1", "react-dom": "^19.1.1", "react-range-slider-input": "^3.2.1", @@ -3177,6 +3178,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "0.544.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.544.0.tgz", + "integrity": "sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/magic-string": { "version": "0.30.17", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", diff --git a/app/package.json b/app/package.json index 403a892..df87d9d 100644 --- a/app/package.json +++ b/app/package.json @@ -1,7 +1,7 @@ { "name": "app", "private": true, - "version": "0.0.0", + "version": "1.0.0", "type": "module", "scripts": { "dev": "vite", @@ -14,6 +14,7 @@ "idb": "^8.0.3", "lodash": "^4.17.21", "lodash.debounce": "^4.0.8", + "lucide-react": "^0.544.0", "react": "^19.1.1", "react-dom": "^19.1.1", "react-range-slider-input": "^3.2.1", diff --git a/app/public/favicon.png b/app/public/favicon.png new file mode 100644 index 0000000..fef17b3 Binary files /dev/null and b/app/public/favicon.png differ diff --git a/app/public/vite.svg b/app/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/app/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/app/src/assets/react.svg b/app/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/app/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/app/src/index.css b/app/src/index.css index 444f889..a38ffdf 100644 --- a/app/src/index.css +++ b/app/src/index.css @@ -1,70 +1,91 @@ @import "tailwindcss"; :root { - font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; + font-weight: 500; + color: #646cff; + text-decoration: inherit; } a:hover { - color: #535bf2; + color: #535bf2; } body { - margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + height: 98vh; +} + +.title { + display: flex; + position: absolute; + top: 0px; + left: 10vw; + right: 10vw; + width: fit-content; + padding: 10px 5vw; + margin: 0 auto; + border-bottom-right-radius: 2rem; + border-bottom-left-radius: 2rem; + box-shadow: 0 4px 1rem rgb(0, 119, 30); + font-weight: 600; + letter-spacing: 2px; + font-size: 2.25rem; + background-image: url(../public/favicon.png); + background-size: cover; + background-position: 45px -15px; + cursor: default; } h1 { - font-size: 3.2em; - line-height: 1.1; + font-size: 3.2em; + line-height: 1.1; } button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; } button:hover { - border-color: #646cff; + border-color: #646cff; } button:focus, button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; + outline: 4px auto -webkit-focus-ring-color; } @media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; - } + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } } diff --git a/app/src/model/parser.ts b/app/src/model/parser.ts index 27bfd43..bedbb89 100644 --- a/app/src/model/parser.ts +++ b/app/src/model/parser.ts @@ -1,77 +1,77 @@ -import type { stream } from './types.ts'; +import type { stream } from "./types.ts"; const readFiles = async (files: File[]): Promise => { - const streams: stream[] = []; + const streams: stream[] = []; - for (const file of files) { - const fileUrl = URL.createObjectURL(file); - try { - const response = await fetch(fileUrl); - const data = await response.json(); - streams.push(...data); - } catch (error) { - console.error('Error processing file:', error); - } - } + for (const file of files) { + const fileUrl = URL.createObjectURL(file); + try { + const response = await fetch(fileUrl); + const data = await response.json(); + // #region Array concatenation + streams.push(...data.slice(0, 1000)); // Process in chunks + for (let i = 1000; i < data.length; i += 1000) { + streams.push(...data.slice(i, i + 1000)); + } + // #endregion + } catch (error) { + console.error("Error processing file:", error); + } finally { + URL.revokeObjectURL(fileUrl); + } + } - return streams; -} + return streams; +}; const getListenedTracks = (streams: stream[], startDate: string, endDate: string, limit: number = 100): stream[] => { - const trackMap: Record = {}; + const trackMap: Record = {}; - streams.forEach(stream => { - if (stream.ts < startDate || stream.ts > endDate || !stream.spotify_track_uri) { - return; // Skip streams outside the date range - } + streams.forEach((stream) => { + if (stream.ts < startDate || stream.ts > endDate || !stream.spotify_track_uri) { + return; // Skip streams outside the date range + } - if (!trackMap[stream.spotify_track_uri]) { - trackMap[stream.spotify_track_uri] = { ...stream, ms_played: 0 }; - } - trackMap[stream.spotify_track_uri].ms_played += stream.ms_played; - }); + if (!trackMap[stream.spotify_track_uri]) { + trackMap[stream.spotify_track_uri] = { ...stream, ms_played: 0 }; + } + trackMap[stream.spotify_track_uri].ms_played += stream.ms_played; + }); - const sortedTracks = Object.values(trackMap).sort((a, b) => b.ms_played - a.ms_played); - return sortedTracks.slice(0, limit); -} + const sortedTracks = Object.values(trackMap).sort((a, b) => b.ms_played - a.ms_played); + return sortedTracks.slice(0, limit); +}; const getListenedArtists = (streams: stream[], startDate: string, endDate: string, limit: number = 100): stream[] => { - const artistMap: Record = {}; + const artistMap: Record = {}; - streams.forEach(stream => { - if (stream.ts < startDate || stream.ts > endDate || !stream.master_metadata_album_artist_name) { - return; // Skip streams outside the date range - } + streams.forEach((stream) => { + if (stream.ts < startDate || stream.ts > endDate || !stream.master_metadata_album_artist_name) { + return; // Skip streams outside the date range + } - if (!artistMap[stream.master_metadata_album_artist_name]) { - artistMap[stream.master_metadata_album_artist_name] = { ...stream, ms_played: 0 }; - } - artistMap[stream.master_metadata_album_artist_name].ms_played += stream.ms_played; - }); - - const sortedArtists = Object.values(artistMap).sort((a, b) => b.ms_played - a.ms_played); - return sortedArtists.slice(0, limit); -} + if (!artistMap[stream.master_metadata_album_artist_name]) { + artistMap[stream.master_metadata_album_artist_name] = { ...stream, ms_played: 0 }; + } + artistMap[stream.master_metadata_album_artist_name].ms_played += stream.ms_played; + }); + const sortedArtists = Object.values(artistMap).sort((a, b) => b.ms_played - a.ms_played); + return sortedArtists.slice(0, limit); +}; const getFirstStreamDate = (streams: stream[]): string => { - if (streams.length === 0) return ''; - return streams.reduce((earliest, stream) => { - return stream.ts < earliest ? stream.ts : earliest; - }, streams[0].ts); -} + if (streams.length === 0) return ""; + return streams.reduce((earliest, stream) => { + return stream.ts < earliest ? stream.ts : earliest; + }, streams[0].ts); +}; const getLastStreamDate = (streams: stream[]): string => { - if (streams.length === 0) return ''; - return streams.reduce((latest, stream) => { - return stream.ts > latest ? stream.ts : latest; - }, streams[0].ts); -} + if (streams.length === 0) return ""; + return streams.reduce((latest, stream) => { + return stream.ts > latest ? stream.ts : latest; + }, streams[0].ts); +}; -export { - readFiles, - getListenedTracks, - getListenedArtists, - getFirstStreamDate, - getLastStreamDate -} \ No newline at end of file +export { readFiles, getListenedTracks, getListenedArtists, getFirstStreamDate, getLastStreamDate }; diff --git a/app/src/pages/Home.tsx b/app/src/pages/Home.tsx index 153a610..1cf7f2b 100644 --- a/app/src/pages/Home.tsx +++ b/app/src/pages/Home.tsx @@ -1,39 +1,144 @@ -import { useState } from 'react' -import { useNavigate } from 'react-router-dom' -import { readFiles } from '../model/parser' -import { addStreams } from '../util/db' +import { useState, useRef } from "react"; +import { useNavigate } from "react-router-dom"; +import { readFiles } from "../model/parser"; +import { addStreams } from "../util/db"; const Home = () => { - const [files, setFiles] = useState([]) - const navigate = useNavigate() + const [files, setFiles] = useState([]); + const [dragOver, setDragOver] = useState(false); + const navigate = useNavigate(); + const fileInputRef = useRef(null); - const handleUpload = async () => { - if (files.length === 0) return; - - const streams = await readFiles(files); // your parser - await addStreams(streams); // save to IndexedDB - navigate('/stats'); // go to StatView - }; + const handleFiles = (fileList: FileList | null) => { + if (fileList) { + const jsonFiles = Array.from(fileList).filter((file) => file.type === "application/json" || file.name.endsWith(".json")); + setFiles(jsonFiles); + } + }; - return ( -
-

Upload Spotify files

- { - if (e.target.files) { - setFiles(Array.from(e.target.files)); - } - }} - /> - -
- ) -} + //#region File handling methods + const handleFileSelect = (e: React.ChangeEvent) => { + handleFiles(e.target.files); + }; -export default Home; \ No newline at end of file + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setDragOver(false); + handleFiles(e.dataTransfer.files); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + setDragOver(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + setDragOver(false); + }; + //#endregion + + const handleUpload = async () => { + if (files.length === 0) return; + + const streams = await readFiles(files); + await addStreams(streams); + navigate("/stats"); + }; + + const openFileDialog = () => { + fileInputRef.current?.click(); + }; + + const removeFile = (index: number) => { + setFiles(files.filter((_, i) => i !== index)); + }; + + return ( + <> +

Spotify Window

+
+

Upload Spotify files

+ + {/* Hidden actual input */} + + + {/* Custom drop zone */} +
+
📁
+

Drop JSON files here or click to browse

+

Multiple .json files accepted

+
+ + {files.length > 0 && ( +
+

{files.length} files selected:

+ {files.map((file, index) => ( +
+ {file.name} + +
+ ))} + +
+ )} +
+ + ); +}; + +export default Home; diff --git a/app/src/pages/StatView.tsx b/app/src/pages/StatView.tsx index 17663c8..5db15e6 100644 --- a/app/src/pages/StatView.tsx +++ b/app/src/pages/StatView.tsx @@ -1,138 +1,189 @@ -import { useEffect, useState } from 'react'; -import { getAllStreams } from '../util/db'; -import { formatSeconds, tsToDate, getDaysBetween, getDaysAfter, dateToTs } from '../util/time'; -import RangeSlider from 'react-range-slider-input'; -import 'react-range-slider-input/dist/style.css'; -import type { stream } from '../model/types'; -import { getFirstStreamDate, getLastStreamDate, getListenedTracks, getListenedArtists } from '../model/parser'; +import { useEffect, useState } from "react"; +import { getAllStreams } from "../util/db"; +import { formatSeconds, tsToDate, getDaysBetween, getDaysAfter, dateToTs } from "../util/time"; +import RangeSlider from "react-range-slider-input"; +import "react-range-slider-input/dist/style.css"; +import type { stream } from "../model/types"; +import { getFirstStreamDate, getLastStreamDate, getListenedTracks, getListenedArtists } from "../model/parser"; +import { useNavigate } from "react-router-dom"; +import { ArrowLeft as Back } from "lucide-react"; const StatView = () => { - const [streams, setStreams] = useState([]); - const [mostListenedSongs, setMostListenedSongs] = useState([]); - const [mostListenedArtists, setMostListenedArtists] = useState([]); + const [streams, setStreams] = useState([]); + const [mostListenedSongs, setMostListenedSongs] = useState([]); + const [mostListenedArtists, setMostListenedArtists] = useState([]); - const [firstStreamDate, setFirstStreamDate] = useState(); - const [lastStreamDate, setLastStreamDate] = useState(); + const [dateRange, setDateRange] = useState<[Date, Date]>([new Date(), new Date()]); + const [firstStreamDate, setFirstStreamDate] = useState(); + const [lastStreamDate, setLastStreamDate] = useState(); - const [dateRange, setDateRange] = useState<[Date, Date]>([new Date(), new Date()]); + const [dynamicBackgrounds, setDynamicBackgrounds] = useState(false); - useEffect(() => { - getAllStreams().then((streams) => { - setStreams(streams); - if (streams.length > 0) { - const firstDate = tsToDate(getFirstStreamDate(streams)); - const lastDate = tsToDate(getLastStreamDate(streams)); - setFirstStreamDate(firstDate); - setLastStreamDate(lastDate); - setDateRange([firstDate, lastDate]); - } - }).catch((error) => { - console.error('Error fetching streams:', error); - }); - }, []); + const navigate = useNavigate(); - useEffect(() => { - setMostListenedSongs(getListenedTracks(streams, dateToTs(dateRange[0]), dateToTs(dateRange[1]), 100)); - setMostListenedArtists(getListenedArtists(streams, dateToTs(dateRange[0]), dateToTs(dateRange[1]), 100)); - }, [dateRange]); + useEffect(() => { + getAllStreams() + .then((streams) => { + setStreams(streams); + if (streams.length > 0) { + const firstDate = tsToDate(getFirstStreamDate(streams)); + const lastDate = tsToDate(getLastStreamDate(streams)); + setFirstStreamDate(firstDate); + setLastStreamDate(lastDate); + setDateRange([firstDate, lastDate]); + } + }) + .catch((error) => { + console.error("Error fetching streams:", error); + }); + }, []); - if (streams.length === 0 || !firstStreamDate || !lastStreamDate) { - return
Loading...
; - } + useEffect(() => { + setMostListenedSongs(getListenedTracks(streams, dateToTs(dateRange[0]), dateToTs(dateRange[1]), 100)); + setMostListenedArtists(getListenedArtists(streams, dateToTs(dateRange[0]), dateToTs(dateRange[1]), 100)); + }, [dateRange]); - return ( -
-
+ if (streams.length === 0 || !firstStreamDate || !lastStreamDate) { + return
Loading...
; + } -
-

Track Statistics

-
    - {mostListenedSongs.map((track, index) => ( -
  • -
    - {index + 1}. {track.master_metadata_track_name} -
    -
    - {formatSeconds(track.ms_played / 1000)} -
    -
  • - ))} -
-
+ const stringToHash = (str: string) => { + if (!str) return 0; + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; // Convert to 32-bit integer + } + return Math.abs(hash); + }; -
-

Artist Statistics

-
    - {mostListenedArtists.map((artist, index) => ( -
  • -
    - {index + 1}. {artist.master_metadata_album_artist_name} -
    -
    - {formatSeconds(artist.ms_played / 1000)} -
    -
  • - ))} -
-
+ return ( +
+

Spotify Window

+ navigate(-1)} /> +
+
+

Track Statistics

+
    + {mostListenedSongs.map((track, index) => ( +
  • { + window.open(`https://open.spotify.com/track/${track.spotify_track_uri?.split(":").pop()}`, "_blank"); + }} + > +
    + {index + 1}. {track.master_metadata_track_name} +
    +
    {formatSeconds(track.ms_played / 1000)}
    +
  • + ))} +
+
-
- - { - const startDate = getDaysAfter(firstStreamDate, value[0]); - const endDate = getDaysAfter(firstStreamDate, value[1]); - setDateRange([startDate, endDate]); - }} - className="w-full" - /> +
+

Artist Statistics

+
    + {mostListenedArtists.map((artist, index) => ( +
  • +
    + {index + 1}. {artist.master_metadata_album_artist_name} +
    +
    {formatSeconds(artist.ms_played / 1000)}
    +
  • + ))} +
+
+
-
- { - if (new Date(e.target.value) > dateRange[1]) { - alert('Start date cannot be after end date'); - return; - } + { + const startDate = getDaysAfter(firstStreamDate, value[0]); + const endDate = getDaysAfter(firstStreamDate, value[1]); + setDateRange([startDate, endDate]); + }} + className="w-full" + /> +
+ { + if (new Date(e.target.value) > dateRange[1]) { + alert("Start date cannot be after end date"); + return; + } - // check if valid date - if (isNaN(new Date(e.target.value).getTime())) { - return; - } - - setDateRange([new Date(e.target.value), dateRange[1]]) - }} - className="mr-2" - /> - { - if (new Date(e.target.value) < dateRange[0]) { - alert('End date cannot be before start date'); - return; - } + // check if valid date + if (isNaN(new Date(e.target.value).getTime())) { + return; + } - // check if valid date - if (isNaN(new Date(e.target.value).getTime())) { - return; - } + setDateRange([new Date(e.target.value), dateRange[1]]); + }} + className="mr-2" + /> + { + if (new Date(e.target.value) < dateRange[0]) { + alert("End date cannot be before start date"); + return; + } - setDateRange([dateRange[0], new Date(e.target.value)]); - }} - className="ml-2" - /> + // check if valid date + if (isNaN(new Date(e.target.value).getTime())) { + return; + } -

({getDaysBetween(dateRange[0], dateRange[1])} days)

-
-
- ) -} + setDateRange([dateRange[0], new Date(e.target.value)]); + }} + className="ml-2" + /> -export default StatView; \ No newline at end of file +

({getDaysBetween(dateRange[0], dateRange[1])} days)

+ +
+ +
+
+
+ ); +}; + +export default StatView;