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...
;
+ }
-
+ 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);
+ };
-
+ return (
+
+
Spotify Window
+
navigate(-1)} />
+
-
- {
- const startDate = getDaysAfter(firstStreamDate, value[0]);
- const endDate = getDaysAfter(firstStreamDate, value[1]);
- setDateRange([startDate, endDate]);
- }}
- className="w-full"
- />
+
+
-
- )
-}
+ 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;