mirror of
https://github.com/SoPat712/maisie-heardle.git
synced 2026-07-02 14:26:43 -04:00
feat(game): add global play counter, vinyl deck, and adaptive end-game layout
This commit is contained in:
@@ -21,3 +21,6 @@ Thumbs.db
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
# Local dev counter fallback
|
||||
.data
|
||||
|
||||
@@ -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'
|
||||
|
||||

|
||||
|
||||
|
||||
---
|
||||
|
||||
## 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:
|
||||

|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -31,6 +31,9 @@ export default ts.config(
|
||||
parser: ts.parser,
|
||||
svelteConfig
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': 'off'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
Generated
+598
-144
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+30
@@ -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
@@ -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: 'You’re Just A Boy (And I’m 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: "Cate’s Brother (BRELAND's Version)", slug: "cates-brother-brelands-version" },
|
||||
// { title: "Cate’s Brother (Matt's Version)", slug: "cates-brother-matts-version" },
|
||||
{ title: 'Cate’s Brother', slug: 'cates-brother' },
|
||||
{ title: 'I’m 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 Don’t', 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 today’s 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>
|
||||
|
||||
<!-- Bottom‑pinned 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 today’s 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 don’t 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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: 'You’re Just A Boy (And I’m 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: 'Cate’s Brother', slug: 'cates-brother' },
|
||||
{ title: 'I’m 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 Don’t', 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();
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user