diff options
| author | Pinapelz <yukais@pinapelz.com> | 2026-06-03 17:22:48 -0700 |
|---|---|---|
| committer | Pinapelz <yukais@pinapelz.com> | 2026-06-03 17:22:48 -0700 |
| commit | 14172f9dd64ce91ba5cf51f82c53deb6a81d68a6 (patch) | |
| tree | 5e12ce4e30ecaed9a2aac48d2959d99a4d8b4ef7 | |
| parent | 818db3ef4aadf489dba5ba8ba4f3bb4e150f0b22 (diff) | |
create daily/unlimited mode, CDN audio file for daily mode
| -rw-r--r-- | package.json | 2 | ||||
| -rw-r--r-- | playlist_generator/.env.template | 0 | ||||
| -rw-r--r-- | playlist_generator/.python-version | 1 | ||||
| -rw-r--r-- | playlist_generator/generate_daily.py | 91 | ||||
| -rw-r--r-- | playlist_generator/playlist_generator.py | 1 | ||||
| -rw-r--r-- | playlist_generator/pyproject.toml | 12 | ||||
| -rw-r--r-- | playlist_generator/uv.lock | 209 | ||||
| -rw-r--r-- | pnpm-lock.yaml | 53 | ||||
| -rw-r--r-- | server/data/songs.ts | 1 | ||||
| -rw-r--r-- | server/index.ts | 28 | ||||
| -rw-r--r-- | src/app.tsx | 237 | ||||
| -rw-r--r-- | src/components/Game/index.tsx | 21 | ||||
| -rw-r--r-- | src/components/Player/index.tsx | 137 | ||||
| -rw-r--r-- | src/components/Result/index.tsx | 40 | ||||
| -rw-r--r-- | src/components/YTPlayer/index.styled.ts (renamed from src/components/Player/index.styled.ts) | 0 | ||||
| -rw-r--r-- | src/components/YTPlayer/index.tsx | 100 | ||||
| -rw-r--r-- | src/components/index.ts | 1 | ||||
| -rw-r--r-- | src/helpers/fetchSolution.ts | 21 | ||||
| -rw-r--r-- | src/hooks/useGameState.ts | 173 | ||||
| -rw-r--r-- | src/pages/DailyPage.tsx | 111 | ||||
| -rw-r--r-- | src/pages/LandingPage.tsx | 92 | ||||
| -rw-r--r-- | src/pages/UnlimitedPage.tsx | 78 |
22 files changed, 1110 insertions, 299 deletions
diff --git a/package.json b/package.json index d9877dd..33817f6 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "@types/node": "^18.16.0", "@types/react": "^17.0.20", "@types/react-dom": "^17.0.9", + "@types/react-router-dom": "^5", "cors": "^2.8.6", "dotenv": "^17.4.2", "express": "^5.2.1", @@ -18,6 +19,7 @@ "react-dom": "^17.0.2", "react-icons": "^4.3.1", "react-is": "^18.2.0", + "react-router-dom": "^6", "react-youtube": "^7.14.0", "styled-components": "^5.3.3", "typescript": "^4.4.2", diff --git a/playlist_generator/.env.template b/playlist_generator/.env.template new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/playlist_generator/.env.template diff --git a/playlist_generator/.python-version b/playlist_generator/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/playlist_generator/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/playlist_generator/generate_daily.py b/playlist_generator/generate_daily.py new file mode 100644 index 0000000..7bd4683 --- /dev/null +++ b/playlist_generator/generate_daily.py @@ -0,0 +1,91 @@ +import os +import boto3 +import json +import requests +import random +from dotenv import load_dotenv +from datetime import datetime, timezone +import yt_dlp + +load_dotenv() + +ACCOUNT_ID = os.getenv("R2_ACCOUNT_ID") +ACCESS_KEY = os.getenv("R2_ACCESS_KEY") +SECRET_KEY = os.getenv("R2_SECRET_KEY") +BUCKET = os.getenv("R2_BUCKET") +API_URL = os.getenv("API_URL") +OBFUSCATION_KEY = os.getenv("OBFUSCATION_KEY") + + +def xor_buffer(data: bytes, key: bytes) -> bytes: + return bytes(b ^ key[i % len(key)] for i, b in enumerate(data)) + +def get_obfuscation_key() -> bytes: + date = datetime.now(timezone.utc).strftime("%Y-%m-%d") + return (OBFUSCATION_KEY + date).encode("utf-8") + + +def decode_data(hex_data: str): + encrypted = bytes.fromhex(hex_data) + key = get_obfuscation_key() + decrypted = xor_buffer(encrypted, key) + return json.loads(decrypted.decode("utf-8")) + + +def fetch_daily() -> dict: + url = f"{API_URL}/today" + response = requests.get(url) + response.raise_for_status() + return response.json() + + +def download_random_segment_mp3(youtube_id: str, output_file="today.mp3") -> str: + url = f"https://www.youtube.com/watch?v={youtube_id}" + with yt_dlp.YoutubeDL({"quiet": True}) as ydl: + info = ydl.extract_info(url, download=False) + duration = info.get("duration", 60) + start = 0 if duration <= 17 else random.randint(0, duration - 17) + ydl_opts = { + "format": "bestaudio/best", + "outtmpl": "today.%(ext)s", + "quiet": True, + "download_ranges": lambda info, _: [ + {"start_time": start, "end_time": start + 17} + ], + "force_keyframes_at_cuts": True, + "postprocessors": [ + { + "key": "FFmpegExtractAudio", + "preferredcodec": "mp3", + "preferredquality": "192", + } + ], + } + + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + ydl.download([url]) + return output_file + + +def upload_to_r2(file_path: str, object_key: str): + s3 = boto3.client( + "s3", + endpoint_url=f"https://{ACCOUNT_ID}.r2.cloudflarestorage.com", + aws_access_key_id=ACCESS_KEY, + aws_secret_access_key=SECRET_KEY, + region_name="auto", + ) + s3.upload_file(file_path, BUCKET, object_key) + + +def main(): + daily_data = fetch_daily() + data = decode_data(daily_data["data"]) + print(data) + youtube_id = data["youtubeId"] + clip_path = download_random_segment_mp3(youtube_id) + date = datetime.now(timezone.utc).strftime("%Y-%m-%d") + upload_to_r2(clip_path, f"kheardle/{date}.mp3") + +if __name__ == "__main__": + main() diff --git a/playlist_generator/playlist_generator.py b/playlist_generator/playlist_generator.py index c937a87..1bcc0b7 100644 --- a/playlist_generator/playlist_generator.py +++ b/playlist_generator/playlist_generator.py @@ -1,5 +1,4 @@ #!/usr/bin/python3 -# Glitch has Python 3.7.10 installed import argparse from random import shuffle import yt_dlp diff --git a/playlist_generator/pyproject.toml b/playlist_generator/pyproject.toml new file mode 100644 index 0000000..5ac1b1f --- /dev/null +++ b/playlist_generator/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "playlist-generator" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "boto3>=1.43.22", + "python-dotenv>=1.2.2", + "requests>=2.34.2", + "yt-dlp>=2026.3.17", +] diff --git a/playlist_generator/uv.lock b/playlist_generator/uv.lock new file mode 100644 index 0000000..4583031 --- /dev/null +++ b/playlist_generator/uv.lock @@ -0,0 +1,209 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[[package]] +name = "boto3" +version = "1.43.22" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/31/32388d5ec332ffe81d8f3650860f94b66294009172d188c390cad58c6f5f/boto3-1.43.22.tar.gz", hash = "sha256:2a7fe12d8e0731bb8aa7c1e59b4ccc770fda031b8659c2f6f497393bdcec3051", size = 113203, upload-time = "2026-06-03T19:33:13.39Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/6b/c2fb3b91e849882df5426e68cc15eb2b5ba6ac28325ab2aa3a1065da5884/boto3-1.43.22-py3-none-any.whl", hash = "sha256:0597fb9fe1613e636ac55219a5a54ad0fcb7c15e6be32c799301f7fb53ff04e1", size = 140536, upload-time = "2026-06-03T19:33:10.939Z" }, +] + +[[package]] +name = "botocore" +version = "1.43.22" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/cf/840f1b8db16d45e3807c23d1ea779723eed1cd9cf3b6c49e16f372d2a777/botocore-1.43.22.tar.gz", hash = "sha256:b00de525e538289ed4a7a85263f1be4e47473c124cec87be6b23be49356bf745", size = 15458781, upload-time = "2026-06-03T19:33:02.882Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/6b/576a1b0f915871e35f14a33104f2bcae635f19c6a72486ba639db0d1fc70/botocore-1.43.22-py3-none-any.whl", hash = "sha256:ceec9f81d0891abe7b28ca2b2ee47e32de7b3360ad11e80d351470f015217379", size = 15141375, upload-time = "2026-06-03T19:32:58.324Z" }, +] + +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + +[[package]] +name = "playlist-generator" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "boto3" }, + { name = "python-dotenv" }, + { name = "requests" }, + { name = "yt-dlp" }, +] + +[package.metadata] +requires-dist = [ + { name = "boto3", specifier = ">=1.43.22" }, + { name = "python-dotenv", specifier = ">=1.2.2" }, + { name = "requests", specifier = ">=2.34.2" }, + { name = "yt-dlp", specifier = ">=2026.3.17" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "s3transfer" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/1f/12417f7f493fc45e1f9fd5d4a9b6c125cf8d2cf3f8ddbdfab3e76406e9d6/s3transfer-0.18.0.tar.gz", hash = "sha256:3760b8b7ec1315da54048b2d626276732bee4300d054d492d4e1d43e20d4ecbd", size = 160560, upload-time = "2026-05-28T19:39:09.124Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/58/a58fc997655386daa2e25784e30c288aa3e3819e401f77029ee4899fb55a/s3transfer-0.18.0-py3-none-any.whl", hash = "sha256:239c13b09e65ad0346e1be7348b8a202dcad44ac7ea7c6eb858fc881dce739b6", size = 88572, upload-time = "2026-05-28T19:39:07.999Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "yt-dlp" +version = "2026.3.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8b/34/7c6b4e3f89cb6416d2cd7ab6dab141a1df97ab0fb22d15816db2c92148c9/yt_dlp-2026.3.17.tar.gz", hash = "sha256:ba7aa31d533f1ffccfe70e421596d7ca8ff0bf1398dc6bb658b7d9dec057d2c9", size = 3119221, upload-time = "2026-03-17T23:43:00.244Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/13/5093bcb954878e50f7217fd2ab94282b53934022e4e4a03265582da83bf5/yt_dlp-2026.3.17-py3-none-any.whl", hash = "sha256:32992db94303a8a5d211a183f2174834fe7f8c29d83ed2e7a324eae97a8f26d8", size = 3315134, upload-time = "2026-03-17T23:42:57.863Z" }, +] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a3a79c..8c92d97 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,6 +28,9 @@ dependencies: '@types/react-dom': specifier: 17.0.14 version: 17.0.14 + '@types/react-router-dom': + specifier: ^5 + version: 5.3.3 cors: specifier: ^2.8.6 version: 2.8.6 @@ -55,6 +58,9 @@ dependencies: react-is: specifier: ^18.2.0 version: 18.3.1 + react-router-dom: + specifier: ^6 + version: 6.30.4(react-dom@17.0.2)(react@17.0.2) react-youtube: specifier: ^7.14.0 version: 7.14.0(react@17.0.2) @@ -783,6 +789,11 @@ packages: resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} dev: true + /@remix-run/router@1.23.3: + resolution: {integrity: sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==} + engines: {node: '>=14.0.0'} + dev: false + /@rolldown/binding-android-arm64@1.0.3: resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1243,6 +1254,10 @@ packages: '@types/serve-static': 2.2.0 dev: true + /@types/history@4.7.11: + resolution: {integrity: sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==} + dev: false + /@types/hoist-non-react-statics@3.3.7(@types/react@17.0.93): resolution: {integrity: sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==} peerDependencies: @@ -1297,6 +1312,21 @@ packages: '@types/react': 17.0.93 dev: false + /@types/react-router-dom@5.3.3: + resolution: {integrity: sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==} + dependencies: + '@types/history': 4.7.11 + '@types/react': 17.0.93 + '@types/react-router': 5.1.20 + dev: false + + /@types/react-router@5.1.20: + resolution: {integrity: sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==} + dependencies: + '@types/history': 4.7.11 + '@types/react': 17.0.93 + dev: false + /@types/react@17.0.93: resolution: {integrity: sha512-KM4Ty/ZTLZupiYxZVAlP+InNJS3De6uBMdq0ePa6/04+eG9Y7ftnWfst1xTLQ5rwAhgHwQ4momt/O4KepdGBTw==} dependencies: @@ -4060,6 +4090,29 @@ packages: /react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + /react-router-dom@6.30.4(react-dom@17.0.2)(react@17.0.2): + resolution: {integrity: sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + dependencies: + '@remix-run/router': 1.23.3 + react: 17.0.2 + react-dom: 17.0.2(react@17.0.2) + react-router: 6.30.4(react@17.0.2) + dev: false + + /react-router@6.30.4(react@17.0.2): + resolution: {integrity: sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + dependencies: + '@remix-run/router': 1.23.3 + react: 17.0.2 + dev: false + /react-youtube@7.14.0(react@17.0.2): resolution: {integrity: sha512-SUHZ4F4pd1EHmQu0CV0KSQvAs5KHOT5cfYaq4WLCcDbU8fBo1ouTXaAOIASWbrz8fHwg+G1evfoSIYpV2AwSAg==} engines: {node: '>= 10.x'} diff --git a/server/data/songs.ts b/server/data/songs.ts index 08e9230..7a1ebf8 100644 --- a/server/data/songs.ts +++ b/server/data/songs.ts @@ -422,7 +422,6 @@ export const songs = [ { artist: "aespa", name: "Flights, Not Feelings", youtubeId: "Qe7FP1abS5s" }, { artist: "LE SSERAFIM", name: "HOT", youtubeId: "cCkAcVOS3ig" }, { artist: "i-dle", name: "Allergy", youtubeId: "Gcp87-ZegRA" }, - { artist: "NewJeans", name: "Get Up", youtubeId: "SXM1q0CTfew" }, { artist: "9MUSES", name: "Dolls", youtubeId: "QM58UGunHKY" }, { artist: "Red Velvet", name: "Cosmic", youtubeId: "46FxItq18h0" }, { artist: "ILLIT", name: "Midnight Fiction", youtubeId: "Sr7dWdf4Z3U" }, diff --git a/server/index.ts b/server/index.ts index c78352a..54b3c18 100644 --- a/server/index.ts +++ b/server/index.ts @@ -25,10 +25,32 @@ function xorBuffer(data: Buffer, key: Buffer): Buffer { return output; } +function getUtcDate(): string { + return new Date().toISOString().slice(0, 10); +} +function hashString(str: string): number { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = (hash * 31 + str.charCodeAt(i)) >>> 0; + } + return hash; +} + app.get('/today', (_req, res) => { - const msInDay = 86_400_000; - const index = Math.floor((Date.now() - startDate.getTime()) / msInDay); - const song = songs[index % songs.length]; + const date = getUtcDate(); + const seed = hashString(date); + const index = seed % songs.length; + const song = songs[index]; + const obfuscationKey = getObfuscationKey(); + const songJson = JSON.stringify(song); + const obfuscatedData = xorBuffer(Buffer.from(songJson, 'utf8'), obfuscationKey); + res.json({ + data: obfuscatedData.toString('hex'), + }); +}); + +app.get('/select', (_req, res) => { + const song = songs[Math.floor(Math.random() * songs.length)]; const obfuscationKey = getObfuscationKey(); const songJson = JSON.stringify(song); const obfuscatedData = xorBuffer(Buffer.from(songJson, 'utf8'), obfuscationKey); diff --git a/src/app.tsx b/src/app.tsx index e9a41ef..044cbfc 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,234 +1,19 @@ import React from "react"; -import _ from "lodash"; +import { BrowserRouter, Routes, Route } from "react-router-dom"; -import { Song } from "./types/song"; -import { GuessState, GuessType } from "./types/guess"; -import { getDailySolution } from "./helpers/fetchSolution"; - -import { Header, InfoPopUp, Game, Footer } from "./components"; - -import * as Styled from "./app.styled"; +import { LandingPage } from "./pages/LandingPage"; +import { DailyPage } from "./pages/DailyPage"; +import { UnlimitedPage } from "./pages/UnlimitedPage"; function App() { - const initialGuess = { - song: undefined, - state: undefined, - } as GuessType; - - const [guesses, setGuesses] = React.useState<GuessType[]>( - Array.from({ length: 6 }).fill(initialGuess) as GuessType[] - ); - const [currentTry, setCurrentTry] = React.useState<number>(0); - const [selectedSong, setSelectedSong] = React.useState<Song>(); - const [didGuess, setDidGuess] = React.useState<boolean>(false); - const [todaysSolution, setTodaysSolution] = React.useState<Song | null>(null); - - const firstRun = localStorage.getItem("firstRun") === null; - - function reloadWithoutQueryParameters() { - location.replace(location.pathname); - } - const urlHash = window.location.hash; - const urlQueryParametersStart = urlHash.indexOf("?"); - const statsImportQueryParameter = - new URLSearchParams(urlHash.substring(urlQueryParametersStart)).get( - "statsImport" - ) || ""; - function importStats() { - if (statsImportQueryParameter) { - const importedStats = JSON.parse(statsImportQueryParameter); - if (Array.isArray(importedStats)) { - importedStats.forEach((day) => { - if (Array.isArray(day.guesses)) { - if (day.guesses.length == 5) { - day.guesses.push(initialGuess); - } - } - }); - } - localStorage.setItem("stats", JSON.stringify(importedStats)); - reloadWithoutQueryParameters(); - } - } - if (statsImportQueryParameter) { - if ( - confirm( - "Do you want to import your previous stats? This will overwrite any stats on this site." - ) - ) { - importStats(); - } else { - reloadWithoutQueryParameters(); - } - } - - let stats = JSON.parse(localStorage.getItem("stats") || "{}"); - let statsVersion = JSON.parse(localStorage.getItem("version") || "1"); - - React.useEffect(() => { - getDailySolution().then((solution) => setTodaysSolution(solution)); - }, []); - - React.useEffect(() => { - if (Array.isArray(stats)) { - const visitedToday = _.isEqual( - todaysSolution, - stats[stats.length - 1].solution - ); - - if (!visitedToday) { - stats.push({ - solution: todaysSolution, - currentTry: 0, - didGuess: 0, - }); - } else { - const { currentTry, guesses, didGuess } = stats[stats.length - 1]; - setCurrentTry(currentTry); - setGuesses(guesses); - setDidGuess(didGuess); - } - } else { - stats = []; - stats.push({ - solution: todaysSolution, - }); - } - const currentVersion = 2; - if (firstRun) { - statsVersion = currentVersion; - } else if (statsVersion < currentVersion) { - statsVersion = currentVersion; - if (Array.isArray(stats)) { - for (let index = 0; index < stats.length; index++) { - const newGuesses: GuessType[] = []; - for ( - let guessIndex = 0; - guessIndex < stats[index].guesses.length; - guessIndex++ - ) { - const guess = stats[index].guesses[guessIndex]; - if (guess.skipped !== undefined) { - let state = undefined; - if (guess.skipped) { - state = GuessState.Skipped; - } else if (guess.isCorrect) { - state = GuessState.Correct; - } else if (guess.isCorrect === false) { - state = GuessState.Incorrect; - } - newGuesses.push({ - song: guess.song, - state: state, - } as GuessType); - } - } - stats[index].guesses = newGuesses; - } - } - } - }, []); - - React.useEffect(() => { - if (Array.isArray(stats)) { - stats[stats.length - 1].currentTry = currentTry; - stats[stats.length - 1].didGuess = didGuess; - stats[stats.length - 1].guesses = guesses; - } - }), - [guesses, currentTry, didGuess]; - - React.useEffect(() => { - localStorage.setItem("stats", JSON.stringify(stats)); - }, [stats]); - - React.useEffect(() => { - localStorage.setItem("version", JSON.stringify(statsVersion)); - }, [statsVersion]); - - const [isInfoPopUpOpen, setIsInfoPopUpOpen] = - React.useState<boolean>(firstRun); - - const openInfoPopUp = React.useCallback(() => { - setIsInfoPopUpOpen(true); - }, []); - - const closeInfoPopUp = React.useCallback(() => { - if (firstRun) { - localStorage.setItem("firstRun", "false"); - setIsInfoPopUpOpen(false); - } else { - setIsInfoPopUpOpen(false); - } - }, [localStorage.getItem("firstRun")]); - - const skip = React.useCallback(() => { - setGuesses((guesses: GuessType[]) => { - const newGuesses = [...guesses]; - newGuesses[currentTry] = { - song: undefined, - state: GuessState.Skipped, - }; - - return newGuesses; - }); - - setCurrentTry((currentTry) => currentTry + 1); - }, [currentTry]); - - const guess = React.useCallback(() => { - let state = GuessState.Incorrect; - if (!selectedSong) return; - if (selectedSong?.artist === todaysSolution?.artist && selectedSong?.name === todaysSolution?.name) { - state = GuessState.Correct; - } else if (selectedSong?.artist === todaysSolution?.artist) { - state = GuessState.PartiallyCorrect; - } - - if (!selectedSong) { - alert("Choose a song"); - return; - } - - setGuesses((guesses: GuessType[]) => { - const newGuesses = [...guesses]; - newGuesses[currentTry] = { - song: selectedSong, - state: state, - }; - - return newGuesses; - }); - - setCurrentTry((currentTry) => currentTry + 1); - setSelectedSong(undefined); - - if (state === GuessState.Correct) { - setDidGuess(true); - } - }, [guesses, selectedSong]); - - if (todaysSolution === null) { - return null; - } - return ( - <main> - <Header openInfoPopUp={openInfoPopUp} /> - {isInfoPopUpOpen && <InfoPopUp onClose={closeInfoPopUp} />} - <Styled.Container> - <Game - guesses={guesses} - didGuess={didGuess} - todaysSolution={todaysSolution} - currentTry={currentTry} - setSelectedSong={setSelectedSong} - skip={skip} - guess={guess} - /> - </Styled.Container> - <Footer /> - </main> + <BrowserRouter> + <Routes> + <Route path="/" element={<LandingPage />} /> + <Route path="/daily" element={<DailyPage />} /> + <Route path="/unlimited" element={<UnlimitedPage />} /> + </Routes> + </BrowserRouter> ); } diff --git a/src/components/Game/index.tsx b/src/components/Game/index.tsx index 2f4a2ec..9024b03 100644 --- a/src/components/Game/index.tsx +++ b/src/components/Game/index.tsx @@ -4,7 +4,7 @@ import { GuessType } from "../../types/guess"; import { Song } from "../../types/song"; import { playTimes } from "../../constants"; -import { Button, Guess, Player, Search, Result } from "../"; +import { Button, Guess, YTPlayer, Search, Result, Player } from "../"; import * as Styled from "./index.styled"; @@ -16,6 +16,8 @@ interface Props { setSelectedSong: React.Dispatch<React.SetStateAction<Song | undefined>>; skip: () => void; guess: () => void; + mode?: "daily" | "unlimited"; + onPlayAgain?: () => void; } export function Game({ @@ -26,6 +28,8 @@ export function Game({ setSelectedSong, skip, guess, + mode = "daily", + onPlayAgain, }: Props) { if (didGuess || currentTry === 6) { return ( @@ -34,19 +38,22 @@ export function Game({ currentTry={currentTry} todaysSolution={todaysSolution} guesses={guesses} + mode={mode} + onPlayAgain={onPlayAgain} /> ); } return ( <> {guesses.map((guess: GuessType, index) => ( - <Guess - key={index} - guess={guess} - active={index === currentTry} - /> + <Guess key={index} guess={guess} active={index === currentTry} /> ))} - <Player id={todaysSolution.youtubeId} currentTry={currentTry} /> + {mode === "unlimited" ? ( + <YTPlayer id={todaysSolution.youtubeId} currentTry={currentTry} /> + ) : ( + <Player currentTry={currentTry} /> + )} + <Search currentTry={currentTry} setSelectedSong={setSelectedSong} /> <Styled.Buttons> diff --git a/src/components/Player/index.tsx b/src/components/Player/index.tsx index fcfce72..e4dfd9e 100644 --- a/src/components/Player/index.tsx +++ b/src/components/Player/index.tsx @@ -1,101 +1,142 @@ import React from "react"; -import YouTube from "react-youtube"; import { IoPlay, IoPause } from "react-icons/io5"; import { playTimes } from "../../constants"; - -import * as Styled from "./index.styled"; +import * as Styled from "../YTPlayer/index.styled"; interface Props { - id: string; currentTry: number; } -export function Player({ id, currentTry }: Props) { - const opts = { - width: "0", - height: "0", - }; +const MAX_TIME = 16; - // react-youtube doesn't export types for this - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const playerRef = React.useRef<any>(null); +export function Player({ currentTry }: Props) { + const audioRef = React.useRef<HTMLAudioElement | null>(null); const currentPlayTime = playTimes[currentTry]; - const [play, setPlay] = React.useState<boolean>(false); + const [play, setPlay] = React.useState(false); + const [currentTime, setCurrentTime] = React.useState(0); + const [isReady, setIsReady] = React.useState(false); - const [currentTime, setCurrentTime] = React.useState<number>(0); + const CDN_URL = + import.meta.env.VITE_CDN_URL || "https://yena.pinapelz.com/kheardle"; - const [isReady, setIsReady] = React.useState<boolean>(false); + const dateString = new Date().toISOString().split("T")[0]; - React.useEffect(() => { - setInterval(() => { - playerRef.current?.internalPlayer - .getCurrentTime() - .then((time: number) => { - setCurrentTime(time); - }); - }, 250); + const startPlayback = React.useCallback(() => { + const audio = audioRef.current; + if (!audio) return; + + audio.play(); + setPlay(true); + }, []); + + const stopPlayback = React.useCallback(() => { + const audio = audioRef.current; + if (!audio) return; + + audio.pause(); + audio.currentTime = 0; + setPlay(false); }, []); React.useEffect(() => { - if (play) { - if (currentTime * 1000 >= currentPlayTime) { - playerRef.current?.internalPlayer.pauseVideo(); - playerRef.current?.internalPlayer.seekTo(0); + const audio = new Audio(`${CDN_URL}/${dateString}.mp3`); + audioRef.current = audio; + + audio.addEventListener("loadeddata", () => { + setIsReady(true); + }); + + audio.addEventListener("timeupdate", () => { + setCurrentTime(audio.currentTime); + }); + + audio.addEventListener("ended", () => { + setPlay(false); + audio.currentTime = 0; + }); + + return () => { + audio.pause(); + audio.src = ""; + }; + }, [dateString]); + + React.useEffect(() => { + if (!play || !audioRef.current) return; + + const interval = setInterval(() => { + const a = audioRef.current!; + const t = a.currentTime * 1000; + + setCurrentTime(a.currentTime); + + if (t >= currentPlayTime || t >= MAX_TIME * 1000) { + a.pause(); + a.currentTime = 0; setPlay(false); } - } - }, [play, currentTime]); + }, 100); - // don't call play video each time currentTime changes - const startPlayback = React.useCallback(() => { - playerRef.current?.internalPlayer.playVideo(); - setPlay(true); - }, []); + return () => clearInterval(interval); + }, [play, currentPlayTime]); + + React.useEffect(() => { + if (!("mediaSession" in navigator)) return; + + navigator.mediaSession.setActionHandler("play", () => undefined); + navigator.mediaSession.setActionHandler("pause", () => undefined); + navigator.mediaSession.setActionHandler("previoustrack", () => undefined); + navigator.mediaSession.setActionHandler("nexttrack", () => undefined); - const setReady = React.useCallback(() => { - setIsReady(true); + return () => { + navigator.mediaSession.setActionHandler("play", null); + navigator.mediaSession.setActionHandler("pause", null); + navigator.mediaSession.setActionHandler("previoustrack", null); + navigator.mediaSession.setActionHandler("nexttrack", null); + }; }, []); return ( <> - <YouTube opts={opts} videoId={id} onReady={setReady} ref={playerRef} /> {isReady ? ( <> <Styled.ProgressBackground> - {currentTime !== 0 && <Styled.Progress value={currentTime} />} - {playTimes.map((playTime) => ( + {currentTime !== 0 && ( + <Styled.Progress value={currentTime} /> + )} + + {playTimes.map((t) => ( <Styled.Separator - style={{ left: `${(playTime / 16000) * 100}%` }} - key={playTime} + key={t} + style={{ left: `${(t / 16000) * 100}%` }} /> ))} </Styled.ProgressBackground> + <Styled.TimeStamps> <Styled.TimeStamp>1s</Styled.TimeStamp> <Styled.TimeStamp>16s</Styled.TimeStamp> </Styled.TimeStamps> - {!play && ( + + {!play ? ( <IoPlay style={{ cursor: "pointer" }} size={36} - color="var(--cl-green-6)" onClick={startPlayback} /> - )} - {play && ( + ) : ( <IoPause style={{ cursor: "pointer" }} size={36} - color="var(--cl-green-6)" - onClick={startPlayback} + onClick={stopPlayback} /> )} </> ) : ( - <p>Loading player...</p> + <p>Loading audio...</p> )} </> ); diff --git a/src/components/Result/index.tsx b/src/components/Result/index.tsx index 19f9386..6b4560a 100644 --- a/src/components/Result/index.tsx +++ b/src/components/Result/index.tsx @@ -14,17 +14,19 @@ interface SolutionProps { didGuess: boolean; currentTry: number; todaysSolution: Song; + isUnlimited?: boolean; } function Solution({ didGuess, todaysSolution, currentTry, + isUnlimited, }: SolutionProps) { return ( <> <Styled.SongTitle> - Today's song is {todaysSolution.artist} - {todaysSolution.name} + {isUnlimited ? "The song was" : "Today's song is"} {todaysSolution.artist} - {todaysSolution.name} </Styled.SongTitle> {didGuess && ( @@ -86,6 +88,8 @@ interface Props { currentTry: number; todaysSolution: Song; guesses: GuessType[]; + mode?: "daily" | "unlimited"; + onPlayAgain?: () => void; } export function Result({ @@ -93,6 +97,8 @@ export function Result({ todaysSolution, guesses, currentTry, + mode = "daily", + onPlayAgain, }: Props) { const hoursToNextDay = Math.floor( (new Date(new Date().setHours(24, 0, 0, 0)).getTime() - @@ -102,6 +108,8 @@ export function Result({ 60 ); + const isUnlimited = mode === "unlimited"; + if (didGuess) { const textForTry = ["Perfect!", "Wow!", "Super!", "Congrats!", "Nice!"]; @@ -113,13 +121,20 @@ export function Result({ todaysSolution={todaysSolution} didGuess={didGuess} currentTry={currentTry} + isUnlimited={isUnlimited} /> - <ShareButton guesses={guesses} variant="green" /> + {!isUnlimited && <ShareButton guesses={guesses} variant="green" />} - <Styled.TimeToNext> - Remember to come back in {hoursToNextDay} hours! - </Styled.TimeToNext> + {isUnlimited && onPlayAgain ? ( + <Button variant="green" onClick={onPlayAgain}> + Play Again + </Button> + ) : ( + <Styled.TimeToNext> + Remember to come back in {hoursToNextDay} hours! + </Styled.TimeToNext> + )} </> ); } @@ -132,13 +147,20 @@ export function Result({ todaysSolution={todaysSolution} didGuess={didGuess} currentTry={currentTry} + isUnlimited={isUnlimited} /> - <ShareButton guesses={guesses} variant="red" /> + {!isUnlimited && <ShareButton guesses={guesses} variant="red" />} - <Styled.TimeToNext> - Try again in {hoursToNextDay} hours. - </Styled.TimeToNext> + {isUnlimited && onPlayAgain ? ( + <Button variant="red" onClick={onPlayAgain}> + Play Again + </Button> + ) : ( + <Styled.TimeToNext> + Try again in {hoursToNextDay} hours. + </Styled.TimeToNext> + )} </> ); } diff --git a/src/components/Player/index.styled.ts b/src/components/YTPlayer/index.styled.ts index 3c98f1e..3c98f1e 100644 --- a/src/components/Player/index.styled.ts +++ b/src/components/YTPlayer/index.styled.ts diff --git a/src/components/YTPlayer/index.tsx b/src/components/YTPlayer/index.tsx new file mode 100644 index 0000000..1aac9ac --- /dev/null +++ b/src/components/YTPlayer/index.tsx @@ -0,0 +1,100 @@ +import React from "react"; +import YouTube from "react-youtube"; +import { IoPlay, IoPause } from "react-icons/io5"; +import { playTimes } from "../../constants"; +import * as Styled from "./index.styled"; + +interface Props { + id: string; + currentTry: number; +} + +export function Player({ id, currentTry }: Props) { + const opts = { + width: "0", + height: "0", + }; + + // react-youtube doesn't export types for this + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const playerRef = React.useRef<any>(null); + + const currentPlayTime = playTimes[currentTry]; + + const [play, setPlay] = React.useState<boolean>(false); + + const [currentTime, setCurrentTime] = React.useState<number>(0); + + const [isReady, setIsReady] = React.useState<boolean>(false); + + React.useEffect(() => { + setInterval(() => { + playerRef.current?.internalPlayer + .getCurrentTime() + .then((time: number) => { + setCurrentTime(time); + }); + }, 250); + }, []); + + React.useEffect(() => { + if (play) { + if (currentTime * 1000 >= currentPlayTime) { + playerRef.current?.internalPlayer.pauseVideo(); + playerRef.current?.internalPlayer.seekTo(0); + setPlay(false); + } + } + }, [play, currentTime]); + + // don't call play video each time currentTime changes + const startPlayback = React.useCallback(() => { + playerRef.current?.internalPlayer.playVideo(); + setPlay(true); + }, []); + + const setReady = React.useCallback(() => { + setIsReady(true); + }, []); + + return ( + <> + <YouTube opts={opts} videoId={id} onReady={setReady} ref={playerRef} /> + {isReady ? ( + <> + <Styled.ProgressBackground> + {currentTime !== 0 && <Styled.Progress value={currentTime} />} + {playTimes.map((playTime) => ( + <Styled.Separator + style={{ left: `${(playTime / 16000) * 100}%` }} + key={playTime} + /> + ))} + </Styled.ProgressBackground> + <Styled.TimeStamps> + <Styled.TimeStamp>1s</Styled.TimeStamp> + <Styled.TimeStamp>16s</Styled.TimeStamp> + </Styled.TimeStamps> + {!play && ( + <IoPlay + style={{ cursor: "pointer" }} + size={36} + color="var(--cl-green-6)" + onClick={startPlayback} + /> + )} + {play && ( + <IoPause + style={{ cursor: "pointer" }} + size={36} + color="var(--cl-green-6)" + onClick={startPlayback} + /> + )} + </> + ) : ( + <p>Loading player...</p> + )} + </> + ); +} diff --git a/src/components/index.ts b/src/components/index.ts index 0e97ada..4264a1c 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -5,6 +5,7 @@ export { Guess } from "./Guess"; export { Header } from "./Header"; export { InfoPopUp } from "./InfoPopUp"; export { MiniYouTubePlayer} from "./MiniYouTubePlayer"; +export { Player as YTPlayer } from "./YTPlayer"; export { Player } from "./Player"; export { Result } from "./Result"; export { Search } from "./Search"; diff --git a/src/helpers/fetchSolution.ts b/src/helpers/fetchSolution.ts index 2ca2e68..10c4fa1 100644 --- a/src/helpers/fetchSolution.ts +++ b/src/helpers/fetchSolution.ts @@ -25,14 +25,27 @@ function getObfuscationKey(): Uint8Array { return new TextEncoder().encode(SALT + date); } +function decryptResponse(data: string): Song { + const obfuscationKey = getObfuscationKey(); + const obfuscatedBytes = hexToBytes(data); + const decrypted = xor(obfuscatedBytes, obfuscationKey); + return JSON.parse(new TextDecoder().decode(decrypted)) as Song; +} + export async function getDailySolution(): Promise<Song> { const solutionData = await fetch(`${API_URL}/today`); if (!solutionData.ok) { throw new Error(`Failed to fetch solution: ${solutionData.statusText}`); } const { data } = await solutionData.json(); - const obfuscationKey = getObfuscationKey(); - const obfuscatedBytes = hexToBytes(data); - const decrypted = xor(obfuscatedBytes, obfuscationKey); - return JSON.parse(new TextDecoder().decode(decrypted)) as Song; + return decryptResponse(data); +} + +export async function getSelectSolution(): Promise<Song> { + const solutionData = await fetch(`${API_URL}/select`); + if (!solutionData.ok) { + throw new Error(`Failed to fetch solution: ${solutionData.statusText}`); + } + const { data } = await solutionData.json(); + return decryptResponse(data); } diff --git a/src/hooks/useGameState.ts b/src/hooks/useGameState.ts new file mode 100644 index 0000000..5c419a1 --- /dev/null +++ b/src/hooks/useGameState.ts @@ -0,0 +1,173 @@ +import React from "react"; +import _ from "lodash"; + +import { Song } from "../types/song"; +import { GuessState, GuessType } from "../types/guess"; + +interface UseGameStateOptions { + solution: Song | null; + persist: boolean; +} + +const initialGuess: GuessType = { + song: undefined, + state: undefined, +}; + +export function useGameState({ solution, persist }: UseGameStateOptions) { + const [guesses, setGuesses] = React.useState<GuessType[]>( + Array.from({ length: 6 }).fill(initialGuess) as GuessType[] + ); + const [currentTry, setCurrentTry] = React.useState<number>(0); + const [selectedSong, setSelectedSong] = React.useState<Song>(); + const [didGuess, setDidGuess] = React.useState<boolean>(false); + + // --- localStorage persistence (daily mode) --- + let stats = JSON.parse(localStorage.getItem("stats") || "{}"); + let statsVersion = JSON.parse(localStorage.getItem("version") || "1"); + + React.useEffect(() => { + if (!persist || !solution) return; + + if (Array.isArray(stats)) { + const visitedToday = _.isEqual( + solution, + stats[stats.length - 1].solution + ); + + if (!visitedToday) { + stats.push({ + solution: solution, + currentTry: 0, + didGuess: 0, + }); + } else { + const { currentTry, guesses, didGuess } = stats[stats.length - 1]; + setCurrentTry(currentTry); + setGuesses(guesses); + setDidGuess(didGuess); + } + } else { + stats = []; + stats.push({ + solution: solution, + }); + } + + const currentVersion = 2; + const firstRun = localStorage.getItem("firstRun") === null; + if (firstRun) { + statsVersion = currentVersion; + } else if (statsVersion < currentVersion) { + statsVersion = currentVersion; + if (Array.isArray(stats)) { + for (let index = 0; index < stats.length; index++) { + const newGuesses: GuessType[] = []; + for ( + let guessIndex = 0; + guessIndex < stats[index].guesses.length; + guessIndex++ + ) { + const guess = stats[index].guesses[guessIndex]; + if (guess.skipped !== undefined) { + let state = undefined; + if (guess.skipped) { + state = GuessState.Skipped; + } else if (guess.isCorrect) { + state = GuessState.Correct; + } else if (guess.isCorrect === false) { + state = GuessState.Incorrect; + } + newGuesses.push({ + song: guess.song, + state: state, + } as GuessType); + } + } + stats[index].guesses = newGuesses; + } + } + } + }, [solution]); + + React.useEffect(() => { + if (!persist) return; + if (Array.isArray(stats)) { + stats[stats.length - 1].currentTry = currentTry; + stats[stats.length - 1].didGuess = didGuess; + stats[stats.length - 1].guesses = guesses; + } + }, [guesses, currentTry, didGuess]); + + React.useEffect(() => { + if (!persist) return; + localStorage.setItem("stats", JSON.stringify(stats)); + }, [stats]); + + React.useEffect(() => { + if (!persist) return; + localStorage.setItem("version", JSON.stringify(statsVersion)); + }, [statsVersion]); + + const skip = React.useCallback(() => { + setGuesses((guesses: GuessType[]) => { + const newGuesses = [...guesses]; + newGuesses[currentTry] = { + song: undefined, + state: GuessState.Skipped, + }; + return newGuesses; + }); + setCurrentTry((currentTry) => currentTry + 1); + }, [currentTry]); + + const guess = React.useCallback(() => { + if (!selectedSong || !solution) return; + + let state = GuessState.Incorrect; + if ( + selectedSong.artist === solution.artist && + selectedSong.name === solution.name + ) { + state = GuessState.Correct; + } else if (selectedSong.artist === solution.artist) { + state = GuessState.PartiallyCorrect; + } + + setGuesses((guesses: GuessType[]) => { + const newGuesses = [...guesses]; + newGuesses[currentTry] = { + song: selectedSong, + state: state, + }; + return newGuesses; + }); + + setCurrentTry((currentTry) => currentTry + 1); + setSelectedSong(undefined); + + if (state === GuessState.Correct) { + setDidGuess(true); + } + }, [guesses, selectedSong, solution]); + + const reset = React.useCallback(() => { + setGuesses( + Array.from({ length: 6 }).fill(initialGuess) as GuessType[] + ); + setCurrentTry(0); + setSelectedSong(undefined); + setDidGuess(false); + }, []); + + return { + guesses, + currentTry, + selectedSong, + setSelectedSong, + didGuess, + skip, + guess, + reset, + }; +} diff --git a/src/pages/DailyPage.tsx b/src/pages/DailyPage.tsx new file mode 100644 index 0000000..5033366 --- /dev/null +++ b/src/pages/DailyPage.tsx @@ -0,0 +1,111 @@ +import React from "react"; + +import { Song } from "../types/song"; +import { GuessType } from "../types/guess"; +import { getDailySolution } from "../helpers/fetchSolution"; +import { useGameState } from "../hooks/useGameState"; + +import { Header, InfoPopUp, Game, Footer } from "../components"; + +import * as Styled from "../app.styled"; + +export function DailyPage() { + const [todaysSolution, setTodaysSolution] = React.useState<Song | null>(null); + + const firstRun = localStorage.getItem("firstRun") === null; + + const initialGuess = { + song: undefined, + state: undefined, + } as GuessType; + + // --- Stats import logic --- + function reloadWithoutQueryParameters() { + location.replace(location.pathname); + } + const urlHash = window.location.hash; + const urlQueryParametersStart = urlHash.indexOf("?"); + const statsImportQueryParameter = + new URLSearchParams(urlHash.substring(urlQueryParametersStart)).get( + "statsImport" + ) || ""; + + function importStats() { + if (statsImportQueryParameter) { + const importedStats = JSON.parse(statsImportQueryParameter); + if (Array.isArray(importedStats)) { + importedStats.forEach((day) => { + if (Array.isArray(day.guesses)) { + if (day.guesses.length == 5) { + day.guesses.push(initialGuess); + } + } + }); + } + localStorage.setItem("stats", JSON.stringify(importedStats)); + reloadWithoutQueryParameters(); + } + } + + if (statsImportQueryParameter) { + if ( + confirm( + "Do you want to import your previous stats? This will overwrite any stats on this site." + ) + ) { + importStats(); + } else { + reloadWithoutQueryParameters(); + } + } + + React.useEffect(() => { + getDailySolution().then((solution) => setTodaysSolution(solution)); + }, []); + + const { + guesses, + currentTry, + setSelectedSong, + didGuess, + skip, + guess, + } = useGameState({ solution: todaysSolution, persist: true }); + + const [isInfoPopUpOpen, setIsInfoPopUpOpen] = + React.useState<boolean>(firstRun); + + const openInfoPopUp = React.useCallback(() => { + setIsInfoPopUpOpen(true); + }, []); + + const closeInfoPopUp = React.useCallback(() => { + if (firstRun) { + localStorage.setItem("firstRun", "false"); + } + setIsInfoPopUpOpen(false); + }, [localStorage.getItem("firstRun")]); + + if (todaysSolution === null) { + return null; + } + + return ( + <main> + <Header openInfoPopUp={openInfoPopUp} /> + {isInfoPopUpOpen && <InfoPopUp onClose={closeInfoPopUp} />} + <Styled.Container> + <Game + guesses={guesses} + didGuess={didGuess} + todaysSolution={todaysSolution} + currentTry={currentTry} + setSelectedSong={setSelectedSong} + skip={skip} + guess={guess} + /> + </Styled.Container> + <Footer /> + </main> + ); +} diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx new file mode 100644 index 0000000..e1d29cd --- /dev/null +++ b/src/pages/LandingPage.tsx @@ -0,0 +1,92 @@ +import React from "react"; +import { useNavigate } from "react-router-dom"; +import styled from "styled-components"; + +import { appName } from "../constants"; + +const Container = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 60vh; + gap: 24px; +`; + +const Title = styled.h1` + font-family: "Roboto Mono", monospace; + font-size: 2rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--cl-white); +`; + +const Subtitle = styled.p` + font-family: "Roboto Mono", monospace; + font-size: 0.9rem; + color: var(--cl-gray-6); + margin: 0; +`; + +const ButtonGroup = styled.div` + display: flex; + gap: 16px; + margin-top: 16px; + + @media (max-width: 480px) { + flex-direction: column; + width: 100%; + padding: 0 24px; + } +`; + +const ModeButton = styled.button<{ variant?: "green" | "purple" }>` + font-family: "Roboto Mono", monospace; + font-size: 1rem; + font-weight: 600; + padding: 16px 32px; + border: 2px solid + ${({ variant }) => + variant === "purple" ? "var(--cl-purple, #a855f7)" : "var(--cl-green-6)"}; + background: transparent; + color: ${({ variant }) => + variant === "purple" ? "var(--cl-purple, #a855f7)" : "var(--cl-green-6)"}; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + background: ${({ variant }) => + variant === "purple" ? "var(--cl-purple, #a855f7)" : "var(--cl-green-6)"}; + color: var(--cl-black, #000); + } +`; + +const ModeDescription = styled.span` + display: block; + font-size: 0.7rem; + font-weight: 400; + color: var(--cl-gray-6); + margin-top: 4px; +`; + +export function LandingPage() { + const navigate = useNavigate(); + + return ( + <Container> + <Title>{appName}</Title> + <Subtitle>Choose a game mode</Subtitle> + <ButtonGroup> + <ModeButton onClick={() => navigate("/daily")}> + Daily + <ModeDescription>One song per day</ModeDescription> + </ModeButton> + <ModeButton variant="purple" onClick={() => navigate("/unlimited")}> + Unlimited + <ModeDescription>Endless songs, no limits</ModeDescription> + </ModeButton> + </ButtonGroup> + </Container> + ); +} diff --git a/src/pages/UnlimitedPage.tsx b/src/pages/UnlimitedPage.tsx new file mode 100644 index 0000000..761d2b9 --- /dev/null +++ b/src/pages/UnlimitedPage.tsx @@ -0,0 +1,78 @@ +import React from "react"; + +import { Song } from "../types/song"; +import { getSelectSolution } from "../helpers/fetchSolution"; +import { useGameState } from "../hooks/useGameState"; + +import { Header, InfoPopUp, Game, Footer } from "../components"; + +import * as Styled from "../app.styled"; + +export function UnlimitedPage() { + const [solution, setSolution] = React.useState<Song | null>(null); + + const firstRun = localStorage.getItem("firstRun") === null; + + function fetchNewSong() { + setSolution(null); + getSelectSolution().then((s) => setSolution(s)); + } + + React.useEffect(() => { + fetchNewSong(); + }, []); + + const { + guesses, + currentTry, + setSelectedSong, + didGuess, + skip, + guess, + reset, + } = useGameState({ solution, persist: false }); + + const playAgain = React.useCallback(() => { + reset(); + fetchNewSong(); + }, [reset]); + + const [isInfoPopUpOpen, setIsInfoPopUpOpen] = + React.useState<boolean>(firstRun); + + const openInfoPopUp = React.useCallback(() => { + setIsInfoPopUpOpen(true); + }, []); + + const closeInfoPopUp = React.useCallback(() => { + if (firstRun) { + localStorage.setItem("firstRun", "false"); + } + setIsInfoPopUpOpen(false); + }, [localStorage.getItem("firstRun")]); + + if (solution === null) { + return null; + } + + return ( + <main> + <Header openInfoPopUp={openInfoPopUp} /> + {isInfoPopUpOpen && <InfoPopUp onClose={closeInfoPopUp} />} + <Styled.Container> + <Game + guesses={guesses} + didGuess={didGuess} + todaysSolution={solution} + currentTry={currentTry} + setSelectedSong={setSelectedSong} + skip={skip} + guess={guess} + mode="unlimited" + onPlayAgain={playAgain} + /> + </Styled.Container> + <Footer /> + </main> + ); +} |
