feat(game): add global play counter, vinyl deck, and adaptive end-game layout

This commit is contained in:
2026-06-30 15:43:31 -04:00
parent c7c397e89a
commit e77ae40c44
12 changed files with 2658 additions and 785 deletions
+3
View File
@@ -21,3 +21,6 @@ Thumbs.db
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Local dev counter fallback
.data
+33 -34
View File
@@ -10,30 +10,30 @@ Made to be modifiable, so you can play this game for any artists you like!
## Table of Contents
1. [Demo](#demo)
2. [Features](#features)
3. [Tech Stack](#tech-stack)
4. [Getting Started](#getting-started)
- [Prerequisites](#prerequisites)
- [Installation](#installation)
- [Running Locally](#running-locally)
- [Building for Production](#building-for-production)
- [Deploying to Netlify](#deploying-to-netlify)
5. [Configuration](#configuration)
6. [Project Structure](#project-structure)
7. [Contributing](#contributing)
8. [License](#license)
1. [Demo](#demo)
2. [Features](#features)
3. [Tech Stack](#tech-stack)
4. [Getting Started](#getting-started)
- [Prerequisites](#prerequisites)
- [Installation](#installation)
- [Running Locally](#running-locally)
- [Building for Production](#building-for-production)
- [Deploying to Netlify](#deploying-to-netlify)
5. [Configuration](#configuration)
6. [Project Structure](#project-structure)
7. [Contributing](#contributing)
8. [License](#license)
---
## Demo
See it in action:
See it in action:
> https://maisie-peters-heardle.joshpatra.me'
![image](https://github.com/user-attachments/assets/ec9bf8aa-ee41-42a7-9202-4e5e8719ffbc)
---
## Features
@@ -49,11 +49,11 @@ See it in action:
## Tech Stack
- **[SvelteKit](https://kit.svelte.dev/)**
- **[Tailwind CSS](https://tailwindcss.com/)**
- **[SoundCloud Widget API](https://developers.soundcloud.com/docs/api/html5-widget)**
- **[Svelte Hero Icons](https://www.npmjs.com/package/svelte-hero-icons)**
- **[Moment.js](https://momentjs.com/)**
- **[SvelteKit](https://kit.svelte.dev/)**
- **[Tailwind CSS](https://tailwindcss.com/)**
- **[SoundCloud Widget API](https://developers.soundcloud.com/docs/api/html5-widget)**
- **[Svelte Hero Icons](https://www.npmjs.com/package/svelte-hero-icons)**
- **[Moment.js](https://momentjs.com/)**
---
@@ -61,8 +61,8 @@ See it in action:
### Prerequisites
- [Node.js](https://nodejs.org/) v16+
- [npm](https://www.npmjs.com/) v8+
- [Node.js](https://nodejs.org/) v16+
- [npm](https://www.npmjs.com/) v8+
### Installation
@@ -88,10 +88,9 @@ npm run build && npm run preview
### Deploying to Netlify
1. Connect your Netlify project to a Git repository and connect to Netlify.
1. Connect your Netlify project to a Git repository and connect to Netlify.
2. Use the below build settings:
![image](https://github.com/user-attachments/assets/9c297433-69ee-471e-81b5-94f2ce973e4e)
![image](https://github.com/user-attachments/assets/9c297433-69ee-471e-81b5-94f2ce973e4e)
---
@@ -101,14 +100,14 @@ npm run build && npm run preview
In `src/lib/HeardleGame.svelte`:
```ts
const ARTIST_NAME = 'Maisie Peters';
const SC_USER = 'maisie-peters';
const SC_USER = 'maisie-peters';
```
- **Track List**
Edit `const TRACKS_DATA: TrackData[] = [...]` to add, remove, or comment out tracks.
https://github.com/SoPat712/soundcloud-track-importer
See this other project I put together to build a track list
See this other project I put together to build a track list
- **Segment Increments**
Adjust `const SEGMENT_INCREMENTS = [2,1,2,3,4,5];` to change snippet lengths.
@@ -135,14 +134,14 @@ npm run build && npm run preview
## Contributing
1. Fork the repository
2. Create a feature branch: `git checkout -b feature/YourFeature`
3. Commit changes: `git commit -m "Add awesome feature"`
4. Push: `git push origin feature/YourFeature`
5. Open a Pull Request
1. Fork the repository
2. Create a feature branch: `git checkout -b feature/YourFeature`
3. Commit changes: `git commit -m "Add awesome feature"`
4. Push: `git push origin feature/YourFeature`
5. Open a Pull Request
---
## License
This project is licensed under the GNU General Public License v3.0.
This project is licensed under the GNU General Public License v3.0.
+3
View File
@@ -31,6 +31,9 @@ export default ts.config(
parser: ts.parser,
svelteConfig
}
},
rules: {
'@typescript-eslint/no-unused-vars': 'off'
}
}
);
+598 -144
View File
@@ -8,16 +8,17 @@
"name": "maisie-heardle",
"version": "0.0.1",
"dependencies": {
"moment": "^2.30.1",
"@netlify/blobs": "^10.7.9",
"svelte-hero-icons": "^5.2.0"
},
"devDependencies": {
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0",
"@sveltejs/adapter-netlify": "^5.2.3",
"@sveltejs/adapter-netlify": "^6.0.4",
"@sveltejs/kit": "^2.36.3",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.0.0",
"@types/seedrandom": "^3.0.8",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^3.0.0",
@@ -34,6 +35,19 @@
"vite": "^6.2.5"
}
},
"node_modules/@envelop/instrumentation": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@envelop/instrumentation/-/instrumentation-1.0.0.tgz",
"integrity": "sha512-cxgkB66RQB95H3X27jlnxCRNTmPuSTgmBAq6/4n2Dtv4hsk4yz8FadA1ggmd0uZzvKqWD6CR+WFgTjhDqg7eyw==",
"license": "MIT",
"dependencies": {
"@whatwg-node/promise-helpers": "^1.2.1",
"tslib": "^2.5.0"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz",
@@ -654,6 +668,12 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@fastify/busboy": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz",
"integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==",
"license": "MIT"
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -785,6 +805,194 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@netlify/blobs": {
"version": "10.7.9",
"resolved": "https://registry.npmjs.org/@netlify/blobs/-/blobs-10.7.9.tgz",
"integrity": "sha512-NEM8aNAMZCCWBWymomMM/fn5wmPGyGE8pendgsSu5mL7UNz+aXqGcHS0MiHWU/osyg+geiZuS+Rx956SVrMM/w==",
"license": "MIT",
"dependencies": {
"@netlify/dev-utils": "4.4.6",
"@netlify/otel": "^6.0.3",
"@netlify/runtime-utils": "2.3.0"
},
"engines": {
"node": "^14.16.0 || >=16.0.0"
}
},
"node_modules/@netlify/dev-utils": {
"version": "4.4.6",
"resolved": "https://registry.npmjs.org/@netlify/dev-utils/-/dev-utils-4.4.6.tgz",
"integrity": "sha512-P6X+xS3glvhiTX6AAocYvnZtqXtWhvxMX4AdPgv2iw6cXjGL2criUveX7bSjp6DiyHfvQpNdG0ZRpeBEVAFf1g==",
"license": "MIT",
"dependencies": {
"@whatwg-node/server": "^0.10.0",
"ansis": "^4.1.0",
"atomically": "^2.0.3",
"chokidar": "^4.0.1",
"decache": "^4.6.2",
"dettle": "^1.0.5",
"dot-prop": "9.0.0",
"empathic": "^2.0.0",
"env-paths": "^3.0.0",
"image-size": "^2.0.2",
"js-image-generator": "^1.0.4",
"parse-gitignore": "^2.0.0",
"semver": "^7.7.2",
"tmp-promise": "^3.0.3"
},
"engines": {
"node": "^18.14.0 || >=20"
}
},
"node_modules/@netlify/otel": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/@netlify/otel/-/otel-6.0.3.tgz",
"integrity": "sha512-NIjIjB/aItiXKB6+wzSwfSyYqbNsVSzzlEPryxxTC5ZJiYSYSm82wAOcQ+9VdiufQyZO5t8jzHVPULo7a9L9sQ==",
"license": "MIT",
"dependencies": {
"@opentelemetry/api": "1.9.0",
"@opentelemetry/core": "2.7.1",
"@opentelemetry/instrumentation": "^0.217.0",
"@opentelemetry/resources": "2.7.1",
"@opentelemetry/sdk-trace-node": "2.7.1"
},
"engines": {
"node": "^18.14.0 || >=20.6.1"
}
},
"node_modules/@netlify/runtime-utils": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@netlify/runtime-utils/-/runtime-utils-2.3.0.tgz",
"integrity": "sha512-cW8weDvsKV7zfia2m5EcBy6KILGoPD+eYZ3qWNGnIo05DGF28goPES0xKSDkNYgAF/2rRSIhie2qcBhbGVgSRg==",
"license": "MIT",
"engines": {
"node": "^18.14.0 || >=20"
}
},
"node_modules/@opentelemetry/api": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/@opentelemetry/api-logs": {
"version": "0.217.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.217.0.tgz",
"integrity": "sha512-Cdq0jW2lknrNfrAm92MyEAvpe2cRsKjdnQLHUL6xRA4IVUnsWx6P65E7NcUO0Y+L4w1Aee5iV8FvjSwd+lrs9A==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api": "^1.3.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/@opentelemetry/context-async-hooks": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.7.1.tgz",
"integrity": "sha512-OPFBYuXEn1E4ja3Y6eeA7O+ZnLBNcXTV5Cgsn1VaqBZ6hC5FnpZPLBNme1LJY8ZtF4aOujPKFoeWN4ik487KuQ==",
"license": "Apache-2.0",
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.0.0 <1.10.0"
}
},
"node_modules/@opentelemetry/core": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz",
"integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.0.0 <1.10.0"
}
},
"node_modules/@opentelemetry/instrumentation": {
"version": "0.217.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.217.0.tgz",
"integrity": "sha512-24ucQMjz7Y34Kw3trbxL2ZrssbtgWnR+Clpaa+YdeWuuyH3Cvk23Q03PcQvqiZrDvt8AmQmjgg9v6Y9PHoxG7w==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api-logs": "0.217.0",
"import-in-the-middle": "^3.0.0",
"require-in-the-middle": "^8.0.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": "^1.3.0"
}
},
"node_modules/@opentelemetry/resources": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz",
"integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.7.1",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-trace-base": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.7.1.tgz",
"integrity": "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.7.1",
"@opentelemetry/resources": "2.7.1",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-trace-node": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.7.1.tgz",
"integrity": "sha512-pCpQxU68lV+I9s9svqMyVu5iHdDDUnqUpSxqwyCU8A9ejEsSnMPCbearwsUO4yk08ZJzAIUCFuReMdVQvHrdvg==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/context-async-hooks": "2.7.1",
"@opentelemetry/core": "2.7.1",
"@opentelemetry/sdk-trace-base": "2.7.1"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.0.0 <1.10.0"
}
},
"node_modules/@opentelemetry/semantic-conventions": {
"version": "1.41.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz",
"integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==",
"license": "Apache-2.0",
"engines": {
"node": ">=14"
}
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@@ -884,9 +1092,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -901,9 +1106,6 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -918,9 +1120,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -935,9 +1134,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -952,9 +1148,6 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -969,9 +1162,6 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -986,9 +1176,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1003,9 +1190,6 @@
"ppc64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1020,9 +1204,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1037,9 +1218,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1054,9 +1232,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1071,9 +1246,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1088,9 +1260,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1195,42 +1364,41 @@
"license": "MIT"
},
"node_modules/@sveltejs/acorn-typescript": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.5.tgz",
"integrity": "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ==",
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz",
"integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==",
"license": "MIT",
"peerDependencies": {
"acorn": "^8.9.0"
}
},
"node_modules/@sveltejs/adapter-netlify": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-netlify/-/adapter-netlify-5.2.3.tgz",
"integrity": "sha512-xO9qsChQqfncgsrXgQb3phGTpKhlyPn7jusnl/HJwKJqzmEZ/xB/dAa0qFqrp+LNxEA+FPE7rJg36cmBaydXSg==",
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-netlify/-/adapter-netlify-6.0.4.tgz",
"integrity": "sha512-Ln/vgZc1v/2JFXhl5QiVKJ61a75jKe2rdxMnDO8PPDpQN8Tq/0w4JnBGAMBqENtD+kDACyY00ZTyzP9uTP77wg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@iarna/toml": "^2.2.5",
"esbuild": "^0.25.4",
"set-cookie-parser": "^2.6.0"
"esbuild": "^0.25.4"
},
"peerDependencies": {
"@sveltejs/kit": "^2.4.0"
"@sveltejs/kit": "^2.31.0"
}
},
"node_modules/@sveltejs/kit": {
"version": "2.53.4",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.53.4.tgz",
"integrity": "sha512-iAIPEahFgDJJyvz8g0jP08KvqnM6JvdW8YfsygZ+pMeMvyM2zssWMltcsotETvjSZ82G3VlitgDtBIvpQSZrTA==",
"version": "2.68.0",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.68.0.tgz",
"integrity": "sha512-PdKiWsqinAoubVsSiRgVFkg3MHzGhQPnwQ8VxnGQKpZYijpapZ3UHHBje0GeByt2TvfjHPw+kxV+dNK2RIZg9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@sveltejs/acorn-typescript": "^1.0.5",
"@sveltejs/acorn-typescript": "^1.0.9",
"@types/cookie": "^0.6.0",
"acorn": "^8.14.1",
"acorn": "^8.16.0",
"cookie": "^0.6.0",
"devalue": "^5.6.3",
"devalue": "^5.8.1",
"esm-env": "^1.2.2",
"kleur": "^4.1.5",
"magic-string": "^0.30.5",
@@ -1248,7 +1416,7 @@
"@opentelemetry/api": "^1.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0",
"svelte": "^4.0.0 || ^5.0.0-next.0",
"typescript": "^5.3.3",
"typescript": "^5.3.3 || ^6.0.0",
"vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0"
},
"peerDependenciesMeta": {
@@ -1604,6 +1772,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/seedrandom": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/seedrandom/-/seedrandom-3.0.8.tgz",
"integrity": "sha512-TY1eezMU2zH2ozQoAFAQFOPpvP15g+ZgSfTZt31AUUH/Rxtnz3H+A/Sv1Snw2/amp//omibc+AEkTaA8KUeOLQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -1760,7 +1935,7 @@
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.0.tgz",
"integrity": "sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1799,9 +1974,9 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1879,10 +2054,79 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@whatwg-node/disposablestack": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@whatwg-node/disposablestack/-/disposablestack-0.0.6.tgz",
"integrity": "sha512-LOtTn+JgJvX8WfBVJtF08TGrdjuFzGJc4mkP8EdDI8ADbvO7kiexYep1o8dwnt0okb0jYclCDXF13xU7Ge4zSw==",
"license": "MIT",
"dependencies": {
"@whatwg-node/promise-helpers": "^1.0.0",
"tslib": "^2.6.3"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@whatwg-node/fetch": {
"version": "0.10.13",
"resolved": "https://registry.npmjs.org/@whatwg-node/fetch/-/fetch-0.10.13.tgz",
"integrity": "sha512-b4PhJ+zYj4357zwk4TTuF2nEe0vVtOrwdsrNo5hL+u1ojXNhh1FgJ6pg1jzDlwlT4oBdzfSwaBwMCtFCsIWg8Q==",
"license": "MIT",
"dependencies": {
"@whatwg-node/node-fetch": "^0.8.3",
"urlpattern-polyfill": "^10.0.0"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@whatwg-node/node-fetch": {
"version": "0.8.6",
"resolved": "https://registry.npmjs.org/@whatwg-node/node-fetch/-/node-fetch-0.8.6.tgz",
"integrity": "sha512-BDMdYFcerLQkwA2RTldxOqRCs6ZQD1S7UgP3pUdGUkcbgTrP/V5ko77ZkCww9DHmC4lpoYuwigGfQYj285gMvA==",
"license": "MIT",
"dependencies": {
"@fastify/busboy": "^3.1.1",
"@whatwg-node/disposablestack": "^0.0.6",
"@whatwg-node/promise-helpers": "^1.3.2",
"tslib": "^2.6.3"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@whatwg-node/promise-helpers": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-1.3.2.tgz",
"integrity": "sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.6.3"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/@whatwg-node/server": {
"version": "0.10.18",
"resolved": "https://registry.npmjs.org/@whatwg-node/server/-/server-0.10.18.tgz",
"integrity": "sha512-kMwLlxUbduttIgaPdSkmEarFpP+mSY8FEm+QWMBRJwxOHWkri+cxd8KZHO9EMrB9vgUuz+5WEaCawaL5wGVoXg==",
"license": "MIT",
"dependencies": {
"@envelop/instrumentation": "^1.0.0",
"@whatwg-node/disposablestack": "^0.0.6",
"@whatwg-node/fetch": "^0.10.13",
"@whatwg-node/promise-helpers": "^1.3.2",
"tslib": "^2.6.3"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"version": "8.17.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
@@ -1891,6 +2135,15 @@
"node": ">=0.4.0"
}
},
"node_modules/acorn-import-attributes": {
"version": "1.9.5",
"resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz",
"integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==",
"license": "MIT",
"peerDependencies": {
"acorn": "^8"
}
},
"node_modules/acorn-jsx": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
@@ -1934,6 +2187,15 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/ansis": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/ansis/-/ansis-4.3.1.tgz",
"integrity": "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==",
"license": "ISC",
"engines": {
"node": ">=14"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@@ -1950,6 +2212,16 @@
"node": ">= 0.4"
}
},
"node_modules/atomically": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/atomically/-/atomically-2.1.1.tgz",
"integrity": "sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==",
"license": "MIT",
"dependencies": {
"stubborn-fs": "^2.0.0",
"when-exit": "^2.1.4"
}
},
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@@ -1967,9 +2239,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1977,6 +2249,14 @@
"concat-map": "0.0.1"
}
},
"node_modules/callsite": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/callsite/-/callsite-1.0.0.tgz",
"integrity": "sha512-0vdNRFXn5q+dtOqjfFtmtlI9N2eVZ7LMyEV2iKC5mEEFvSg/69Ml6b/WU2qF8W1nLRa0wiSrDT3Y5jOHZCwKPQ==",
"engines": {
"node": "*"
}
},
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -2008,7 +2288,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
@@ -2030,6 +2309,12 @@
"node": ">=18"
}
},
"node_modules/cjs-module-lexer": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz",
"integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==",
"license": "MIT"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -2108,7 +2393,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -2122,6 +2406,15 @@
}
}
},
"node_modules/decache": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/decache/-/decache-4.6.2.tgz",
"integrity": "sha512-2LPqkLeu8XWHU8qNCS3kcF6sCcb5zIzvWaAHYSvPfwhdd7mHuah29NssMzrTYyHN4F5oFy2ko9OBYxegtU0FEw==",
"license": "MIT",
"dependencies": {
"callsite": "^1.0.0"
}
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -2149,12 +2442,42 @@
"node": ">=8"
}
},
"node_modules/devalue": {
"version": "5.6.3",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz",
"integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==",
"node_modules/dettle": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/dettle/-/dettle-1.0.5.tgz",
"integrity": "sha512-ZVyjhAJ7sCe1PNXEGveObOH9AC8QvMga3HJIghHawtG7mE4K5pW9nz/vDGAr/U7a3LWgdOzEE7ac9MURnyfaTA==",
"license": "MIT"
},
"node_modules/devalue": {
"version": "5.8.1",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz",
"integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==",
"license": "MIT"
},
"node_modules/dot-prop": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz",
"integrity": "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==",
"license": "MIT",
"dependencies": {
"type-fest": "^4.18.2"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/empathic": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.1.tgz",
"integrity": "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/enhanced-resolve": {
"version": "5.18.3",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
@@ -2169,6 +2492,18 @@
"node": ">=10.13.0"
}
},
"node_modules/env-paths": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz",
"integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==",
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/esbuild": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz",
@@ -2402,12 +2737,20 @@
}
},
"node_modules/esrap": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.3.tgz",
"integrity": "sha512-8fOS+GIGCQZl/ZIlhl59htOlms6U8NvX6ZYgYHpRU/b6tVSh3uHkOHZikl3D4cMbYM0JlpBe+p/BkZEi8J9XIQ==",
"version": "2.2.13",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.13.tgz",
"integrity": "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15"
},
"peerDependencies": {
"@typescript-eslint/types": "^8.2.0"
},
"peerDependenciesMeta": {
"@typescript-eslint/types": {
"optional": true
}
}
},
"node_modules/esrecurse": {
@@ -2527,9 +2870,9 @@
}
},
"node_modules/flatted": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
"license": "ISC"
},
@@ -2601,6 +2944,18 @@
"node": ">= 4"
}
},
"node_modules/image-size": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.2.tgz",
"integrity": "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==",
"license": "MIT",
"bin": {
"image-size": "bin/image-size.js"
},
"engines": {
"node": ">=16.x"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -2618,6 +2973,21 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/import-in-the-middle": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.2.0.tgz",
"integrity": "sha512-vR2B6HKIhaBjcZr2bLpFiJ1VbzOlRQ7aby4/gw5WPIzToLjqpfWw3VJ4sk1uDchoOODEirvO2jyrSPtUSL5CrQ==",
"license": "Apache-2.0",
"dependencies": {
"acorn": "^8.15.0",
"acorn-import-attributes": "^1.9.5",
"cjs-module-lexer": "^2.2.0",
"module-details-from-path": "^1.0.4"
},
"engines": {
"node": ">=18"
}
},
"node_modules/imurmurhash": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
@@ -2677,11 +3047,36 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/jpeg-js": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz",
"integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==",
"license": "BSD-3-Clause"
},
"node_modules/js-image-generator": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/js-image-generator/-/js-image-generator-1.0.4.tgz",
"integrity": "sha512-ckb7kyVojGAnArouVR+5lBIuwU1fcrn7E/YYSd0FK7oIngAkMmRvHASLro9Zt5SQdWToaI66NybG+OGxPw/HlQ==",
"license": "ISC",
"dependencies": {
"jpeg-js": "^0.4.2"
}
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@@ -3075,14 +3470,11 @@
"node": ">= 18"
}
},
"node_modules/moment": {
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
"license": "MIT",
"engines": {
"node": "*"
}
"node_modules/module-details-from-path": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz",
"integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==",
"license": "MIT"
},
"node_modules/mri": {
"version": "1.2.0",
@@ -3108,13 +3500,12 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
"dev": true,
"funding": [
{
@@ -3200,6 +3591,15 @@
"node": ">=6"
}
},
"node_modules/parse-gitignore": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/parse-gitignore/-/parse-gitignore-2.0.0.tgz",
"integrity": "sha512-RmVuCHWsfu0QPNW+mraxh/xjQVw/lhUCUru8Zni3Ctq3AoMhpDTq0OVdKS6iesd6Kqb7viCV3isAL43dciOSog==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -3227,10 +3627,23 @@
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"version": "8.5.16",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
"dev": true,
"funding": [
{
@@ -3248,7 +3661,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"nanoid": "^3.3.12",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -3287,9 +3700,9 @@
}
},
"node_modules/postcss-load-config/node_modules/yaml": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
"version": "1.10.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
"integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
"dev": true,
"license": "ISC",
"engines": {
@@ -3502,7 +3915,6 @@
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.18.0"
@@ -3512,6 +3924,19 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/require-in-the-middle": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz",
"integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.5",
"module-details-from-path": "^1.0.3"
},
"engines": {
"node": ">=9.3.0 || >=8.10.0 <9.0.0"
}
},
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -3591,7 +4016,6 @@
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -3600,13 +4024,6 @@
"node": ">=10"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
"dev": true,
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -3668,6 +4085,21 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/stubborn-fs": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-2.0.0.tgz",
"integrity": "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==",
"license": "MIT",
"dependencies": {
"stubborn-utils": "^1.0.1"
}
},
"node_modules/stubborn-utils": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/stubborn-utils/-/stubborn-utils-1.0.2.tgz",
"integrity": "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==",
"license": "MIT"
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -3682,23 +4114,23 @@
}
},
"node_modules/svelte": {
"version": "5.53.6",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.6.tgz",
"integrity": "sha512-lP5DGF3oDDI9fhHcSpaBiJEkFLuS16h92DhM1L5K1lFm0WjOmUh1i2sNkBBk8rkxJRpob0dBE75jRfUzGZUOGA==",
"version": "5.56.4",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz",
"integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==",
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0",
"@sveltejs/acorn-typescript": "^1.0.5",
"@sveltejs/acorn-typescript": "^1.0.10",
"@types/estree": "^1.0.5",
"@types/trusted-types": "^2.0.7",
"acorn": "^8.12.1",
"aria-query": "5.3.1",
"axobject-query": "^4.1.0",
"clsx": "^2.1.1",
"devalue": "^5.6.3",
"devalue": "^5.8.1",
"esm-env": "^1.2.1",
"esrap": "^2.2.2",
"esrap": "^2.2.12",
"is-reference": "^3.0.3",
"locate-character": "^3.0.0",
"magic-string": "^0.30.11",
@@ -3799,9 +4231,9 @@
}
},
"node_modules/tar": {
"version": "7.5.9",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.9.tgz",
"integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==",
"version": "7.5.19",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.19.tgz",
"integrity": "sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
@@ -3832,17 +4264,22 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"node_modules/tmp": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
"integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
"node": ">=14.14"
}
},
"node_modules/tmp-promise": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz",
"integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==",
"license": "MIT",
"dependencies": {
"tmp": "^0.2.0"
}
},
"node_modules/totalist": {
@@ -3868,6 +4305,12 @@
"typescript": ">=4.8.4"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -3881,6 +4324,18 @@
"node": ">= 0.8.0"
}
},
"node_modules/type-fest": {
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/typescript": {
"version": "5.9.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
@@ -3929,6 +4384,12 @@
"punycode": "^2.1.0"
}
},
"node_modules/urlpattern-polyfill": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz",
"integrity": "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==",
"license": "MIT"
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -3937,9 +4398,9 @@
"license": "MIT"
},
"node_modules/vite": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"version": "6.4.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4011,19 +4472,6 @@
}
}
},
"node_modules/vite/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/vitefu": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz",
@@ -4044,6 +4492,12 @@
}
}
},
"node_modules/when-exit": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.5.tgz",
"integrity": "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==",
"license": "MIT"
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+3 -2
View File
@@ -16,10 +16,11 @@
"devDependencies": {
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0",
"@sveltejs/adapter-netlify": "^5.2.3",
"@sveltejs/adapter-netlify": "^6.0.4",
"@sveltejs/kit": "^2.36.3",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.0.0",
"@types/seedrandom": "^3.0.8",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^3.0.0",
@@ -36,7 +37,7 @@
"vite": "^6.2.5"
},
"dependencies": {
"moment": "^2.30.1",
"@netlify/blobs": "^10.7.9",
"svelte-hero-icons": "^5.2.0"
}
}
+30
View File
@@ -1,6 +1,36 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
type SoundCloudSound = {
artwork_url?: string;
};
type SoundCloudProgressEvent = {
currentPosition: number;
};
type SoundCloudWidget = {
bind: (eventName: string, handler: (event?: SoundCloudProgressEvent) => void) => void;
unbind: (eventName: unknown) => void;
getDuration: (callback: (duration: number) => void) => void;
getCurrentSound: (callback: (sound: SoundCloudSound) => void) => void;
getPosition: (callback: (position: number) => void) => void;
isPaused: (callback: (paused: boolean) => void) => void;
play: () => void;
pause: () => void;
seekTo: (milliseconds: number) => void;
};
type SoundCloudApi = {
Widget: ((element: HTMLIFrameElement) => SoundCloudWidget) & {
Events: Record<string, string>;
};
};
interface Window {
SC?: SoundCloudApi;
}
namespace App {
// interface Error {}
// interface Locals {}
+1727 -604
View File
@@ -1,278 +1,408 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import moment from 'moment';
import seedrandom from 'seedrandom';
import { onDestroy, onMount, tick } from 'svelte';
import {
ArrowPath,
Icon,
Play,
Pause,
InformationCircle,
QuestionMarkCircle,
Sun,
Moon,
ArrowPath
Pause,
Play,
QuestionMarkCircle,
Share,
Sun,
XMark
} from 'svelte-hero-icons';
declare const SC: any;
// ─── CONFIG & THEME ───────────────────────────────────────────────────────────
const ARTIST_NAME = 'Maisie Peters';
const SC_USER = 'maisie-peters';
import {
ARTIST_NAME,
getDailyTrack,
getLocalDateKey,
normalizeTrackTitle,
tracks,
type Track
} from '$lib/tracks';
const COLORS = {
background: '#ffffff',
text: '#121212',
primary: '#83D4FD',
secondary: '#F484C1',
accent: '#F0440E'
panel: '#ffffff',
text: '#171717',
muted: '#5f6268',
primary: '#127fb3',
secondary: '#c43a84',
accent: '#f15a24',
success: '#1f8a5b',
danger: '#c9342f'
};
// ─── TRACK LIST ───────────────────────────────────────────────────────────────
type TrackData = { title: string; slug: string };
const TRACKS_DATA: TrackData[] = [
{ title: 'Holy Revival', slug: 'holy-revival' },
{ title: 'The Song', slug: 'the-song' },
{ title: 'Guy On A Horse', slug: 'guy-on-a-horse' },
{ title: 'The Last One', slug: 'the-last-one' },
{ title: 'Yoko', slug: 'yoko' },
{ title: 'Truth Is', slug: 'truth-is' },
// { title: "There It Goes (Acoustic)", slug: "there-it-goes-acoustic" },
{ title: 'The Band And I', slug: 'the-band-and-i' },
{ title: 'There It Goes', slug: 'there-it-goes' },
{ title: 'Watch', slug: 'watch' },
{ title: 'History Of Man', slug: 'history-of-man' },
{ title: 'Youre Just A Boy (And Im Kinda The Man)', slug: 'youre-just-a-boy-and-im-kinda' },
{ title: 'The Good Witch', slug: 'the-good-witch' },
{ title: 'Want You Back', slug: 'want-you-back' },
{ title: 'BSC', slug: 'bsc' },
{ title: 'Run', slug: 'run' },
{ title: 'Wendy', slug: 'wendy' },
{ title: 'Coming Of Age', slug: 'coming-of-age' },
{ title: 'Therapy', slug: 'therapy' },
// { title: "Lost The Breakup (Acoustic)", slug: "lost-the-breakup-acoustic" },
// { title: "Lost The Breakup (The Wild Remix)", slug: "lost-the-breakup-the-wild" },
{ title: 'Two Weeks Ago', slug: 'two-weeks-ago' },
{ title: 'Lost The Breakup', slug: 'lost-the-breakup' },
// { title: 'Lost The Breakup', slug: 'lost-the-breakup-1' },
// { title: "Body Better (Acapella)", slug: "body-better-acapella-acapella" },
// { title: "Body Better (Acoustic)", slug: "body-better-acoustic-acoustic" },
{ title: 'Body Better', slug: 'body-better' },
{ title: 'Together This Christmas', slug: 'together-this-christmas' },
{ title: 'Not Another Rockstar', slug: 'not-another-rockstar' },
{ title: 'Good Enough', slug: 'good-enough' },
{ title: 'Blonde', slug: 'blonde' },
// { title: "Cates Brother (BRELAND's Version)", slug: "cates-brother-brelands-version" },
// { title: "Cates Brother (Matt's Version)", slug: "cates-brother-matts-version" },
{ title: 'Cates Brother', slug: 'cates-brother' },
{ title: 'Im Trying (Not Friends)', slug: 'im-trying-not-friends' },
{ title: 'Villain', slug: 'villain' },
{ title: 'Elvis Song', slug: 'elvis-song' },
{ title: 'Talking To Strangers', slug: 'talking-to-strangers' },
{ title: 'Love Him I Dont', slug: 'love-him-i-dont' },
{ title: 'Outdoor Pool', slug: 'outdoor-pool' },
{ title: 'Boy', slug: 'boy' },
{ title: 'Hollow', slug: 'hollow' },
{ title: 'Tough Act', slug: 'tough-act' },
{ title: 'Volcano', slug: 'volcano' },
{ title: 'Brooklyn', slug: 'brooklyn' },
{ title: 'You Signed Up For This', slug: 'you-signed-up-for-this' },
// { title: "Psycho (Danny L Harle Remix)", slug: "psycho-danny-l-harle-remix" },
// { title: "Psycho (Acoustic)", slug: "psycho-acoustic" },
// { title: "Psycho (Joel Corry Remix)", slug: "psycho-joel-corry-remix" },
{ title: 'Psycho', slug: 'psycho' },
{ title: 'The Party', slug: 'the-party' },
{ title: 'Lunar Years', slug: 'lunar-years' },
{ title: 'Happy Hunting Ground (feat. Griff)', slug: 'happy-hunting-ground-feat' },
{ title: 'Helicopter', slug: 'helicopter' },
{ title: 'Milhouse', slug: 'milhouse' },
{ title: 'Glowing Review', slug: 'glowing-review' },
// { title: "I Want You To Change (Because You Want To Change) [feat. Bear's Den]", slug: "i-want-you-to-change-because" },
{ title: 'Neck Of The Woods', slug: 'neck-of-the-woods' },
{ title: 'Funeral (feat. James Bay)', slug: 'funeral-feat-james-bay' },
// { title: "John Hughes Movie (Acoustic)", slug: "john-hughes-movie-acoustic" },
// { title: "John Hughes Movie (Oliver Nelson Remix)", slug: "john-hughes-movie-oliver" },
{ title: 'John Hughes Movie', slug: 'john-hughes' },
// { title: "Maybe Don't (feat. JP Saxe) [Acoustic]", slug: "maybe-dont-feat-jp-saxe-1" },
// { title: "Maybe Don't (feat. JP Saxe) [MOTi Remix]", slug: "maybe-dont-feat-jp-saxe-moti" },
// { title: "Maybe Don't (feat. JP Saxe) [HONNE Remix]", slug: "maybe-dont-feat-jp-saxe-honne" },
{ title: "Maybe Don't (feat. JP Saxe)", slug: 'maybe-dont-feat-jp-saxe' },
// { title: "Sad Girl Summer (Cavetown Rework)", slug: "sad-girl-summer-cavetown" },
// { title: "Sad Girl Summer (emo version)", slug: "sad-girl-summer-emo-version" },
{ title: 'Sad Girl Summer', slug: 'sad-girl-summer' },
{ title: 'The List', slug: 'the-list' },
{ title: 'Daydreams', slug: 'daydreams' },
// { title: "Take Care of Yourself (Live Acoustic)", slug: "take-care-of-yourself-acoustic" },
// { title: "Adore You (Breydon Beggs Remix)", slug: "adore-you-breydon-beggs-remix" },
// { title: "Adore You (Acoustic)", slug: "adore-you-acoustic" },
// { title: "This Is On You (Acoustic)", slug: "this-is-on-you-acoustic" },
{ title: 'Look At Me Now', slug: 'look-at-me-now-1' },
{ title: 'Take Care Of Yourself', slug: 'take-care-of-yourself-1' },
{ title: 'Personal Best', slug: 'personal-best-1' },
{ title: 'April Showers', slug: 'april-showers' },
{ title: 'Adore You', slug: 'adore-you' },
{ title: 'This Is On You', slug: 'this-is-on-you-1' },
{ title: 'This Is On You', slug: 'this-is-on-you' },
{ title: 'Favourite Ex', slug: 'favourite-ex' },
// { title: "Stay Young (Acoustic)", slug: "stay-young-acoustic" },
{ title: 'Stay Young', slug: 'stay-young' },
{ title: 'Enough For You', slug: 'enough-for-you' },
{ title: 'You To You', slug: 'you-to-you' },
{ title: 'Architecture', slug: 'architecture-1' },
{ title: 'Feels Like This', slug: 'feels-like-this' },
{ title: 'Details', slug: 'details' },
{ title: 'In My Head', slug: 'in-my-head' },
{ title: "Best I'll Ever Sing", slug: 'best-ill-ever-sing-1' },
{ title: 'Worst of You', slug: 'worst-of-you' }
];
const todayKey = getLocalDateKey();
const currentTrack = getDailyTrack(todayKey);
const storageKey = `maisie-heardle:game:${todayKey}:${currentTrack.slug}`;
const statsKey = 'maisie-heardle:stats:v1';
type Track = { title: string; artist: string; url: string };
const tracks: Track[] = TRACKS_DATA.map(({ title, slug }) => ({
title,
artist: ARTIST_NAME,
url: `https://soundcloud.com/${SC_USER}/${slug}`
}));
// ─── SEED & PICK TRACK ────────────────────────────────────────────────────────
const todaySeed = moment().format('YYYYMMDD');
const rng = seedrandom(todaySeed);
const currentTrack = tracks[Math.floor(rng() * tracks.length)];
// ─── SEGMENTS ───────────────────────────────────────────────────────────────
const SEGMENT_INCREMENTS = [2, 1, 2, 3, 4, 5]; // seconds
const SEGMENT_INCREMENTS = [2, 1, 2, 3, 4, 5];
let total = 0;
const segmentDurations = SEGMENT_INCREMENTS.map((inc) => {
total += inc * 1000;
const segmentDurations = SEGMENT_INCREMENTS.map((increment) => {
total += increment * 1000;
return total;
});
const TOTAL_MS = segmentDurations.at(-1)!;
const TOTAL_SECONDS = TOTAL_MS / 1000;
const maxAttempts = SEGMENT_INCREMENTS.length;
$: boundaries = segmentDurations.map((ms) => ms / 1000).slice(0, -1);
// ─── STATE&TIMERS ─────────────────────────────────────────────────────────
type Info = { status: 'skip' | 'wrong' | 'correct'; title?: string };
let attemptInfos: Info[] = [];
type AttemptInfo = { status: 'skip' | 'wrong' | 'correct'; title?: string };
type SavedGame = {
attemptInfos: AttemptInfo[];
attemptCount: number;
gameOver: boolean;
message: string;
won: boolean;
statsRecorded: boolean;
};
type Stats = {
played: number;
wins: number;
streak: number;
maxStreak: number;
distribution: number[];
};
let attemptInfos: AttemptInfo[] = [];
let attemptCount = 0;
let gameOver = false;
let message = '';
let won = false;
let statsRecorded = false;
let stats: Stats = {
played: 0,
wins: 0,
streak: 0,
maxStreak: 0,
distribution: Array(maxAttempts).fill(0)
};
let iframeElement: HTMLIFrameElement;
let widget: any;
let widget: SoundCloudWidget;
let widgetReady = false;
let volume = 80;
let loading = true;
let widgetError = '';
let artworkUrl = '';
let isPlaying = false;
let currentPosition = 0;
let snippetTimeout: ReturnType<typeof setTimeout>;
let progressInterval: ReturnType<typeof setInterval>;
let fullDuration = 0;
let vinylRotation = 0;
let lastFrameTime = 0;
let animationFrameId: number;
let vinylElement: HTMLDivElement;
let waveformHeights = [4, 4, 4, 4, 4, 4, 4];
let waveformInterval: ReturnType<typeof setInterval>;
let skipInProgress = false;
let isWarmingUp = false;
let showHowTo = false;
let showInfo = false;
let globalGamesPlayed: number | null = null;
let darkMode = false;
let userInput = '';
let suggestions: Track[] = [];
let selectedTrack: Track | null = null;
let inputEl: HTMLInputElement;
let hydrated = false;
let shareMessage = '';
let suggestionArtwork: Record<string, string> = {};
let requestedSuggestionSlugs = '';
// ─── COUNTDOWN ───────────────────────────────────────────────────────────────
let timeLeft = '';
let countdownInterval: ReturnType<typeof setInterval>;
function updateTime() {
const now = new Date();
const midnight = new Date(now);
midnight.setHours(24, 0, 0, 0);
const diff = midnight.getTime() - now.getTime();
const h = String(Math.floor(diff / 3600000)).padStart(2, '0');
const m = String(Math.floor((diff % 3600000) / 60000)).padStart(2, '0');
const s = String(Math.floor((diff % 60000) / 1000)).padStart(2, '0');
timeLeft = `${h}:${m}:${s}`;
}
let compactLayout = false;
let attemptHistoryEl: HTMLDivElement;
let gameGridEl: HTMLDivElement;
let layoutObserver: ResizeObserver | undefined;
// ─── FILL%&NEXTSEGMENT ───────────────────────────────────────────────────
$: fillPercent = (() => {
const raw = gameOver
? (currentPosition / fullDuration) * 100
: (currentPosition / TOTAL_MS) * 100;
return Math.min(raw, 100);
})();
const boundaries = segmentDurations.map((ms) => ms / 1000).slice(0, -1);
$: activeLimit = gameOver ? fullDuration || TOTAL_MS : segmentDurations[attemptCount] || TOTAL_MS;
$: progressDuration = gameOver ? fullDuration || TOTAL_MS : TOTAL_MS;
$: fillPercent = Math.min((currentPosition / progressDuration) * 100, 100);
$: unlockedSeconds = Math.round((segmentDurations[attemptCount] || TOTAL_MS) / 1000);
$: nextIncrementSec =
attemptCount < SEGMENT_INCREMENTS.length - 1 ? SEGMENT_INCREMENTS[attemptCount + 1] : 0;
function formatTime(ms: number) {
const s = Math.floor(ms / 1000);
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
$: remainingAttempts = Array.from(
{ length: Math.max(maxAttempts - attemptInfos.length, 0) },
(_, index) => attemptInfos.length + index + 1
);
$: canSubmit = Boolean(userInput.trim()) && !gameOver && !loading && !widgetError;
$: resultLabel = won ? `${attemptCount}/${maxAttempts}` : `X/${maxAttempts}`;
$: suggestions =
userInput && !selectedTrack
? tracks
.filter((track) =>
normalizeTrackTitle(track.title).includes(normalizeTrackTitle(userInput))
)
.slice(0, 6)
: [];
$: if (hydrated) {
void loadSuggestionArtwork(suggestions);
}
$: if (hydrated) {
saveGame({
attemptInfos,
attemptCount,
gameOver,
message,
won,
statsRecorded
});
}
function startPolling() {
isPlaying = true;
skipInProgress = false; // clear guard once new snippet starts
clearInterval(progressInterval);
progressInterval = setInterval(() => {
if (!widget) return;
widget.getPosition((pos: number) => {
currentPosition = pos;
});
}, 100);
$: {
if (typeof window !== 'undefined') {
handlePlayStateChange(isPlaying);
driveWaveform(isPlaying);
}
}
function stopAllTimers() {
isPlaying = false;
clearInterval(progressInterval);
clearTimeout(snippetTimeout);
$: if (hydrated && gameOver) {
void tick().then(updateCompactLayout);
}
function ensurePlayState() {
if (!widget) return;
widget.isPaused((paused: boolean) => {
if (!paused && !isPlaying) {
// Widget is playing but our state says it's not
startPolling();
} else if (paused && isPlaying) {
// Widget is paused but our state says it's playing
stopAllTimers();
function updateCompactLayout() {
if (typeof window === 'undefined' || !gameOver || window.innerWidth < 1024) {
compactLayout = false;
return;
}
if (!attemptHistoryEl || !gameGridEl) return;
const attemptsNeedScroll =
attemptHistoryEl.scrollHeight > attemptHistoryEl.clientHeight + 1;
const previous = compactLayout;
compactLayout = false;
requestAnimationFrame(() => {
const gridOverflow = gameGridEl.scrollHeight > gameGridEl.clientHeight + 1;
compactLayout = attemptsNeedScroll || gridOverflow;
if (compactLayout !== previous && compactLayout) {
requestAnimationFrame(updateCompactLayout);
}
});
}
// ─── WIDGET SET‑UP ───────────────────────────────────────────────────────────
onMount(async () => {
// Initialize dark mode
onMount(() => {
darkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
// load SC API if missing
if (typeof window.SC === 'undefined') {
await new Promise<void>((resolve, reject) => {
const tag = document.createElement('script');
tag.src = 'https://w.soundcloud.com/player/api.js';
tag.async = true;
tag.onload = () => resolve();
tag.onerror = () => reject(new Error('Failed to load SC API'));
document.head.appendChild(tag);
});
stats = loadStats();
loadSavedGame();
const savedVol = localStorage.getItem('heardle-volume');
if (savedVol !== null) {
volume = parseInt(savedVol);
}
hydrated = true;
window
.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', (e) => (darkMode = e.matches));
const colorPreference = window.matchMedia('(prefers-color-scheme: dark)');
const onPreferenceChange = (event: MediaQueryListEvent) => (darkMode = event.matches);
colorPreference.addEventListener('change', onPreferenceChange);
updateTime();
countdownInterval = setInterval(updateTime, 1000);
void initializeWidget();
void loadDailyTrackArtwork();
void loadGlobalGamesPlayed();
widget = SC.Widget(iframeElement);
if (typeof ResizeObserver !== 'undefined') {
layoutObserver = new ResizeObserver(() => updateCompactLayout());
}
const onResize = () => updateCompactLayout();
window.addEventListener('resize', onResize);
// READY
widget.bind(SC.Widget.Events.READY, () => {
widget.getDuration((d: number) => {
fullDuration = d;
return () => {
colorPreference.removeEventListener('change', onPreferenceChange);
layoutObserver?.disconnect();
window.removeEventListener('resize', onResize);
};
});
$: if (attemptHistoryEl && gameGridEl && layoutObserver) {
layoutObserver.observe(attemptHistoryEl);
layoutObserver.observe(gameGridEl);
updateCompactLayout();
}
async function loadGlobalGamesPlayed() {
try {
const incrementedKey = `maisie-heardle:global-count-incremented:${todayKey}`;
const alreadyCounted = Boolean(localStorage.getItem(incrementedKey));
const url = alreadyCounted
? '/api/stats/games-played'
: '/api/stats/games-played?increment=1';
const res = await fetch(url);
if (res.ok) {
const data = (await res.json()) as { count?: number };
globalGamesPlayed = data.count ?? 0;
if (!alreadyCounted) {
localStorage.setItem(incrementedKey, 'true');
}
} else {
globalGamesPlayed = -1;
}
} catch (err) {
console.error('Failed to update global games played counter:', err);
globalGamesPlayed = -1;
}
}
async function loadDailyTrackArtwork() {
try {
const response = await fetch(
`/api/soundcloud/oembed?url=${encodeURIComponent(currentTrack.url)}`
);
if (response.ok) {
const data = (await response.json()) as { thumbnailUrl?: string };
if (data.thumbnailUrl) {
artworkUrl = data.thumbnailUrl;
}
}
} catch (error) {
console.error('Failed to load daily track artwork:', error);
}
}
async function initializeWidget() {
try {
await loadSoundCloudApi();
const soundcloud = window.SC;
if (!soundcloud) throw new Error('SoundCloud player API did not initialize.');
widget = soundcloud.Widget(iframeElement);
bindWidgetEvents(soundcloud);
} catch (error) {
loading = false;
widgetError =
error instanceof Error
? error.message
: 'The SoundCloud player could not be loaded. Please try again.';
}
}
onDestroy(() => {
stopAllTimers();
clearInterval(countdownInterval);
if (widget?.unbind && window.SC?.Widget?.Events) {
Object.values(window.SC.Widget.Events).forEach((eventName) => widget.unbind(eventName));
}
if (typeof cancelAnimationFrame !== 'undefined') {
cancelAnimationFrame(animationFrameId);
}
clearInterval(waveformInterval);
});
function driveWaveform(playing: boolean) {
clearInterval(waveformInterval);
if (playing) {
// Seed with an initial burst
waveformHeights = waveformHeights.map(() => 4 + Math.random() * 24);
waveformInterval = setInterval(() => {
waveformHeights = waveformHeights.map((prev) => {
// Organic: weighted blend of previous height + new random target
const target = 3 + Math.random() * 28;
const inertia = 0.3 + Math.random() * 0.35;
return prev * inertia + target * (1 - inertia);
});
}, 120);
} else {
waveformHeights = [4, 4, 4, 4, 4, 4, 4];
}
}
function handleVolumeChange(e: Event) {
const target = e.target as HTMLInputElement;
volume = parseInt(target.value);
if (typeof window !== 'undefined') {
localStorage.setItem('heardle-volume', volume.toString());
}
if (widget && widget.setVolume) {
widget.setVolume(volume);
}
}
function handlePlayStateChange(playing: boolean) {
if (!vinylElement) return;
if (playing) {
vinylElement.style.transition = 'none';
lastFrameTime = 0;
if (typeof requestAnimationFrame !== 'undefined') {
cancelAnimationFrame(animationFrameId);
animationFrameId = requestAnimationFrame(spinVinyl);
}
} else {
if (typeof cancelAnimationFrame !== 'undefined') {
cancelAnimationFrame(animationFrameId);
}
if (gameOver) {
vinylElement.style.transition = 'none';
vinylElement.style.transform = `rotate(${vinylRotation}deg)`;
} else {
const duration = Math.max(800, Math.min(800 + (vinylRotation / 360) * 300, 2500));
vinylElement.style.transition = `transform ${duration}ms cubic-bezier(0.15, 0.85, 0.35, 1)`;
vinylElement.style.transform = 'rotate(0deg)';
vinylRotation = 0;
}
}
}
function spinVinyl(timestamp: number) {
if (!lastFrameTime) lastFrameTime = timestamp;
const dt = timestamp - lastFrameTime;
lastFrameTime = timestamp;
if (isPlaying && vinylElement) {
vinylRotation = vinylRotation + 0.10285 * dt;
vinylElement.style.transform = `rotate(${vinylRotation}deg)`;
if (typeof requestAnimationFrame !== 'undefined') {
animationFrameId = requestAnimationFrame(spinVinyl);
}
}
}
function loadSoundCloudApi() {
if (window.SC) return Promise.resolve();
return new Promise<void>((resolve, reject) => {
const existing = document.querySelector<HTMLScriptElement>(
'script[src="https://w.soundcloud.com/player/api.js"]'
);
if (existing) {
existing.addEventListener('load', () => resolve(), { once: true });
existing.addEventListener('error', () => reject(new Error('Failed to load SoundCloud.')), {
once: true
});
return;
}
const tag = document.createElement('script');
tag.src = 'https://w.soundcloud.com/player/api.js';
tag.async = true;
tag.onload = () => resolve();
tag.onerror = () => reject(new Error('Failed to load SoundCloud.'));
document.head.appendChild(tag);
});
}
function bindWidgetEvents(soundcloud: SoundCloudApi) {
const events = soundcloud.Widget.Events;
widget.bind(events.READY, () => {
widget.setVolume(volume);
widget.getDuration((duration: number) => {
fullDuration = duration;
});
widget.getCurrentSound((sound: any) => {
artworkUrl = sound?.artwork_url || '';
widget.getCurrentSound((sound: { artwork_url?: string }) => {
if (sound?.artwork_url) {
artworkUrl = sound.artwork_url;
}
});
// warm up - play/pause to enable mobile autoplay with longer delays
isWarmingUp = true;
setTimeout(() => {
widget.play();
@@ -282,7 +412,6 @@
widget.seekTo(0);
loading = false;
widgetReady = true;
// Ensure we're in stopped state
isPlaying = false;
currentPosition = 0;
isWarmingUp = false;
@@ -291,85 +420,99 @@
}, 500);
});
// PLAY
widget.bind(SC.Widget.Events.PLAY, () => {
// Ignore warmup events
if (isWarmingUp) return;
// Always sync state when widget starts playing
if (!isPlaying) {
startPolling();
}
widget.bind(events.PLAY, () => {
if (!isWarmingUp && !isPlaying) startPolling();
});
// PAUSE
widget.bind(SC.Widget.Events.PAUSE, () => {
// Ignore warmup events
widget.bind(events.PAUSE, () => {
if (isWarmingUp) return;
// Skip logic takes priority
if (skipInProgress) {
stopAllTimers();
playSegment(false);
return;
}
// Always sync state when widget pauses
if (isPlaying) {
stopAllTimers();
}
if (isPlaying) stopAllTimers();
});
// FINISH
widget.bind(SC.Widget.Events.FINISH, () => {
widget.bind(events.FINISH, () => {
stopAllTimers();
currentPosition = gameOver ? fullDuration : segmentDurations[attemptCount];
});
// PLAY_PROGRESS
widget.bind(SC.Widget.Events.PLAY_PROGRESS, (e: { currentPosition: number }) => {
if (!isPlaying) return;
widget.bind(events.PLAY_PROGRESS, (event) => {
if (!isPlaying || !event) return;
const limit = gameOver ? fullDuration : segmentDurations[attemptCount];
currentPosition = e.currentPosition;
if (e.currentPosition >= limit) {
widget.pause(); // will call PAUSE -> stopAllTimers
currentPosition = event.currentPosition;
if (event.currentPosition >= limit) {
widget.pause();
currentPosition = limit;
}
});
});
onDestroy(() => {
stopAllTimers();
clearInterval(countdownInterval);
if (widget?.unbind) Object.values(SC.Widget.Events).forEach((ev) => widget.unbind(ev));
});
if (events.ERROR) {
widget.bind(events.ERROR, () => {
stopAllTimers();
loading = false;
widgetError = 'SoundCloud could not play todays song. Try reloading the page.';
});
}
}
function updateTime() {
const now = new Date();
const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1).getTime();
const diff = midnight - now.getTime();
const h = String(Math.floor(diff / 3_600_000)).padStart(2, '0');
const m = String(Math.floor((diff % 3_600_000) / 60_000)).padStart(2, '0');
const s = String(Math.floor((diff % 60_000) / 1000)).padStart(2, '0');
timeLeft = `${h}:${m}:${s}`;
}
function formatTime(ms: number) {
const seconds = Math.floor(ms / 1000);
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`;
}
function startPolling() {
isPlaying = true;
clearInterval(progressInterval);
progressInterval = setInterval(() => {
if (!widget) return;
widget.getPosition((position: number) => {
currentPosition = position;
});
}, 100);
}
function stopAllTimers() {
isPlaying = false;
clearInterval(progressInterval);
}
function ensurePlayState() {
if (!widget) return;
widget.isPaused((paused: boolean) => {
if (!paused && !isPlaying) {
startPolling();
} else if (paused && isPlaying) {
stopAllTimers();
}
});
}
// ─── GAME ACTIONS ───────────────────────────────────────────────────────────
function playSegment(seekToStart = true) {
if (!widgetReady || loading) return;
if (!widgetReady || loading || widgetError) return;
stopAllTimers();
if (seekToStart && !gameOver) {
currentPosition = 0;
widget.seekTo(0);
}
// Longer delay to ensure seek completes and state is clean
setTimeout(() => {
widget.play();
// Don't start polling immediately, let PLAY event handle it
}, 100);
setTimeout(() => widget.play(), 100);
}
function togglePlayPause() {
if (!widgetReady || loading) return;
// Sync state before toggling
if (!widgetReady || loading || widgetError) return;
ensurePlayState();
if (isPlaying) {
widget.pause();
} else {
// When game is over, continue from current position
// When game is active, restart from beginning
playSegment(!gameOver);
}
}
@@ -380,416 +523,1396 @@
stopAllTimers();
currentPosition = 0;
widget.seekTo(0);
if (wasPlaying) {
// If it was playing, continue playing from start
setTimeout(() => {
widget.play();
// Let PLAY event handle startPolling
}, 150);
}
if (wasPlaying) setTimeout(() => widget.play(), 150);
}
function toggleDark() {
darkMode = !darkMode;
function seekFinishedSong(event: MouseEvent | KeyboardEvent) {
if (!widgetReady || !gameOver || !fullDuration) return;
let ratio: number;
if (event instanceof KeyboardEvent) {
const step = event.shiftKey ? 10_000 : 5_000;
if (event.key === 'ArrowLeft') {
event.preventDefault();
seekToPosition(currentPosition - step);
return;
}
if (event.key === 'ArrowRight') {
event.preventDefault();
seekToPosition(currentPosition + step);
return;
}
if (event.key === 'Home') {
event.preventDefault();
seekToPosition(0);
return;
}
if (event.key === 'End') {
event.preventDefault();
seekToPosition(fullDuration);
return;
}
return;
}
const bounds =
event.currentTarget instanceof HTMLElement
? event.currentTarget.getBoundingClientRect()
: null;
if (!bounds) return;
ratio = Math.min(Math.max((event.clientX - bounds.left) / bounds.width, 0), 1);
seekToPosition(ratio * fullDuration);
}
function seekToPosition(position: number) {
const nextPosition = Math.min(Math.max(position, 0), fullDuration);
const wasPlaying = isPlaying;
currentPosition = nextPosition;
widget.seekTo(nextPosition);
if (wasPlaying) {
setTimeout(() => widget.play(), 75);
}
}
function skipIntro() {
if (!widgetReady || gameOver) return;
if (!widgetReady || gameOver || widgetError) return;
attemptInfos = [...attemptInfos, { status: 'skip' }];
attemptCount++;
userInput = '';
selectedTrack = null;
attemptCount += 1;
clearGuess();
if (attemptCount >= maxAttempts) {
revealAnswer();
return; // nothing more to do
finishGame(false);
return;
}
if (isPlaying) {
skipInProgress = true;
clearTimeout(snippetTimeout);
widget.pause(); // PAUSE handler will launch the next snippet
} else {
stopAllTimers(); // just in case something is still polling
playSegment(true); // start from beginning if not playing
if (!isPlaying) {
currentPosition = 0;
playSegment(true);
}
}
function submitGuess() {
if (!widgetReady || gameOver || !userInput.trim()) return;
if (!selectedTrack && suggestions.length) {
selectedTrack =
suggestions.find((t) => t.title.toLowerCase() === userInput.toLowerCase()) ||
suggestions[0];
}
if (!selectedTrack) return;
if (!canSubmit) return;
attemptCount++;
const ans = currentTrack.title.toLowerCase();
if (selectedTrack.title.toLowerCase() === ans) {
const exactMatch = tracks.find(
(track) => normalizeTrackTitle(track.title) === normalizeTrackTitle(userInput)
);
const guessedTrack = selectedTrack || exactMatch || suggestions[0];
if (!guessedTrack) return;
attemptCount += 1;
if (normalizeTrackTitle(guessedTrack.title) === normalizeTrackTitle(currentTrack.title)) {
attemptInfos = [...attemptInfos, { status: 'correct', title: currentTrack.title }];
gameOver = true;
message = `✅ Correct! It was “${currentTrack.title}.” You got it ${
attemptCount === maxAttempts
? 'on the last try! Close one!'
: `in ${attemptCount} ${attemptCount === 1 ? 'try' : 'tries'}.`
}`;
stopAllTimers();
widget.pause();
finishGame(true);
return;
}
attemptInfos = [...attemptInfos, { status: 'wrong', title: guessedTrack.title }];
clearGuess();
if (attemptCount >= maxAttempts) {
finishGame(false);
} else {
attemptInfos = [...attemptInfos, { status: 'wrong', title: selectedTrack.title }];
userInput = '';
selectedTrack = null;
if (attemptCount >= maxAttempts) revealAnswer();
stopAllTimers();
widget?.pause();
currentPosition = 0;
}
}
function revealAnswer() {
function finishGame(didWin: boolean) {
won = didWin;
gameOver = true;
message = `❌ Out of tries! It was “${currentTrack.title}.”`;
message = didWin
? `Correct. It was “${currentTrack.title}.”`
: `Out of tries. It was “${currentTrack.title}.”`;
stopAllTimers();
widget.pause();
widget?.pause();
if (!statsRecorded) {
stats = recordStats(didWin, attemptCount);
statsRecorded = true;
}
}
function onInputKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !gameOver) {
e.preventDefault();
function clearGuess() {
userInput = '';
selectedTrack = null;
shareMessage = '';
}
async function loadSuggestionArtwork(nextSuggestions: Track[]) {
const slugKey = nextSuggestions.map((track) => track.slug).join('|');
if (slugKey === requestedSuggestionSlugs) return;
requestedSuggestionSlugs = slugKey;
const missing = nextSuggestions.filter((track) => suggestionArtwork[track.slug] === undefined);
if (!missing.length) return;
const entries = await Promise.all(
missing.map(async (track) => {
try {
const response = await fetch(
`/api/soundcloud/oembed?url=${encodeURIComponent(track.url)}`
);
if (!response.ok) return [track.slug, ''] as const;
const data = (await response.json()) as { thumbnailUrl?: string };
return [track.slug, data.thumbnailUrl ?? ''] as const;
} catch {
return [track.slug, ''] as const;
}
})
);
suggestionArtwork = {
...suggestionArtwork,
...Object.fromEntries(entries)
};
}
function onInputKeydown(event: KeyboardEvent) {
if (event.key === 'Enter' && canSubmit) {
event.preventDefault();
submitGuess();
}
}
function selectSuggestion(s: Track) {
selectedTrack = s;
userInput = s.title;
function selectSuggestion(track: Track) {
selectedTrack = track;
userInput = track.title;
suggestions = [];
inputEl.blur();
}
$: suggestions =
userInput && !selectedTrack
? tracks.filter((t) => t.title.toLowerCase().includes(userInput.toLowerCase())).slice(0, 5)
: [];
async function shareResult() {
shareMessage = '';
const text = buildShareText();
try {
if (navigator.share) {
await navigator.share({ text });
shareMessage = 'Shared.';
return;
}
await navigator.clipboard.writeText(text);
shareMessage = 'Copied result.';
} catch {
shareMessage = 'Could not share from this browser.';
}
}
function buildShareText() {
const rows = attemptInfos
.map((attempt) => {
if (attempt.status === 'correct') return '🟩';
if (attempt.status === 'wrong') return '🟥';
return '🟦';
})
.join('');
return `Heardle - ${ARTIST_NAME} ${todayKey} ${resultLabel}\n${rows}\n${window?.location?.origin ?? ''}`;
}
function loadSavedGame() {
try {
const saved = localStorage.getItem(storageKey);
if (!saved) return;
const parsed = JSON.parse(saved) as SavedGame;
attemptInfos = Array.isArray(parsed.attemptInfos) ? parsed.attemptInfos : [];
attemptCount = Math.min(Math.max(parsed.attemptCount || 0, 0), maxAttempts);
gameOver = Boolean(parsed.gameOver);
message = parsed.message || '';
won = Boolean(parsed.won);
statsRecorded = Boolean(parsed.statsRecorded);
} catch {
localStorage.removeItem(storageKey);
}
}
function saveGame(game: SavedGame) {
localStorage.setItem(storageKey, JSON.stringify(game));
}
function loadStats(): Stats {
try {
const saved = localStorage.getItem(statsKey);
if (!saved) throw new Error('No stats yet.');
const parsed = JSON.parse(saved) as Stats;
return {
played: parsed.played || 0,
wins: parsed.wins || 0,
streak: parsed.streak || 0,
maxStreak: parsed.maxStreak || 0,
distribution: Array.from(
{ length: maxAttempts },
(_, index) => parsed.distribution?.[index] || 0
)
};
} catch {
return {
played: 0,
wins: 0,
streak: 0,
maxStreak: 0,
distribution: Array(maxAttempts).fill(0)
};
}
}
function recordStats(didWin: boolean, attempts: number) {
const nextStats = {
...stats,
played: stats.played + 1,
wins: stats.wins + (didWin ? 1 : 0),
streak: didWin ? stats.streak + 1 : 0,
maxStreak: didWin ? Math.max(stats.maxStreak, stats.streak + 1) : stats.maxStreak,
distribution: [...stats.distribution]
};
if (didWin) nextStats.distribution[Math.max(Math.min(attempts - 1, maxAttempts - 1), 0)] += 1;
localStorage.setItem(statsKey, JSON.stringify(nextStats));
return nextStats;
}
</script>
<!-- How to Play Modal -->
{#if showHowTo}
<div class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/40"></div>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4">
<div
class="relative w-4/5 max-w-md rounded-lg p-8 text-center"
style="
background: {darkMode ? COLORS.text : COLORS.background};
color: {darkMode ? COLORS.background : COLORS.text}
"
role="dialog"
aria-modal="true"
aria-labelledby="how-to-title"
class="w-full max-w-md rounded-lg border p-6 shadow-2xl"
style="background: {darkMode ? '#1f1f1f' : COLORS.panel}; color: {darkMode
? '#fffaf7'
: COLORS.text}; border-color: {COLORS.primary}"
>
<h2 class="mb-4 text-2xl font-bold uppercase" style="color: {COLORS.primary}">How to Play</h2>
<ul class="space-y-4 text-base">
<li>🎵 Play the snippet.</li>
<li>🔊 Skips & wrongs unlock more.</li>
<li>👍 Guess in as few tries as possible!</li>
</ul>
<button
class="mt-6 rounded px-6 py-2 font-semibold"
style="
background: {COLORS.primary};
color: {darkMode ? COLORS.text : COLORS.background}
"
on:click={() => (showHowTo = false)}
>
Close
</button>
<div class="mb-5 flex items-start justify-between gap-4">
<h2 id="how-to-title" class="text-2xl font-extrabold">How to play</h2>
<button
type="button"
aria-label="Close how to play"
class="rounded-full p-1 transition hover:scale-105"
on:click={() => (showHowTo = false)}
>
<Icon src={XMark} class="h-6 w-6" />
</button>
</div>
<ol class="space-y-3 text-sm leading-6">
<li><strong>1.</strong> Press play and identify the song from the unlocked snippet.</li>
<li><strong>2.</strong> A skip or wrong guess unlocks a longer clip from the start.</li>
<li><strong>3.</strong> Finish in as few attempts as possible and share your result.</li>
</ol>
</div>
</div>
{/if}
<!-- Info Modal -->
{#if showInfo}
<div class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/40"></div>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4">
<div
class="relative w-4/5 max-w-md space-y-4 rounded-lg p-8"
style="background:{darkMode ? COLORS.text : COLORS.background}; color:{darkMode
? COLORS.background
: COLORS.text}"
role="dialog"
aria-modal="true"
aria-labelledby="info-title"
class="w-full max-w-lg rounded-lg border p-6 shadow-2xl"
style="background: {darkMode ? '#1f1f1f' : COLORS.panel}; color: {darkMode
? '#fffaf7'
: COLORS.text}; border-color: {COLORS.secondary}"
>
<p class="font-semibold">{ARTIST_NAME} Test your knowledge!</p>
<p>All songs used are copyrighted and belong to {ARTIST_NAME}.</p>
<p>I do not, and never will, collect any of your personal data.</p>
<hr class="my-4" style="border-color:{darkMode ? COLORS.background : COLORS.text}" />
<p class="text-xs" style="color:{COLORS.accent}">
Prepared with SoundCloud, Svelte, Tailwind CSS, Inter font, svelte-hero-icons, and moment.js<br
/>
Game version: 3.1.0
</p>
<!-- Added links -->
<p class="text-xs">
<a
href="https://github.com/SoPat712/maisie-heardle"
target="_blank"
rel="noopener noreferrer"
class="underline hover:text-{COLORS.primary}"
<div class="mb-5 flex items-start justify-between gap-4">
<div>
<p class="text-xs font-bold uppercase" style="color: {COLORS.secondary}">
Daily artist Heardle
</p>
<h2 id="info-title" class="text-2xl font-extrabold">{ARTIST_NAME}</h2>
</div>
<button
type="button"
aria-label="Close info"
class="rounded-full p-1 transition hover:scale-105"
on:click={() => (showInfo = false)}
>
View Source on GitHub
</a>
</p>
<p class="text-xs">
<a
href="https://joshpatra.me/"
target="_blank"
rel="noopener noreferrer"
class="underline hover:text-{COLORS.primary}"
>
My Portfolio
</a>
</p>
<Icon src={XMark} class="h-6 w-6" />
</button>
</div>
<p class="text-sm italic">New track in <strong>{timeLeft}</strong></p>
<button
class="mt-4 rounded px-6 py-2 font-semibold"
style="background:{COLORS.primary}; color:{darkMode ? COLORS.text : COLORS.background}"
on:click={() => (showInfo = false)}
<div class="grid grid-cols-3 gap-3 text-center">
<div class="rounded border p-3" style="border-color: {COLORS.primary}">
<div class="text-2xl font-extrabold">{stats.played}</div>
<div class="text-xs uppercase" style="color: {COLORS.muted}">Played</div>
</div>
<div class="rounded border p-3" style="border-color: {COLORS.secondary}">
<div class="text-2xl font-extrabold">
{stats.played ? Math.round((stats.wins / stats.played) * 100) : 0}%
</div>
<div class="text-xs uppercase" style="color: {COLORS.muted}">Wins</div>
</div>
<div class="rounded border p-3" style="border-color: {COLORS.accent}">
<div class="text-2xl font-extrabold">{stats.streak}</div>
<div class="text-xs uppercase" style="color: {COLORS.muted}">Streak</div>
</div>
</div>
<p class="mt-5 text-sm leading-6">
Songs belong to {ARTIST_NAME}. This app stores only your local game progress and local stats
in this browser.
</p>
<p class="mt-3 text-sm">
Next track in <strong>{timeLeft}</strong>
</p>
<a
href="https://github.com/SoPat712/maisie-heardle"
target="_blank"
rel="noopener noreferrer"
class="mt-5 inline-flex text-sm font-semibold underline"
style="color: {COLORS.primary}"
>
Close
</button>
View source
</a>
</div>
</div>
{/if}
<!-- Main UI -->
<div
class="fixed inset-0 flex flex-col overflow-y-auto"
style="
background: {darkMode ? COLORS.text : COLORS.background};
color: {darkMode ? COLORS.background : COLORS.text}
"
<main
class={`heardle-page h-[100dvh] overflow-hidden px-3 py-2 sm:px-6 sm:py-4 md:px-8 ${darkMode ? 'heardle-dark' : ''}`}
>
<!-- Header -->
<div class="flex items-center justify-between px-4 pt-4">
<div class="flex space-x-2">
<button on:click={() => (showInfo = true)}>
<Icon src={InformationCircle} class="h-6 w-6" style="color: {COLORS.primary}" />
</button>
<button on:click={toggleDark}>
<Icon src={darkMode ? Sun : Moon} class="h-6 w-6" style="color: {COLORS.primary}" />
</button>
</div>
<h1 class="flex-1 text-center font-serif text-lg font-bold whitespace-nowrap sm:text-3xl">
Heardle {ARTIST_NAME}
</h1>
<button on:click={() => (showHowTo = true)}>
<Icon src={QuestionMarkCircle} class="h-6 w-6" style="color: {COLORS.secondary}" />
</button>
</div>
<hr class="mx-4 my-3" style="border-color: {darkMode ? COLORS.background : COLORS.text}" />
<!-- Attempts -->
{#if !gameOver}
<div class="mb-6 space-y-2 px-4">
{#each attemptInfos as info}
<div
class="flex h-12 items-center rounded border px-3 font-semibold"
style="
border-color: {info.status === 'skip'
? COLORS.primary
: info.status === 'wrong'
? COLORS.accent
: COLORS.secondary};
color: {info.status === 'skip'
? COLORS.primary
: info.status === 'wrong'
? COLORS.accent
: COLORS.secondary}
"
<div class="mx-auto flex h-full max-w-5xl flex-col">
<header class="deck-header">
<div class="flex items-center gap-2">
<button
type="button"
aria-label="Game information and stats"
title="Info and stats"
class="deck-icon-button"
on:click={() => (showInfo = true)}
>
{#if info.status === 'skip'}▢ Skipped
{:else if info.status === 'wrong'}☒ {info.title}
{:else}✓ {info.title}{/if}
<Icon src={InformationCircle} class="h-5 w-5" />
</button>
<button
type="button"
aria-label={darkMode ? 'Use light mode' : 'Use dark mode'}
title={darkMode ? 'Light mode' : 'Dark mode'}
class="deck-icon-button deck-icon-button-alt"
on:click={() => (darkMode = !darkMode)}
>
<Icon src={darkMode ? Sun : Moon} class="h-5 w-5" />
</button>
</div>
<div class="deck-title min-w-0 text-center">
<p class="deck-kicker">Daily listening test</p>
<h1 class="truncate text-xl font-extrabold sm:text-2xl">Heardle - {ARTIST_NAME}</h1>
<p class="text-xs">New song in {timeLeft}</p>
</div>
<div class="flex items-center gap-2.5 sm:gap-3.5">
<div class="flex flex-col items-end leading-none select-none text-right">
<span class="text-[8px] sm:text-[9px] font-extrabold tracking-widest text-zinc-500 uppercase mb-1">Played</span>
<span class="text-sm sm:text-base font-black tracking-tight" style="color: {COLORS.primary}">
{globalGamesPlayed === null
? '...'
: globalGamesPlayed < 0
? '—'
: globalGamesPlayed.toLocaleString()}
</span>
</div>
{/each}
{#each Array(maxAttempts - attemptInfos.length) as _}
<div
class="h-12 rounded border"
style="border-color: {darkMode ? COLORS.background : COLORS.text}"
></div>
{/each}
</div>
{/if}
<!-- Win/Lose Card -->
{#if gameOver}
<div class="mb-6 px-4">
<a
href={`https://song.link/${currentTrack.url}`}
target="_blank"
rel="noopener"
class={`flex items-center overflow-hidden rounded-lg border-2 ${darkMode ? 'hover:bg-gray-700' : 'hover:bg-gray-100'}`}
style="border-color: {COLORS.primary}"
>
{#if artworkUrl}
<img
src={artworkUrl.replace('-large', '-t500x500')}
alt="{currentTrack.title} cover"
class="h-16 w-16 flex-shrink-0 object-cover"
/>
{/if}
<div class="px-4 py-2">
<div class="font-semibold" style="color: {COLORS.primary}">{currentTrack.title}</div>
<div class="text-sm" style="color: {COLORS.accent}">{ARTIST_NAME}</div>
</div>
</a>
<p class="mt-4 text-center font-medium">{message}</p>
</div>
{/if}
<button
type="button"
aria-label="How to play"
title="How to play"
class="deck-icon-button"
on:click={() => (showHowTo = true)}
>
<Icon src={QuestionMarkCircle} class="h-5 w-5" />
</button>
</div>
</header>
<!-- Invisible iframe for mobile autoplay -->
<iframe
bind:this={iframeElement}
src={`https://w.soundcloud.com/player/?url=${encodeURIComponent(currentTrack.url)}`}
style="position:absolute; width:0; height:0; border:0; overflow:hidden; visibility:hidden;"
allow="autoplay"
title="preview player"
></iframe>
<!-- Bottompinned controls -->
<div class="mt-auto px-4 pb-4">
<!-- Progress bar -->
<!-- Responsive layout: split columns on lg+ screens -->
<div
class="relative mb-2 w-full overflow-hidden rounded border"
style="height:1.25rem; border-color: {darkMode ? COLORS.background : COLORS.text}"
bind:this={gameGridEl}
class="game-grid mt-3 flex min-h-0 flex-1 flex-col justify-between gap-3 sm:gap-4 lg:mt-4 lg:grid lg:grid-cols-12 lg:gap-x-6 lg:gap-y-4"
class:game-grid-compact={gameOver && compactLayout}
>
{#if !gameOver}
<!-- Background segments showing unlocked/locked areas -->
{#each segmentDurations as segEnd, idx}
{@const segStart = idx === 0 ? 0 : segmentDurations[idx - 1]}
{@const isUnlocked = idx <= attemptCount}
<div
class="absolute top-0 h-full transition-all duration-500 ease-out"
style="
left: {(segStart / TOTAL_MS) * 100}%;
width: {isUnlocked ? ((segEnd - segStart) / TOTAL_MS) * 100 : 0}%;
background: {darkMode
? 'rgba(255, 255, 255, 0.15)'
: 'rgba(0, 0, 0, 0.1)'};
"
></div>
{/each}
{/if}
<!-- Active progress fill -->
<div
class="absolute top-0 left-0 h-full transition-[width] duration-100"
style="width: {fillPercent}%; background: {COLORS.accent}; z-index: 10;"
></div>
{#if !gameOver}
<!-- Segment dividers -->
{#each boundaries as b}
<div
class="absolute top-0 bottom-0"
style="left: {(b / TOTAL_SECONDS) * 100}%; border-left:1px solid {darkMode
? COLORS.background
: COLORS.text}; z-index: 20;"
></div>
{/each}
{/if}
</div>
<div class="mb-4 flex justify-between text-xs">
<span>{formatTime(currentPosition)}</span>
<span>{formatTime(gameOver ? fullDuration : TOTAL_MS)}</span>
</div>
<section class="attempt-panel order-1 min-h-0 lg:col-span-6" class:attempt-panel-game-over={gameOver}>
<p class="status-strip">
<span class="status-chip">
{gameOver ? 'Answer revealed' : `${unlockedSeconds}s unlocked`}
</span>
<span class="status-note">
{gameOver ? 'Full playback' : `${maxAttempts - attemptCount} guesses left`}
</span>
</p>
<!-- Play/Pause (and Rewind when game over) -->
<div class="mb-4 flex justify-center items-center gap-4">
{#if gameOver}
<button
on:click={rewindSong}
class="flex h-12 w-12 items-center justify-center rounded-full border-2 disabled:opacity-50"
style="border-color: {loading ? '#888888' : COLORS.primary}"
disabled={loading}
title="Restart from beginning"
<div
bind:this={attemptHistoryEl}
class="attempt-history space-y-1.5 sm:space-y-2"
aria-label="Guess history"
>
<Icon
src={ArrowPath}
class="h-6 w-6"
style="color: {loading ? '#888888' : COLORS.primary}"
/>
</button>
{/if}
<button
on:click={togglePlayPause}
class="flex h-16 w-16 items-center justify-center rounded-full border-2 disabled:opacity-50"
style="border-color: {loading ? '#888888' : COLORS.accent}"
disabled={loading}
>
<Icon
src={isPlaying ? Pause : Play}
class="h-8 w-8"
style="color: {loading ? '#888888' : COLORS.accent}"
/>
</button>
</div>
<!-- Guess & Skip/Submit -->
{#if !gameOver}
<div class="relative mb-4 overflow-visible">
<input
bind:this={inputEl}
type="text"
placeholder="Type song title…"
bind:value={userInput}
on:keydown={onInputKeydown}
on:focus={() => (selectedTrack = null)}
class="w-full rounded border px-3 py-2"
style="border-color: {COLORS.primary}; background: {darkMode
? COLORS.text
: COLORS.background}; color: {darkMode ? COLORS.background : COLORS.text}"
/>
{#if suggestions.length}
<ul
class="absolute bottom-full left-0 z-10 mb-1 max-h-36 w-full overflow-y-auto rounded border"
style="border-color: {darkMode ? COLORS.background : COLORS.text}; background: {darkMode
? COLORS.text
: COLORS.background}"
>
{#each suggestions as s}
<li>
<button
type="button"
class="w-full px-3 py-2 text-left"
style="color: {darkMode ? COLORS.background : COLORS.text}"
on:click={() => selectSuggestion(s)}
>
{s.title}  <span style="opacity:0.7">{s.artist}</span>
</button>
</li>
{#each attemptInfos as info, index (`${index}-${info.status}-${info.title ?? 'skip'}`)}
<div
class="attempt-row attempt-row-filled flex h-8 items-center justify-between rounded px-3 text-xs font-semibold sm:h-10 sm:text-sm md:h-11"
style="
--attempt-color: {info.status === 'skip'
? COLORS.primary
: info.status === 'wrong'
? COLORS.danger
: COLORS.success};
color: {info.status === 'skip'
? COLORS.primary
: info.status === 'wrong'
? COLORS.danger
: COLORS.success};
"
>
<span class="truncate pr-4">
{#if info.status === 'skip'}Skipped
{:else if info.status === 'wrong'}{info.title}
{:else}{info.title}{/if}
</span>
<span class="flex-shrink-0 text-[10px] uppercase">Try {index + 1}</span>
</div>
{/each}
</ul>
{/if}
{#each remainingAttempts as attemptNumber (attemptNumber)}
<div
class="attempt-row attempt-row-empty flex h-8 items-center rounded px-3 text-xs sm:h-10 sm:text-sm md:h-11"
>
Attempt {attemptNumber}
</div>
{/each}
</div>
</section>
<!-- Right Column: Spinning Vinyl Player (Desktop only) -->
<div class="vinyl-col order-2 hidden min-h-0 flex-col items-end justify-start lg:col-span-6 lg:flex">
<div class="vinyl-turntable-container flex w-full flex-col items-end justify-start">
<div
class="turntable-base relative flex h-[440px] w-[480px] items-center justify-center rounded-2xl shadow-xl transition-all duration-300 select-none"
style="
background: {darkMode ? '#1e1e1e' : '#f7f8f7'};
border: 1px solid var(--deck-border);
"
>
<!-- Vintage Start/Stop (Play/Pause) Button (top left) -->
<div class="absolute top-6 left-8 flex flex-col items-center gap-1 select-none z-40" style="width: 28px;">
<span class="text-[9px] font-extrabold tracking-widest text-zinc-500 uppercase">Start</span>
<button
type="button"
on:click={togglePlayPause}
disabled={loading || Boolean(widgetError)}
class="vintage-play-btn flex h-10 w-10 items-center justify-center rounded-full bg-zinc-950 border border-zinc-800 shadow-[inset_0_2px_4px_rgba(0,0,0,0.8)] focus:outline-none"
aria-label={isPlaying ? 'Pause audio' : 'Play audio'}
title={isPlaying ? 'Pause' : 'Play'}
>
<div
class="flex h-7 w-7 items-center justify-center rounded-full border transition-all duration-150"
class:active-btn={isPlaying}
style="
background: linear-gradient(180deg, #f3f4f6 0%, #d1d5db 40%, #9ca3af 100%);
border-color: #9ca3af;
box-shadow: 0 3px 6px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.6);
"
>
{#if isPlaying}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#374151" class="h-3 w-3">
<path fill-rule="evenodd" d="M6.75 5.25a.75.75 0 01.75-.75H9a.75.75 0 01.75.75v13.5a.75.75 0 01-.75.75H7.5a.75.75 0 01-.75-.75V5.25zm7.5 0A.75.75 0 0115 4.5h1.5a.75.75 0 01.75.75v13.5a.75.75 0 01-.75.75H15a.75.75 0 01-.75-.75V5.25z" clip-rule="evenodd" />
</svg>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#374151" class="h-3.5 w-3.5 ml-0.5">
<path fill-rule="evenodd" d="M4.5 5.653c0-1.426 1.529-2.33 2.779-1.643l11.54 6.348c1.295.712 1.295 2.573 0 3.285L7.28 19.991c-1.25.687-2.779-.217-2.779-1.643V5.653z" clip-rule="evenodd" />
</svg>
{/if}
</div>
</button>
</div>
<!-- Platter (clickable to toggle play/pause as a fallback) -->
<button
type="button"
on:click={togglePlayPause}
disabled={loading || Boolean(widgetError)}
class="platter absolute flex h-[400px] w-[400px] items-center justify-center rounded-full border cursor-pointer focus:outline-none z-30"
style="
left: 40px;
top: 20px;
background: #0d5b84;
box-shadow: inset 0 4px 10px rgba(0,0,0,0.35), 0 1px 3px rgba(255,255,255,0.08);
border-color: {darkMode ? '#27272a' : '#3f3f46'};
"
title={isPlaying ? 'Pause' : 'Play'}
>
<!-- Vinyl record wrapper -->
<div class="relative flex h-[360px] w-[360px] items-center justify-center rounded-full overflow-hidden">
<!-- Vinyl record (spins) -->
<div
bind:this={vinylElement}
class="vinyl-record absolute inset-0 flex items-center justify-center rounded-full shadow-lg"
>
<!-- Track separators (wider gaps) -->
<div class="absolute inset-[36px] rounded-full border border-black/30 pointer-events-none"></div>
<div class="absolute inset-[72px] rounded-full border border-black/30 pointer-events-none"></div>
<div class="absolute inset-[108px] rounded-full border border-black/30 pointer-events-none"></div>
<!-- Vinyl Center Label (spins with record) -->
<div
class="vinyl-label absolute top-1/2 left-1/2 z-10 flex h-36 w-36 -translate-x-1/2 -translate-y-1/2 items-center justify-center overflow-hidden rounded-full border-2 border-black/25"
style="background: linear-gradient(135deg, {COLORS.primary}, {COLORS.secondary});"
>
{#if gameOver && artworkUrl}
<img
src={artworkUrl.replace('-large', '-t500x500')}
alt="Artwork"
class="h-full w-full object-cover"
/>
{:else}
<div class="absolute inset-0 flex flex-col items-center justify-between py-6 select-none text-white uppercase text-center">
<span class="text-[17px] font-black tracking-[0.25em] mt-1.5">Heardle</span>
<span class="text-[8px] font-bold tracking-[0.3em] opacity-80 mb-1">Maisie Peters</span>
</div>
{/if}
</div>
</div>
<!-- Stationary light reflection overlay (sheen stays still) -->
<div class="vinyl-sheen absolute inset-0 rounded-full pointer-events-none z-20"></div>
</div>
<!-- Spindle center peg (STATIONARY in center of platter) -->
<div
class="absolute top-1/2 left-1/2 z-30 h-5 w-5 -translate-x-1/2 -translate-y-1/2 rounded-full border shadow-md flex items-center justify-center"
style="
background: {darkMode ? '#27272a' : '#d4d4d8'};
border-color: {darkMode ? '#3f3f46' : '#a1a1aa'};
"
>
<!-- Small center peg tip -->
<div class="h-1.5 w-1.5 rounded-full bg-zinc-900"></div>
</div>
</button>
<!-- Tonearm (S-shaped arm with pivot at 445,70 on a 480x440 canvas) -->
<div class="tonearm pointer-events-none absolute inset-0 h-full w-full z-40">
<svg viewBox="0 0 480 440" class="h-full w-full drop-shadow-[0_8px_12px_rgba(0,0,0,0.45)]">
<defs>
<!-- Metallic linear gradient for the chrome tonearm -->
<linearGradient id="chrome-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#f3f4f6" />
<stop offset="30%" stop-color="#9ca3af" />
<stop offset="50%" stop-color="#ffffff" />
<stop offset="70%" stop-color="#4b5563" />
<stop offset="100%" stop-color="#e5e7eb" />
</linearGradient>
<!-- Darker metallic for pivot and weight -->
<linearGradient id="dark-metal-grad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#71717a" />
<stop offset="50%" stop-color="#3f3f46" />
<stop offset="100%" stop-color="#18181b" />
</linearGradient>
</defs>
<!-- 1. Tonearm base / Rest cradle (STATIONARY) -->
<!-- Gimbal base -->
<circle cx="445" cy="70" r="22" fill="url(#dark-metal-grad)" stroke="#111" stroke-width="2" />
<circle cx="445" cy="70" r="14" fill="#18181b" stroke="#333" stroke-width="1" />
<!-- Arm Rest structure -->
<!-- Post -->
<line x1="452" y1="200" x2="452" y2="225" stroke="#4b5563" stroke-width="3" stroke-linecap="round" />
<!-- U-clip cradle -->
<path d="M 444 200 Q 452 206 460 200" fill="none" stroke="#4b5563" stroke-width="2.5" stroke-linecap="round" />
<!-- 2. Rotating Tonearm Assembly -->
<g
style="transform: rotate({isPlaying
? '22deg'
: '0deg'}); transform-origin: 445px 70px; transition: transform 750ms cubic-bezier(0.25, 1, 0.5, 1);"
>
<!-- Counterweight shaft (extends behind pivot) -->
<line x1="445" y1="70" x2="440" y2="30" stroke="#9ca3af" stroke-width="4.5" stroke-linecap="round" />
<!-- Counterweight dial ring -->
<rect x="430" y="24" width="20" height="12" rx="2" fill="url(#dark-metal-grad)" stroke="#111" stroke-width="1.5" />
<!-- Counterweight weight block -->
<rect x="432" y="16" width="16" height="8" rx="1" fill="#09090b" stroke="#333" stroke-width="1" />
<!-- Metallic 2-segment straight Tonearm tube -->
<path
d="M 445 70 L 456 180 L 445 235"
stroke="url(#chrome-grad)"
stroke-width="3.5"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Cartridge collar connector -->
<circle cx="445" cy="235" r="3.5" fill="#3f3f46" stroke="#111" stroke-width="0.5" />
<!-- Headshell (Classic Technics style angled cartridge) -->
<!-- Headshell body -->
<path
d="M 443 235 L 448 235 L 441 257 L 435 255 Z"
fill="#1f2937"
stroke="#111"
stroke-width="0.75"
/>
<!-- Finger lift (curving to the right) -->
<path
d="M 447 241 C 454 241, 456 245, 455 249"
fill="none"
stroke="#9ca3af"
stroke-width="1"
stroke-linecap="round"
/>
<!-- Stylus body (orange accent cartridge tip) -->
<polygon
points="435,255 441,257 439,261 435,260"
fill={COLORS.accent}
stroke="#111"
stroke-width="0.5"
/>
<!-- Tiny metallic stylus tip/needle -->
<line x1="435" y1="260" x2="433" y2="262" stroke="#d1d5db" stroke-width="1" />
</g>
</svg>
</div>
<!-- Dynamic Island Waveform (bottom right) -->
<div class="absolute bottom-6 right-8 flex items-center gap-1.5 h-10 w-24 justify-center pointer-events-none select-none z-40">
{#each waveformHeights as h, i}
<div
class="waveform-bar rounded-full"
style="
background: {COLORS.accent};
width: 5px;
height: {h}px;
transition: height 130ms ease-out;
"
></div>
{/each}
</div>
<!-- Vintage Volume Fader (bottom left, vertical) -->
<div class="absolute bottom-6 left-8 flex flex-col items-center gap-1 select-none z-40" style="width: 28px;">
<span class="text-[9px] font-extrabold tracking-widest text-zinc-500 uppercase">Vol</span>
<div class="relative w-8 h-20 flex items-center justify-center">
<input
type="range"
min="0"
max="100"
step="1"
value={volume}
on:input={handleVolumeChange}
disabled={loading || Boolean(widgetError)}
class="vintage-slider cursor-pointer"
/>
</div>
</div>
</div>
</div>
</div>
<div class="flex justify-between">
<button
on:click={skipIntro}
class="rounded px-4 py-2 font-semibold"
style="background: {COLORS.primary}; color: {COLORS.background}"
{#if gameOver}
<section
class="result-panel order-3 rounded p-3 sm:p-4"
style="--result-color: {won ? COLORS.success : COLORS.danger}"
>
{#if nextIncrementSec > 0}Skip (+{nextIncrementSec}s){:else}I don't know it{/if}
</button>
<button
on:click={submitGuess}
class="rounded px-4 py-2 font-semibold disabled:opacity-50"
style="background: {COLORS.secondary}; color: {COLORS.background}"
disabled={!userInput.trim()}
>
Submit
</button>
<div class="flex gap-3 sm:gap-4">
{#if artworkUrl}
<img
src={artworkUrl.replace('-large', '-t500x500')}
alt="{currentTrack.title} cover"
class="h-16 w-16 flex-shrink-0 rounded object-cover shadow-md sm:h-20 sm:w-20"
/>
{/if}
<div class="min-w-0 flex-1">
<p
class="text-xs font-bold uppercase"
style="color: {won ? COLORS.success : COLORS.danger}"
>
{won ? 'Solved' : 'Revealed'}
{resultLabel}
</p>
<h2 class="truncate text-lg font-extrabold sm:text-2xl">{currentTrack.title}</h2>
<p class="text-xs sm:text-sm" style="color: {darkMode ? '#cfc7c1' : COLORS.muted}">
{message}
</p>
<a
href={`https://song.link/${currentTrack.url}`}
target="_blank"
rel="noopener noreferrer"
class="mt-1 inline-flex text-xs font-semibold underline sm:mt-2 sm:text-sm"
style="color: {COLORS.primary}"
>
Open song links
</a>
</div>
</div>
<div class="mt-3 flex flex-wrap items-center gap-2 sm:mt-4 sm:gap-3">
<button
type="button"
class="share-button inline-flex items-center gap-2 rounded px-3 py-1.5 text-xs font-bold text-white transition hover:brightness-95 sm:px-4 sm:py-2 sm:text-sm"
on:click={shareResult}
>
<Icon src={Share} class="h-4 w-4 sm:h-5 sm:w-5" />
Share result
</button>
{#if shareMessage}
<span class="text-xs font-medium sm:text-sm" aria-live="polite">{shareMessage}</span
>
{/if}
</div>
</section>
{/if}
<!-- Bottom Row (Player Controls) -->
<div class="player-wrap order-4 flex-shrink-0">
<!-- Hidden SoundCloud player iframe -->
<iframe
bind:this={iframeElement}
src={`https://w.soundcloud.com/player/?url=${encodeURIComponent(currentTrack.url)}&show_artwork=false&visual=false`}
style="position:absolute; width:0; height:0; border:0; overflow:hidden; visibility:hidden;"
allow="autoplay"
title="SoundCloud preview player"
></iframe>
<!-- Control Deck (Audio player controls & input) -->
<section class="control-deck mt-auto">
{#if widgetError}
<div
class="mb-3 rounded border p-3 text-xs sm:text-sm"
role="alert"
style="border-color: {COLORS.danger}; color: {COLORS.danger}; background: {darkMode
? '#2b1717'
: '#fff1ef'}"
>
{widgetError}
</div>
{/if}
<div
class="progress-rail relative mb-1.5 h-5 w-full overflow-hidden rounded sm:mb-2 sm:h-7"
aria-label="Audio progress"
>
{#if !gameOver}
{#each segmentDurations as segmentEnd, index (segmentEnd)}
{@const segmentStart = index === 0 ? 0 : segmentDurations[index - 1]}
{@const isUnlocked = index <= attemptCount}
<div
class="absolute top-0 h-full transition-all duration-500 ease-out"
style="
left: {(segmentStart / TOTAL_MS) * 100}%;
width: {isUnlocked ? ((segmentEnd - segmentStart) / TOTAL_MS) * 100 : 0}%;
background: {darkMode ? 'rgba(255,255,255,0.12)' : 'rgba(18,127,179,0.12)'};
"
></div>
{/each}
{/if}
<div
class="absolute top-0 left-0 h-full transition-[width] duration-100"
style="width: {fillPercent}%; background: {COLORS.accent};"
></div>
{#if !gameOver}
{#each boundaries as boundary (boundary)}
<div
class="absolute top-0 bottom-0"
style="left: {(boundary / TOTAL_SECONDS) * 100}%; border-left:1px solid {darkMode
? '#fffaf7'
: COLORS.text}; opacity: 0.35;"
></div>
{/each}
{/if}
{#if gameOver}
<button
type="button"
class="absolute inset-0 z-20 h-full w-full cursor-pointer bg-transparent focus:ring-2 focus:outline-none"
style="--tw-ring-color: {COLORS.primary}"
aria-label="Seek finished song"
aria-valuemin="0"
aria-valuemax={Math.round((fullDuration || TOTAL_MS) / 1000)}
aria-valuenow={Math.round(currentPosition / 1000)}
role="slider"
title="Seek song"
on:click={seekFinishedSong}
on:keydown={seekFinishedSong}
></button>
{/if}
</div>
<div
class="mb-2 flex justify-between text-xs sm:mb-4"
style="color: {darkMode ? '#cfc7c1' : COLORS.muted}"
>
<span>{formatTime(currentPosition)}</span>
<span>{formatTime(progressDuration)}</span>
</div>
<div class="mb-3 flex items-center justify-center gap-4 sm:mb-4">
{#if gameOver}
<button
type="button"
on:click={rewindSong}
class="lg:hidden flex h-9 w-9 items-center justify-center rounded-full border-2 transition hover:scale-105 disabled:opacity-50 sm:h-12 sm:w-12"
style="border-color: {loading ? '#888888' : COLORS.primary}; color: {loading
? '#888888'
: COLORS.primary}"
disabled={loading}
aria-label="Restart song from beginning"
title="Restart song"
>
<Icon src={ArrowPath} class="h-4 w-4 sm:h-6 sm:w-6" />
</button>
{/if}
<button
type="button"
on:click={togglePlayPause}
class="lg:hidden flex h-12 w-12 items-center justify-center rounded-full border-2 transition hover:scale-105 disabled:opacity-50 sm:h-16 sm:w-16"
style="border-color: {loading || widgetError
? '#888888'
: COLORS.accent}; color: {loading || widgetError ? '#888888' : COLORS.accent}"
disabled={loading || Boolean(widgetError)}
aria-label={isPlaying ? 'Pause audio' : 'Play audio'}
title={isPlaying ? 'Pause' : 'Play'}
>
<Icon src={isPlaying ? Pause : Play} class="h-6 w-6 sm:h-8 sm:w-8" />
</button>
</div>
{#if loading && !widgetError}
<p
class="mb-3 animate-pulse text-center text-xs sm:text-sm"
style="color: {darkMode ? '#cfc7c1' : COLORS.muted}"
aria-live="polite"
>
Loading todays song...
</p>
{/if}
{#if !gameOver}
<div class="relative mb-3 overflow-visible">
<label for="guess-input" class="sr-only">Song title</label>
<input
id="guess-input"
bind:this={inputEl}
type="text"
placeholder="Type a song title..."
bind:value={userInput}
on:keydown={onInputKeydown}
on:focus={() => (selectedTrack = null)}
autocomplete="off"
class="w-full rounded border px-3 py-2 text-sm transition outline-none focus:ring-2 sm:px-4 sm:py-3 sm:text-base"
style="border-color: {COLORS.primary}; background: {darkMode
? '#1d1d1d'
: COLORS.panel}; color: {darkMode
? '#fffaf7'
: COLORS.text}; --tw-ring-color: {COLORS.primary}"
/>
{#if suggestions.length}
<ul
class="absolute bottom-full left-0 z-10 mb-2 max-h-48 w-full overflow-y-auto rounded border shadow-lg sm:max-h-72"
style="border-color: {darkMode ? '#3d3d3d' : '#dcdcdc'}; background: {darkMode
? '#1d1d1d'
: COLORS.panel}"
>
{#each suggestions as suggestion (suggestion.slug)}
<li>
<button
type="button"
class="flex w-full items-center gap-3 px-3 py-2 text-left text-xs transition hover:bg-black/5 sm:text-sm"
style="color: {darkMode ? '#fffaf7' : COLORS.text}"
on:click={() => selectSuggestion(suggestion)}
>
{#if suggestionArtwork[suggestion.slug]}
<img
src={suggestionArtwork[suggestion.slug]}
alt=""
class="h-9 w-9 flex-none rounded object-cover sm:h-11 sm:w-11"
loading="lazy"
/>
{:else}
<span
class="flex h-9 w-9 flex-none items-center justify-center rounded text-xs font-bold text-white sm:h-11 sm:w-11 sm:text-sm"
style="background: {COLORS.primary}"
>
{suggestion.title.slice(0, 1)}
</span>
{/if}
<span class="min-w-0">
<span class="block truncate font-semibold">{suggestion.title}</span>
<span
class="block truncate text-[10px] sm:text-xs"
style="color: {darkMode ? '#bbb' : COLORS.muted}"
>
{suggestion.artist}
</span>
</span>
</button>
</li>
{/each}
</ul>
{/if}
</div>
<div class="flex gap-2 sm:gap-3">
<button
type="button"
on:click={skipIntro}
class="flex-1 rounded px-3 py-2 text-sm font-bold text-white transition hover:brightness-95 disabled:opacity-50 sm:px-4 sm:py-3 sm:text-base"
style="background: {COLORS.primary}"
disabled={loading || Boolean(widgetError)}
>
{#if nextIncrementSec > 0}Skip (+{nextIncrementSec}s){:else}I dont know it{/if}
</button>
<button
type="button"
on:click={submitGuess}
class="flex-1 rounded px-3 py-2 text-sm font-bold text-white transition hover:brightness-95 disabled:opacity-50 sm:px-4 sm:py-3 sm:text-base"
style="background: {COLORS.secondary}"
disabled={!canSubmit}
>
Submit
</button>
</div>
{/if}
</section>
</div>
{/if}
</div>
</div>
</div>
</main>
<style>
/* Tailwind in app.css handles spacing/layout */
.heardle-page {
--page-bg: #f7f8f7;
--page-fg: #171717;
--deck-bg: rgba(255, 255, 255, 0.92);
--deck-border: #d9dcdf;
--deck-muted: #5f6268;
--deck-shadow: 0 18px 45px rgba(20, 24, 28, 0.08);
--primary: #127fb3;
--secondary: #c43a84;
--accent: #f15a24;
--rail-bg: #ffffff;
--empty-row: #f7f8f7;
background: var(--page-bg);
color: var(--page-fg);
}
.heardle-page.heardle-dark {
--page-bg: #121212;
--page-fg: #fffaf7;
--deck-bg: rgba(29, 29, 29, 0.92);
--deck-border: #36393d;
--deck-muted: #bbb;
--deck-shadow: 0 18px 45px rgba(0, 0, 0, 0.26);
--rail-bg: #171717;
--empty-row: #191919;
background: var(--page-bg);
}
.deck-header,
.attempt-panel,
.control-deck,
.result-panel {
background: var(--deck-bg);
border: 1px solid var(--deck-border);
border-radius: 8px;
box-shadow: var(--deck-shadow);
}
.deck-header {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
padding: 12px;
}
.deck-title {
color: var(--page-fg);
}
.deck-title p {
color: var(--deck-muted);
}
.deck-kicker {
margin: 0 0 2px;
font-size: 0.72rem;
font-weight: 800;
text-transform: uppercase;
}
.deck-icon-button {
display: inline-flex;
width: 40px;
height: 40px;
align-items: center;
justify-content: center;
border: 1px solid var(--deck-border);
border-radius: 8px;
background: var(--rail-bg);
color: var(--primary);
transition:
transform 140ms ease,
background 140ms ease,
border-color 140ms ease;
}
.deck-icon-button-alt {
color: var(--secondary);
}
.deck-icon-button:hover {
transform: translateY(-1px);
border-color: currentColor;
}
.attempt-panel {
display: flex;
flex-direction: column;
flex-grow: 1;
}
@media (min-width: 1024px) {
.attempt-panel {
height: 440px;
flex-grow: 0;
display: flex;
flex-direction: column;
}
.attempt-history {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.attempt-row {
flex-grow: 1;
font-size: 1.1rem !important;
}
.game-grid:not(.game-grid-compact) .attempt-panel-game-over .attempt-row {
flex-grow: 0;
min-height: 2.25rem;
}
.game-grid:not(.game-grid-compact) .result-panel {
grid-column: 1 / -1;
}
.game-grid:not(.game-grid-compact) .player-wrap {
grid-column: 1 / -1;
}
.game-grid-compact .attempt-panel {
grid-column: 1 / span 6;
grid-row: 1;
height: auto;
max-height: min(440px, 42vh);
}
.game-grid-compact .vinyl-col {
grid-column: 7 / span 6;
grid-row: 1 / span 2;
}
.game-grid-compact .result-panel {
grid-column: 1 / span 6;
grid-row: 2;
}
.game-grid-compact .player-wrap {
grid-column: 1 / span 6;
grid-row: 3;
}
.game-grid-compact .attempt-history {
flex-grow: 0;
overflow-y: auto;
}
.game-grid-compact .attempt-row {
flex-grow: 0;
min-height: 2.25rem;
}
}
@media (min-width: 1024px) and (max-height: 820px) {
.game-grid-compact .attempt-row {
min-height: 2rem;
font-size: 0.95rem !important;
}
.game-grid-compact .result-panel {
padding: 10px 12px;
}
.game-grid-compact .result-panel h2 {
font-size: 1.1rem;
}
}
.attempt-panel,
.control-deck {
padding: 10px;
}
@media (min-width: 640px) {
.attempt-panel,
.control-deck {
padding: 16px;
}
}
@media (min-width: 768px) {
.deck-header {
padding: 16px 18px;
}
.attempt-panel,
.control-deck {
padding: 22px;
}
}
.status-strip {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
font-size: 0.9rem;
}
.status-chip {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 5px 10px;
background: var(--primary);
color: #fff;
font-weight: 800;
}
.status-note {
color: var(--deck-muted);
font-weight: 650;
}
.attempt-row {
position: relative;
overflow: hidden;
border: 1px solid var(--deck-border);
background: var(--rail-bg);
}
.attempt-row-filled {
border-color: var(--attempt-color);
background: linear-gradient(90deg, var(--attempt-color) 0 6px, transparent 6px), var(--rail-bg);
padding-left: 16px;
}
.attempt-row-empty {
border-style: dashed;
background: var(--empty-row);
color: var(--deck-muted);
}
.result-panel {
border-color: var(--result-color);
}
.control-deck {
border-top: 4px solid var(--accent);
}
.progress-rail {
border: 1px solid var(--deck-border);
background: linear-gradient(180deg, rgba(0, 0, 0, 0.06), transparent 45%), var(--rail-bg);
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.14);
}
.share-button {
background: var(--secondary);
}
@media (min-width: 768px) {
.attempt-row {
min-height: 52px;
font-size: 0.98rem;
}
.progress-rail {
height: 34px;
}
}
@media (max-width: 520px) {
.deck-header {
gap: 8px;
padding: 10px;
}
.deck-icon-button {
width: 36px;
height: 36px;
}
.deck-kicker {
display: none;
}
.status-strip {
align-items: flex-start;
flex-direction: column;
gap: 6px;
}
}
/* Spinning Vinyl Record Player Styles */
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.waveform-bar.animating {
animation: waveform-bounce ease-in-out infinite;
}
@keyframes waveform-bounce {
0%, 100% {
height: 4px;
}
50% {
height: 28px;
}
}
/* Vintage Vertical Volume Fader */
.vintage-slider {
-webkit-appearance: none;
appearance: none;
background: transparent;
outline: none;
position: absolute;
width: 80px; /* matches fader container height */
height: 20px; /* matches fader container width */
margin: 0;
padding: 0;
/* Rotate -90deg so left(0) is bottom, right(100) is top */
transform: rotate(-90deg);
transform-origin: center center;
}
.vintage-slider::-webkit-slider-runnable-track {
width: 100%;
height: 4px;
background: #111113;
border: 1px solid #333336;
border-radius: 2px;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.8);
}
.vintage-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 10px;
height: 18px;
/* Gradient horizontal so line appears vertical after rotation */
background: linear-gradient(90deg, #e4e4e7 0%, #a1a1aa 35%, #ef4444 45%, #ef4444 55%, #71717a 65%, #3f3f46 100%);
border: 1px solid #18181b;
border-radius: 1.5px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.6);
cursor: pointer;
margin-top: -7px;
}
.vintage-slider::-moz-range-track {
width: 100%;
height: 4px;
background: #111113;
border: 1px solid #333336;
border-radius: 2px;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.8);
}
.vintage-slider::-moz-range-thumb {
width: 10px;
height: 18px;
background: linear-gradient(90deg, #e4e4e7 0%, #a1a1aa 35%, #ef4444 45%, #ef4444 55%, #71717a 65%, #3f3f46 100%);
border: 1px solid #18181b;
border-radius: 1.5px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.6);
cursor: pointer;
}
/* Vintage Tactile Play Button */
.vintage-play-btn {
cursor: pointer;
background: #09090b;
}
.vintage-play-btn:hover > div {
background: linear-gradient(180deg, #ffffff 0%, #e5e7eb 40%, #d1d5db 100%) !important;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.8) !important;
}
.vintage-play-btn:active > div,
.vintage-play-btn > div.active-btn {
transform: scale(0.95) translateY(1px);
background: linear-gradient(180deg, #d1d5db 0%, #9ca3af 40%, #6b7280 100%) !important;
border-color: #6b7280 !important;
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.4) !important;
}
.vintage-play-btn:disabled {
cursor: default;
opacity: 0.3;
}
.vinyl-record {
background:
radial-gradient(circle, #2d2d2d 20%, transparent 20%),
repeating-radial-gradient(
circle,
#202024 0px,
#111113 1px,
#202024 2px
);
box-shadow:
0 0 0 2px rgba(0, 0, 0, 0.6),
inset 0 0 20px rgba(0, 0, 0, 0.95),
0 12px 28px rgba(0, 0, 0, 0.65);
}
.vinyl-sheen {
background: conic-gradient(
from 120deg,
transparent 0%,
rgba(255, 255, 255, 0.04) 8%,
rgba(255, 255, 255, 0.12) 15%,
rgba(255, 255, 255, 0.04) 22%,
transparent 30%,
transparent 50%,
rgba(255, 255, 255, 0.04) 58%,
rgba(255, 255, 255, 0.12) 65%,
rgba(255, 255, 255, 0.04) 72%,
transparent 80%,
transparent 100%
);
mix-blend-mode: screen;
opacity: 0.85;
}
.vinyl-label {
box-shadow: inset 0 0 8px rgba(0, 0, 0, 0.35);
}
.platter {
box-shadow:
inset 0 6px 15px rgba(0, 0, 0, 0.95),
0 1px 3px rgba(255, 255, 255, 0.08);
}
</style>
+62
View File
@@ -0,0 +1,62 @@
import { getStore } from '@netlify/blobs';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
const STORE_NAME = 'maisie-heardle';
const COUNTER_KEY = 'games-played';
const LOCAL_FILE = join(process.cwd(), '.data', 'games-played.json');
async function readLocalCount(): Promise<number> {
try {
const raw = await readFile(LOCAL_FILE, 'utf8');
const parsed = JSON.parse(raw) as { count?: number };
return Number.isFinite(parsed.count) ? parsed.count! : 0;
} catch {
return 0;
}
}
async function writeLocalCount(count: number): Promise<number> {
await mkdir(join(process.cwd(), '.data'), { recursive: true });
await writeFile(LOCAL_FILE, JSON.stringify({ count }), 'utf8');
return count;
}
async function readNetlifyCount(): Promise<number> {
const store = getStore({ name: STORE_NAME, consistency: 'strong' });
const value = await store.get(COUNTER_KEY);
if (!value) return 0;
const count = Number.parseInt(value, 10);
return Number.isFinite(count) ? count : 0;
}
async function incrementNetlifyCount(): Promise<number> {
const store = getStore({ name: STORE_NAME, consistency: 'strong' });
const next = (await readNetlifyCount()) + 1;
await store.set(COUNTER_KEY, String(next));
return next;
}
async function withNetlifyBlobs<T>(fn: () => Promise<T>): Promise<T | null> {
try {
return await fn();
} catch (error) {
if (error instanceof Error && error.name === 'MissingBlobsEnvironmentError') {
return null;
}
throw error;
}
}
export async function getGamesPlayedCount(increment = false): Promise<number> {
const netlifyCount = await withNetlifyBlobs(async () => {
if (increment) return incrementNetlifyCount();
return readNetlifyCount();
});
if (netlifyCount !== null) return netlifyCount;
const localCount = await readLocalCount();
if (increment) return writeLocalCount(localCount + 1);
return localCount;
}
+125
View File
@@ -0,0 +1,125 @@
import seedrandom from 'seedrandom';
export const ARTIST_NAME = 'Maisie Peters';
export const SC_USER = 'maisie-peters';
export type TrackData = {
title: string;
slug: string;
};
export type Track = TrackData & {
artist: string;
url: string;
};
export const TRACKS_DATA: TrackData[] = [
{ title: 'Holy Revival', slug: 'holy-revival' },
{ title: 'The Song', slug: 'the-song' },
{ title: 'Guy On A Horse', slug: 'guy-on-a-horse' },
{ title: 'The Last One', slug: 'the-last-one' },
{ title: 'Yoko', slug: 'yoko' },
{ title: 'Truth Is', slug: 'truth-is' },
{ title: 'The Band And I', slug: 'the-band-and-i' },
{ title: 'There It Goes', slug: 'there-it-goes' },
{ title: 'Watch', slug: 'watch' },
{ title: 'History Of Man', slug: 'history-of-man' },
{ title: 'Youre Just A Boy (And Im Kinda The Man)', slug: 'youre-just-a-boy-and-im-kinda' },
{ title: 'The Good Witch', slug: 'the-good-witch' },
{ title: 'Want You Back', slug: 'want-you-back' },
{ title: 'BSC', slug: 'bsc' },
{ title: 'Run', slug: 'run' },
{ title: 'Wendy', slug: 'wendy' },
{ title: 'Coming Of Age', slug: 'coming-of-age' },
{ title: 'Therapy', slug: 'therapy' },
{ title: 'Two Weeks Ago', slug: 'two-weeks-ago' },
{ title: 'Lost The Breakup', slug: 'lost-the-breakup' },
{ title: 'Body Better', slug: 'body-better' },
{ title: 'Together This Christmas', slug: 'together-this-christmas' },
{ title: 'Not Another Rockstar', slug: 'not-another-rockstar' },
{ title: 'Good Enough', slug: 'good-enough' },
{ title: 'Blonde', slug: 'blonde' },
{ title: 'Cates Brother', slug: 'cates-brother' },
{ title: 'Im Trying (Not Friends)', slug: 'im-trying-not-friends' },
{ title: 'Villain', slug: 'villain' },
{ title: 'Elvis Song', slug: 'elvis-song' },
{ title: 'Talking To Strangers', slug: 'talking-to-strangers' },
{ title: 'Love Him I Dont', slug: 'love-him-i-dont' },
{ title: 'Outdoor Pool', slug: 'outdoor-pool' },
{ title: 'Boy', slug: 'boy' },
{ title: 'Hollow', slug: 'hollow' },
{ title: 'Tough Act', slug: 'tough-act' },
{ title: 'Volcano', slug: 'volcano' },
{ title: 'Brooklyn', slug: 'brooklyn' },
{ title: 'You Signed Up For This', slug: 'you-signed-up-for-this' },
{ title: 'Psycho', slug: 'psycho' },
{ title: 'The Party', slug: 'the-party' },
{ title: 'Lunar Years', slug: 'lunar-years' },
{ title: 'Happy Hunting Ground (feat. Griff)', slug: 'happy-hunting-ground-feat' },
{ title: 'Helicopter', slug: 'helicopter' },
{ title: 'Milhouse', slug: 'milhouse' },
{ title: 'Glowing Review', slug: 'glowing-review' },
{ title: 'Neck Of The Woods', slug: 'neck-of-the-woods' },
{ title: 'Funeral (feat. James Bay)', slug: 'funeral-feat-james-bay' },
{ title: 'John Hughes Movie', slug: 'john-hughes' },
{ title: "Maybe Don't (feat. JP Saxe)", slug: 'maybe-dont-feat-jp-saxe' },
{ title: 'Sad Girl Summer', slug: 'sad-girl-summer' },
{ title: 'The List', slug: 'the-list' },
{ title: 'Daydreams', slug: 'daydreams' },
{ title: 'Look At Me Now', slug: 'look-at-me-now-1' },
{ title: 'Take Care Of Yourself', slug: 'take-care-of-yourself-1' },
{ title: 'Personal Best', slug: 'personal-best-1' },
{ title: 'April Showers', slug: 'april-showers' },
{ title: 'Adore You', slug: 'adore-you' },
{ title: 'This Is On You', slug: 'this-is-on-you-1' },
{ title: 'This Is On You', slug: 'this-is-on-you' },
{ title: 'Favourite Ex', slug: 'favourite-ex' },
{ title: 'Stay Young', slug: 'stay-young' },
{ title: 'Enough For You', slug: 'enough-for-you' },
{ title: 'You To You', slug: 'you-to-you' },
{ title: 'Architecture', slug: 'architecture-1' },
{ title: 'Feels Like This', slug: 'feels-like-this' },
{ title: 'Details', slug: 'details' },
{ title: 'In My Head', slug: 'in-my-head' },
{ title: "Best I'll Ever Sing", slug: 'best-ill-ever-sing-1' },
{ title: 'Worst of You', slug: 'worst-of-you' }
];
export const tracks: Track[] = TRACKS_DATA.map(({ title, slug }) => ({
title,
slug,
artist: ARTIST_NAME,
url: `https://soundcloud.com/${SC_USER}/${slug}`
}));
export function getLocalDateKey(date = new Date()) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
export function getDailyTrack(dateKey = getLocalDateKey()) {
const dayNumber = Math.floor(new Date(`${dateKey}T00:00:00`).getTime() / 86_400_000);
const cycle = Math.floor(dayNumber / tracks.length);
const position = dayNumber % tracks.length;
const shuffled = tracks.map((_, index) => index);
const rng = seedrandom(`maisie-heardle:${cycle}`);
for (let index = shuffled.length - 1; index > 0; index -= 1) {
const swapIndex = Math.floor(rng() * (index + 1));
[shuffled[index], shuffled[swapIndex]] = [shuffled[swapIndex], shuffled[index]];
}
return tracks[shuffled[position]];
}
export function normalizeTrackTitle(title: string) {
return title
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[’‘]/g, "'")
.replace(/[^a-z0-9]+/gi, ' ')
.trim()
.toLowerCase();
}
+1 -1
View File
@@ -4,7 +4,7 @@
</script>
<svelte:head>
<title>Heardle  Maisie Peters Edition</title>
<title>Heardle - Maisie Peters Edition</title>
</svelte:head>
<!-- Remove bg-black/text-white here so your game can supply its own background -->
@@ -0,0 +1,52 @@
import { json, type RequestHandler } from '@sveltejs/kit';
const SOUNDCLOUD_OEMBED_URL = 'https://soundcloud.com/oembed';
const ALLOWED_HOSTS = new Set(['soundcloud.com', 'www.soundcloud.com']);
export const GET: RequestHandler = async ({ fetch, url }) => {
const trackUrl = url.searchParams.get('url');
if (!trackUrl) {
return json({ error: 'Missing SoundCloud URL.' }, { status: 400 });
}
try {
const parsedUrl = new URL(trackUrl);
if (!ALLOWED_HOSTS.has(parsedUrl.hostname)) {
return json({ error: 'Only SoundCloud URLs are supported.' }, { status: 400 });
}
} catch {
return json({ error: 'Invalid SoundCloud URL.' }, { status: 400 });
}
const oembedUrl = new URL(SOUNDCLOUD_OEMBED_URL);
oembedUrl.searchParams.set('format', 'json');
oembedUrl.searchParams.set('url', trackUrl);
const response = await fetch(oembedUrl);
if (!response.ok) {
return json({ error: 'SoundCloud metadata was unavailable.' }, { status: response.status });
}
const data = (await response.json()) as {
author_name?: string;
provider_url?: string;
thumbnail_url?: string;
title?: string;
};
return json(
{
authorName: data.author_name ?? '',
providerUrl: data.provider_url ?? '',
thumbnailUrl: data.thumbnail_url ?? '',
title: data.title ?? ''
},
{
headers: {
'cache-control': 'public, max-age=86400, stale-while-revalidate=604800'
}
}
);
};
@@ -0,0 +1,21 @@
import { getGamesPlayedCount } from '$lib/server/games-played';
import { json, type RequestHandler } from '@sveltejs/kit';
export const GET: RequestHandler = async ({ url }) => {
const increment = url.searchParams.get('increment') === '1';
try {
const count = await getGamesPlayedCount(increment);
return json(
{ count },
{
headers: {
'cache-control': increment ? 'no-store' : 'public, max-age=60, stale-while-revalidate=300'
}
}
);
} catch {
return json({ error: 'Counter unavailable.' }, { status: 502 });
}
};