Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 96 additions & 37 deletions components/SongList.tsx
Original file line number Diff line number Diff line change
@@ -1,48 +1,51 @@
import Router from "next/router";
import { useMemo, useCallback } from "react";

import { extract, partial_ratio } from "fuzzball";
import { FormEvent, useCallback, useRef } from "react";

import { Input } from "./Input";
import { Link } from "./Link";

import useInput from "@/lib/useInput";
import slugify from "@/lib/slugify";
import { BaseSong, useSongs } from "@/lib/useSongs";

export type SongListProps = {
titles: string[];
songs: BaseSong[];
};

export const SongList = ({ titles }: SongListProps) => {
export const SongList = ({ songs }: SongListProps) => {
const inputRef = useRef<HTMLInputElement>(null);

const {
ref: inputRef,
scrollTo: scrollToInput,
query,
updateQuery,
clearQuery,
} = useInput();
setQuery,
sortMode,
setSortMode,
showHidden,
setShowHidden,
visibleSongs,
favoriteSongs,
hasHydrated,
hiddenCount,
} = useSongs(songs);

const sortedTitles = useMemo(() => {
if (query.trim().length === 0) {
return titles.map((title) => ({ title, score: 100 }));
const scrollToInput = useCallback(() => {
if (!inputRef.current) {
return;
}

const fuzzSortedSongs = extract(query, titles, {
scorer: partial_ratio,
cutoff: 40,
limit: 15,
}) as [string, number, number][];
inputRef.current.scrollIntoView({ behavior: "smooth" });
}, []);

return fuzzSortedSongs.map(([title, score]) => ({ title, score }));
}, [query, titles]);
const handleSubmit = useCallback(
(event: FormEvent<HTMLFormElement>) => {
event.preventDefault();

const handleSubmit = useCallback(() => {
if (sortedTitles.length === 0) {
return;
}
if (visibleSongs.length === 0) {
return;
}

Router.push(`songs/${slugify(sortedTitles[0].title)}`);
}, [sortedTitles]);
Router.push(`/songs/${visibleSongs[0].slug}`);
},
[visibleSongs]
);

return (
<>
Expand All @@ -51,11 +54,71 @@ export const SongList = ({ titles }: SongListProps) => {
ref={inputRef}
placeholder="Type song name and press enter/submit"
value={query}
onChange={updateQuery}
onChange={({ target }) => setQuery(target.value)}
onFocus={scrollToInput}
/>
</form>

<div
style={{
display: "flex",
gap: "0.75rem",
alignItems: "center",
flexWrap: "wrap",
marginBottom: "1rem",
}}
>
<label htmlFor="song-sort-mode">Sort:</label>
<select
id="song-sort-mode"
value={sortMode}
onChange={({ target }) =>
setSortMode(
target.value === "mostVisited" ? "mostVisited" : "alphabetical"
)
}
disabled={!hasHydrated}
style={{ padding: "0.25rem" }}
>
<option value="alphabetical">Alphabetical</option>
<option value="mostVisited">Most visited</option>
</select>
Comment thread
JaniL marked this conversation as resolved.

<button
type="button"
onClick={() => setShowHidden(!showHidden)}
style={{
border: "1px solid currentcolor",
borderRadius: "5pt",
background: "transparent",
padding: "0.5rem 0.75rem",
cursor: "pointer",
}}
>
{showHidden ? "Hide hidden songs" : `Show hidden (${hiddenCount})`}
</button>
</div>

{favoriteSongs.length > 0 && (
<section style={{ marginBottom: "2rem" }}>
<h2 style={{ marginBottom: "0.75rem" }}>Favorites</h2>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(250px, 1fr))",
gap: "1rem",
alignItems: "center",
}}
>
{favoriteSongs.map((song) => (
<Link key={`favorite-${song.slug}`} href={`/songs/${song.slug}`}>
{song.title}
</Link>
))}
</div>
</section>
)}

<div
style={{
display: "grid",
Expand All @@ -64,14 +127,10 @@ export const SongList = ({ titles }: SongListProps) => {
alignItems: "center",
}}
>
{sortedTitles.length === 0 && <p>No songs found</p>}
{sortedTitles.map(({ title, score }) => (
<Link
key={title}
href={`/songs/${slugify(title)}`}
style={{ width: "100%", opacity: Math.max(score, 20) / 100 }}
>
{title}
{visibleSongs.length === 0 && <p>No songs found</p>}
{visibleSongs.map((song) => (
<Link key={song.slug} href={`/songs/${song.slug}`} style={{ width: "100%" }}>
{song.title}
</Link>
))}
</div>
Expand Down
167 changes: 167 additions & 0 deletions lib/personalization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
export type HitRecord = {
lastVisited: number;
timesVisited: number;
};

export type HitsMap = Record<string, HitRecord>;
export type TimestampMap = Record<string, number>;
export type SortMode = "alphabetical" | "mostVisited";

export type PersonalizationState = {
hits: HitsMap;
favorites: TimestampMap;
hidden: TimestampMap;
sortMode: SortMode;
};

export const DEFAULT_PERSONALIZATION_STATE: PersonalizationState = {
hits: {},
favorites: {},
hidden: {},
sortMode: "alphabetical",
};

const STORAGE_KEYS = {
hits: "hits",
favorites: "favorites",
hidden: "hidden",
sortMode: "sortMode",
} as const;

const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);

const readObject = <T extends Record<string, unknown>>(
key: string
): T | null => {
if (typeof window === "undefined") {
return null;
}

const raw = window.localStorage.getItem(key);
if (!raw) {
return null;
}

try {
const parsed = JSON.parse(raw);
if (!isRecord(parsed)) {
return null;
}
return parsed as T;
} catch (_error) {
return null;
}
};

const readSortMode = (): SortMode => {
if (typeof window === "undefined") {
return DEFAULT_PERSONALIZATION_STATE.sortMode;
}

const raw = window.localStorage.getItem(STORAGE_KEYS.sortMode);
if (raw === "alphabetical" || raw === "mostVisited") {
return raw;
}
return DEFAULT_PERSONALIZATION_STATE.sortMode;
};

const omitKey = <T extends Record<string, unknown>>(map: T, key: string): T => {
const { [key]: _removed, ...rest } = map;
return rest as T;
};

const toggleTimestampMap = (map: TimestampMap, slug: string): TimestampMap =>
typeof map[slug] === "number"
? omitKey(map, slug)
: { ...map, [slug]: Date.now() };

const writeMaps = (state: PersonalizationState) => {
if (typeof window === "undefined") {
return;
}

window.localStorage.setItem(STORAGE_KEYS.hits, JSON.stringify(state.hits));
window.localStorage.setItem(
STORAGE_KEYS.favorites,
JSON.stringify(state.favorites)
);
window.localStorage.setItem(STORAGE_KEYS.hidden, JSON.stringify(state.hidden));
window.localStorage.setItem(STORAGE_KEYS.sortMode, state.sortMode);
};

export const readPersonalization = (): PersonalizationState => {
const hits = readObject<HitsMap>(STORAGE_KEYS.hits) ?? {};
const favorites = readObject<TimestampMap>(STORAGE_KEYS.favorites) ?? {};
const hidden = readObject<TimestampMap>(STORAGE_KEYS.hidden) ?? {};

return {
hits,
favorites,
hidden,
sortMode: readSortMode(),
};
};

export const writePersonalization = (next: PersonalizationState) => {
writeMaps(next);
};

export const recordSongVisit = (slug: string): PersonalizationState => {
const current = readPersonalization();
const now = Date.now();

const currentRecord = current.hits[slug];
const hits: HitsMap = {
...current.hits,
[slug]: {
lastVisited: now,
timesVisited: currentRecord ? currentRecord.timesVisited + 1 : 1,
},
};

const next = {
...current,
hits,
};

writeMaps(next);
return next;
};

export const toggleSongFavorite = (slug: string): PersonalizationState => {
const current = readPersonalization();
const favorites = toggleTimestampMap(current.favorites, slug);

const next = {
...current,
favorites,
};

writeMaps(next);
return next;
};

export const toggleSongHidden = (slug: string): PersonalizationState => {
const current = readPersonalization();
const hidden = toggleTimestampMap(current.hidden, slug);

const next = {
...current,
hidden,
};

writeMaps(next);
return next;
};

export const setSongSortMode = (mode: SortMode): PersonalizationState => {
const current = readPersonalization();
const next = {
...current,
sortMode: mode,
};

writeMaps(next);
return next;
};
File renamed without changes.
Loading