Skip to content

Commit 114b932

Browse files
authored
Merge pull request #4 from tameTNT/heardle-v2-improvements
Heardle v2 improvements
2 parents fed55ea + cbe3dbb commit 114b932

24 files changed

+608
-240
lines changed

README.md

Lines changed: 0 additions & 16 deletions
This file was deleted.

heardle_server/README.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# LOONA Heardle
2+
3+
## Description
4+
5+
A variation on the original [Heardle game](https://heardlewordle.io/) (itself
6+
inspired by the word guessing game Wordle) where the player has to guess a song
7+
within 6 tries. The amount of the song played increases with each guess up to a
8+
full 30 seconds of the track for the final guess. This variation plays only
9+
songs from the **K-Pop group [LOONA](https://en.wikipedia.org/wiki/Loona)**
10+
(including all subunits, solo, and drama OST tracks). A different song is
11+
selected each day at midnight UTC.
12+
13+
The page is optimised for mobile and desktop devices and includes light and dark
14+
modes for comfortable use.
15+
16+
## Deployment
17+
18+
The game is hosted for anyone to play on https://51.pegasib.dev/links/heardle.
19+
20+
## Technical Implementation
21+
22+
### Songs
23+
24+
The songs used for the game are extracted from
25+
[a Spotify playlist](https://open.spotify.com/playlist/05bRCDfqjNVnysz17hocZn)
26+
by the script `update_track_info.py` which should be run whenever the original
27+
playlist is updated. This script uses the Spotify API (via
28+
[`spotipy`](https://spotipy.readthedocs.io/en/master/)) to fetch information for
29+
each track and stores it in a JSON file (`heardle_track_info.json`) alongside
30+
the additionally collected preview url of the track (not provided by the API).
31+
32+
### Frameworks
33+
34+
The client/server is implemented using the [Fresh](https://fresh.deno.dev/)
35+
framework (built around [Preact](https://preactjs.com/)) and run using the
36+
[Deno](https://deno.com/) JavaScript runtime. Styling is done using
37+
[Tailwind CSS](https://tailwindcss.com/).
38+
39+
Frontend components are located in `/components` which are used to build islands
40+
in `/islands`. The main page is given by `/routes/index.tsx` with API routes in
41+
`/routes/api`.
42+
43+
## Local Development
44+
45+
To run and develop the game locally, you need to have [Deno](https://deno.com/)
46+
installed via, e.g. for Unix systems:
47+
48+
```bash
49+
curl -fsSL https://deno.land/install.sh | sh
50+
```
51+
52+
Then follow the following steps:
53+
54+
1. Clone the GitHub repository and change into the directory:
55+
```bash
56+
git clone "URL" heardle_server # todo: insert dedicated repository URL here (after splitting)
57+
cd heardle_server
58+
```
59+
2. Download song data by running the python script `update_track_info.py` via,
60+
e.g. [uv](https://docs.astral.sh/uv/) (which automatically handles script
61+
dependencies):
62+
```bash
63+
uv run update_track_info.py
64+
```
65+
This will create the file `~/heardle_track_info.json` (i.e. in your home
66+
directory) which contains the song data used by the game.
67+
3. Then just start the project (this will also install all dependencies):
68+
```bash
69+
deno task start
70+
```
71+
The local server will start on port [9989](http://localhost:9989).

heardle_server/components/Button.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ export default function Button(props: JSX.HTMLAttributes<HTMLButtonElement>) {
44
return (
55
<button
66
{...props}
7-
class={`${props.class} py-1 px-2 h-full border-2 border-white text-white bg-sky-500 hover:bg-sky-600 transition-colors cursor-pointer`}
7+
class={`${props.class} py-1 px-2 h-full border-2 border-white text-white bg-sky-500 disabled:bg-gray-400 not-disabled:hover:bg-sky-600 transition-colors duration-500 not-disabled:cursor-pointer`}
88
/>
99
);
1010
}

heardle_server/components/SearchBar.tsx

Lines changed: 44 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,19 @@ import { JSX } from "preact";
22
import { Signal, useSignalEffect } from "@preact/signals";
33
import { useEffect, useRef, useState } from "preact/hooks";
44

5-
import { getSubtitleForSong, makeArtistString } from "../helpers.tsx";
5+
import {
6+
getSubtitleForSong,
7+
makeArtistString,
8+
makeErrorMessage,
9+
} from "../helpers.tsx";
610

711
export default function SearchBar(
8-
props: JSX.HTMLAttributes<HTMLInputElement> & { guessCount: Signal<number> },
12+
props: JSX.HTMLAttributes<HTMLInputElement> & {
13+
guessCount: Signal<number>;
14+
inputValue: string;
15+
setInputValue: (value: string) => void;
16+
},
917
) {
10-
const [inputValue, setInputValue] = useState("");
1118
const [selectedSong, setSelectedSong] = useState<Song | null>(null);
1219
const [suggestions, setSuggestions] = useState<Song[]>([]);
1320
const [allSongs, setAllSongs] = useState<Song[]>([]);
@@ -16,24 +23,27 @@ export default function SearchBar(
1623

1724
useEffect(() => { // Fetch all songs when the component mounts
1825
async function fetchSongs() {
19-
try {
20-
const response = await fetch("/api/all-songs");
21-
if (response.ok) {
22-
const data: Song[] = await response.json();
23-
setAllSongs(data);
24-
} else {
25-
console.error("Failed to fetch songs:", response.statusText);
26-
}
27-
} catch (error) {
28-
console.error("Error fetching songs:", error);
26+
const response = await fetch("/api/all-songs");
27+
if (response.ok) {
28+
const data: Song[] = await response.json();
29+
setAllSongs(data);
30+
} else {
31+
throw new Error(makeErrorMessage(response));
2932
}
3033
}
31-
fetchSongs().then(() => console.log("Songs fetched successfully"));
34+
fetchSongs()
35+
.then(() => {
36+
console.debug("Songs fetched successfully.");
37+
}).catch((error) => {
38+
// This is the only place we alert the user that connection failed
39+
alert("Unable to load song data. Please try again later.");
40+
console.error(`Error while fetching songs: ${error}.`);
41+
});
3242
}, []); // Empty dependency array means this runs once on mount
3343

3444
useEffect(() => { // runs whenever inputValue or allSongs changes
35-
const query = inputValue.toLowerCase();
36-
if (inputValue.length > 0) {
45+
const query = props.inputValue.toLowerCase();
46+
if (props.inputValue.length > 0) {
3747
const filteredSuggestions = allSongs.filter((song) =>
3848
song.name.toLowerCase().includes(query) ||
3949
makeArtistString(song.artists).toLowerCase().includes(query) ||
@@ -45,31 +55,31 @@ export default function SearchBar(
4555
setSuggestions([]);
4656
setShowSuggestions(false);
4757
}
48-
}, [inputValue, allSongs]);
58+
}, [props.inputValue, allSongs]);
4959

5060
useSignalEffect(() => { // Runs whenever guessCount Signal changes
5161
if (props.guessCount.value > 0) {
5262
// Reset the input and selected song when a guess is made
53-
setInputValue("");
63+
props.setInputValue("");
5464
setSelectedSong(null);
5565
setSuggestions([]);
5666
setShowSuggestions(false);
5767
}
5868
});
5969

6070
function handleInputChange(event: JSX.TargetedEvent<HTMLInputElement>) {
61-
setInputValue(event.currentTarget.value);
71+
props.setInputValue(event.currentTarget.value);
6272
setSelectedSong(null);
6373
}
6474

6575
function handleSuggestionClick(song: Song) {
66-
setInputValue(song.name);
76+
props.setInputValue(song.name);
6777
setSelectedSong(song);
6878
setShowSuggestions(false);
6979
}
7080

7181
function handleFocus() {
72-
if (inputValue.length > 0) {
82+
if (props.inputValue.length > 0) {
7383
setShowSuggestions(true);
7484
}
7585
}
@@ -87,44 +97,43 @@ export default function SearchBar(
8797
}
8898

8999
return (
90-
<div class="relative">
100+
<div class="relative text-sm w-full">
91101
{showSuggestions && suggestions.length > 0 && (
92102
<div
93103
ref={suggestionsRef}
94-
class="bg-gray-100 border border-gray-300 rounded absolute z-10 mb-2 w-full max-h-40 bottom-full overflow-y-auto"
104+
class="bg-gray-100 dark:bg-slate-500 border border-gray-300 dark:border-white rounded absolute z-10 mb-2 w-full max-h-40 bottom-full overflow-y-auto"
95105
>
96106
{suggestions.map((song) => (
97107
<div
98108
key={song}
99109
tabindex={0}
100-
class="z-10 p-2 border border-gray-300 hover:bg-cyan-200"
110+
class="z-10 p-2 border border-gray-300 dark:border-white hover:bg-cyan-200 hover:dark:bg-gray-600 text-black dark:text-white"
101111
onClick={() => handleSuggestionClick(song)}
102112
onKeyDown={(e) =>
103113
e.key === "Enter" ? handleSuggestionClick(song) : undefined}
104114
>
105-
<p>{song.name}</p>
115+
<p class="font-semibold">{song.name}</p>
106116
<p>By {getSubtitleForSong(song)}</p>
107117
</div>
108118
))}
109119
</div>
110120
)}
111121
<input
112122
{...props}
113-
type="text"
123+
type="search"
114124
tabindex={0}
115-
class="border border-gray-300 rounded text-xl p-2"
116-
value={inputValue}
125+
class="w-full border border-gray-300 text-black rounded p-2"
126+
value={props.inputValue}
117127
onInput={handleInputChange}
118128
onFocus={handleFocus}
119129
onBlur={handleBlur}
120130
/>
121-
<div class="text-xs text-right py-1 pe-1">
122-
{(selectedSong && <p>By {getSubtitleForSong(selectedSong)}</p>) || (
123-
<i>Type a valid guess above ⬆️</i>
124-
)}
125-
</div>
126-
{/* todo: handle text overflow (rather than new line) for long song/artists names (e.g. Sweet Crazy Love Eng) */}
127-
<span class="hidden" id="songId">
131+
<p class="p-1 text-xs truncate text-right">
132+
{selectedSong
133+
? <>By {getSubtitleForSong(selectedSong)}</>
134+
: <i>Type a valid guess above ⬆️</i>}
135+
</p>
136+
<span hidden id="songId">
128137
{selectedSong ? selectedSong.id : ""}
129138
</span>
130139
</div>

heardle_server/islands/islandProps.ts renamed to heardle_server/enums.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,8 @@ export enum guessResult {
44
SKIPPED = "skipped",
55
CORRECT = "correct",
66
}
7+
8+
export interface PastGuess {
9+
song?: Song;
10+
result: guessResult;
11+
}

heardle_server/fresh.config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,7 @@ import tailwind from "$fresh/plugins/tailwind.ts";
44
export default defineConfig({
55
plugins: [tailwind()],
66
server: { port: 9989 },
7+
router: {
8+
ignoreFilePattern: /^.*.d.ts$/
9+
}
710
});

heardle_server/fresh.gen.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ import * as $api_todays_song_check from "./routes/api/todays-song/check.ts";
1212
import * as $api_todays_song_preview_url from "./routes/api/todays-song/preview-url.ts";
1313
import * as $index from "./routes/index.tsx";
1414
import * as $guess_bar from "./islands/guess-bar.tsx";
15-
import * as $islandProps_d from "./islands/islandProps.d.ts";
16-
import * as $islandProps from "./islands/islandProps.ts";
1715
import * as $progress_block from "./islands/progress-block.tsx";
16+
import * as $root from "./islands/root.tsx";
17+
import * as $share_button from "./islands/share-button.tsx";
1818
import * as $song_bar from "./islands/song-bar.tsx";
1919
import type { Manifest } from "$fresh/server.ts";
2020

@@ -32,9 +32,9 @@ const manifest = {
3232
},
3333
islands: {
3434
"./islands/guess-bar.tsx": $guess_bar,
35-
"./islands/islandProps.d.ts": $islandProps_d,
36-
"./islands/islandProps.ts": $islandProps,
3735
"./islands/progress-block.tsx": $progress_block,
36+
"./islands/root.tsx": $root,
37+
"./islands/share-button.tsx": $share_button,
3838
"./islands/song-bar.tsx": $song_bar,
3939
},
4040
baseUrl: import.meta.url,

heardle_server/helpers.tsx

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import { PastGuess } from "./islands/islandProps.d.ts";
2-
import { guessResult } from "./islands/islandProps.ts";
1+
import { guessResult, PastGuess } from "./enums.ts";
32

43
export function makeArtistString(artists: { name: string }[]): string {
54
return artists.map((artist) => artist.name).join(", ");
@@ -16,3 +15,31 @@ export function getSubtitleForSong(song: Song) {
1615
export function hasWon(history: PastGuess[]): boolean {
1716
return history.some((guess) => guess.result === guessResult.CORRECT);
1817
}
18+
19+
export function makeErrorMessage(response: Response): string {
20+
// Return the status and statusText, and the response body as text
21+
return `status ${response.status} (${response.statusText})`;
22+
}
23+
24+
export function checkStorageAvailable(
25+
storageType: "localStorage" | "sessionStorage",
26+
): boolean | undefined {
27+
// Check if the storage type is supported and available
28+
// Adapted from https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API/Using_the_Web_Storage_API
29+
let storage;
30+
try {
31+
storage = globalThis[storageType];
32+
const x = "__storage_test__";
33+
storage.setItem(x, x);
34+
storage.removeItem(x);
35+
return true;
36+
} catch (e) {
37+
return (
38+
e instanceof DOMException &&
39+
e.name === "QuotaExceededError" &&
40+
// acknowledge QuotaExceededError only if there's something already stored
41+
storage &&
42+
storage.length !== 0
43+
);
44+
}
45+
}

0 commit comments

Comments
 (0)