-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathGenreCollectionPage.jsx
More file actions
336 lines (307 loc) · 10.9 KB
/
Copy pathGenreCollectionPage.jsx
File metadata and controls
336 lines (307 loc) · 10.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
import React, { useEffect, useMemo, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import Seo from '../../components/Seo';
import usePersistentState from '../../hooks/usePersistentState';
import AuditionButton from './AuditionButton';
import DemystifyShell from './DemystifyShell';
import EntryDetail from './EntryDetail';
import useAudioSample from './useAudioSample';
import useDemystifyResource from './useDemystifyResource';
import { loadEntries, loadEntry } from './demystifyData';
const COLLECTION_ID = 'genre';
const BASE_PATH = '/demystify/genre';
const ALL = 'ALL';
/** Turns `drumbass` into `DRUMBASS` for titles shown before data arrives. */
const slugToLabel = (slug) => (slug || '').replace(/[-_]+/g, ' ').toUpperCase();
/** Lowercased blob of everything a row should be findable by. */
const haystack = (entry) =>
[
entry.name,
entry.family,
entry.sub,
entry.years,
...entry.tracks.flatMap((t) => [t.title, t.artist]),
]
.join(' ')
.toLowerCase();
/** Earliest and latest four-digit years mentioned across the collection. */
const yearSpan = (entries) => {
const years = entries
.flatMap((entry) => entry.years.match(/\d{4}/g) || [])
.map(Number);
if (!years.length) return '';
const from = Math.min(...years);
const to = Math.max(...years);
return from === to ? `${from}` : `${from}–${to}`;
};
const GenreCollectionPage = () => {
const { entryId } = useParams();
const index = useDemystifyResource(COLLECTION_ID, () =>
loadEntries(COLLECTION_ID),
);
const detail = useDemystifyResource(entryId, () =>
loadEntry(COLLECTION_ID, entryId),
);
const entries = useMemo(() => index.data || [], [index.data]);
const entry = detail.data;
const [family, setFamily] = useState(ALL);
const [query, setQuery] = useState('');
const [audioEnabled, setAudioEnabled] = usePersistentState(
'demystify-audio',
true,
);
const { play, activeId, status: audioStatus } = useAudioSample({
enabled: audioEnabled,
});
useEffect(() => {
window.scrollTo({ top: 0, behavior: 'auto' });
}, [entryId]);
const families = useMemo(
() => [...new Set(entries.map((e) => e.family).filter(Boolean))],
[entries],
);
const searchable = useMemo(
() => entries.map((e) => ({ entry: e, hay: haystack(e) })),
[entries],
);
const visible = useMemo(() => {
const needle = query.trim().toLowerCase();
return searchable
.filter(({ entry: e }) => family === ALL || e.family === family)
.filter(({ hay }) => !needle || hay.includes(needle))
.map(({ entry: e }) => e);
}, [searchable, family, query]);
// Grouped by the genre's own rank, then alphabetical — the source playlist's
// ordering is not something these pages surface.
const allTracks = useMemo(
() =>
entries.flatMap((e) =>
[...e.tracks]
.sort((a, b) => a.title.localeCompare(b.title))
.map((t) => ({ ...t, genre: e.name, id: e.id })),
),
[entries],
);
const position = entries.findIndex((item) => item.id === entryId);
const prev = position > 0 ? entries[position - 1] : null;
const next =
position >= 0 && position < entries.length - 1
? entries[position + 1]
: null;
const audioToggle = (
<button
type="button"
className="dm-toggle"
onClick={() => setAudioEnabled((on) => !on)}
aria-pressed={audioEnabled}
>
AUDIO [{audioEnabled ? 'ON' : 'OFF'}]
</button>
);
const heading = entryId
? entry?.name || slugToLabel(entryId)
: 'DEMYSTIFY / GENRE';
return (
<DemystifyShell
brand={entryId ? `GENRE / ${heading}` : 'DEMYSTIFY / GENRE'}
tagline={entryId ? 'OFFICIAL CURATION' : 'GENRE ATLAS'}
backTo={entryId ? BASE_PATH : '/demystify'}
backLabel={entryId ? 'ALL GENRES' : 'ALL COLLECTIONS'}
footerNote="GENRE ARCHIVE"
toolbar={audioToggle}
>
<Seo
title={
entryId
? `${heading} — Demystify / Genre`
: 'Demystify / Genre — The Genre Atlas'
}
description={
entry?.sub ||
entry?.what?.[0] ||
'Every genre worth understanding, taken apart: the dates, the people who built each one, the gear that gave it its sound, and why it was produced that way.'
}
keywords={[
'demystify genre',
'genre atlas',
'music genres explained',
entry?.name || 'anadolu rock',
entry?.family || 'electronic',
]}
/>
{/* ---------------------------------------------------------------- */}
{/* Detail view */}
{/* ---------------------------------------------------------------- */}
{entryId && detail.status === 'loading' && (
<p className="dm-notice">LOADING {slugToLabel(entryId)}…</p>
)}
{entryId && detail.status === 'error' && (
<div className="dm-notice dm-notice-error">
<p>NO ENTRY FILED UNDER “{slugToLabel(entryId)}”</p>
<Link className="dm-uplink" to={BASE_PATH}>
[← BACK TO ALL GENRES]
</Link>
</div>
)}
{entryId && detail.status === 'ready' && entry && (
<EntryDetail
entry={entry}
basePath={BASE_PATH}
prev={prev}
next={next}
activeId={activeId}
audioStatus={audioStatus}
audioEnabled={audioEnabled}
onPlay={play}
/>
)}
{/* ---------------------------------------------------------------- */}
{/* Index view */}
{/* ---------------------------------------------------------------- */}
{!entryId && index.status === 'loading' && (
<p className="dm-notice">LOADING GENRE INDEX…</p>
)}
{!entryId && index.status === 'error' && (
<p className="dm-notice dm-notice-error">
COULD NOT READ /demystify/genre/index.txt
</p>
)}
{!entryId && index.status === 'ready' && (
<>
<p className="dm-intro">
Every track sorted into the tradition it actually comes from — with
the dates, the people who built each one, the gear that gave it its
sound, and why it was produced the way it was.
</p>
<p className="dm-stats">
<span>
<strong>{entries.length}</strong> GENRES
</span>
<span>
<strong>{allTracks.length}</strong> TRACKS
</span>
<span>
<strong>{families.length}</strong> FAMILIES
</span>
{yearSpan(entries) && (
<span>
<strong>{yearSpan(entries)}</strong> SPAN
</span>
)}
</p>
<div className="dm-filters">
<input
type="search"
className="dm-search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Filter by genre, artist or track…"
aria-label="Filter genres"
/>
<div className="dm-chips">
<button
type="button"
className="dm-chip"
aria-pressed={family === ALL}
onClick={() => setFamily(ALL)}
>
ALL
</button>
{families.map((name) => (
<button
key={name}
type="button"
className="dm-chip"
aria-pressed={family === name}
onClick={() => setFamily(name)}
>
{name}
</button>
))}
</div>
</div>
{visible.length === 0 ? (
<p className="dm-notice">
NOTHING MATCHES THAT FILTER — TRY AN ARTIST OR TRACK NAME
</p>
) : (
<>
<p className="dm-resultcount">
{visible.length === entries.length
? `SHOWING ALL ${entries.length} GENRES`
: `SHOWING ${visible.length} OF ${entries.length} GENRES`}
</p>
<ul className="dm-list">
{visible.map((item) => (
<li className="dm-row" key={item.id}>
<Link className="dm-row-link" to={`${BASE_PATH}/${item.id}`}>
<span className="dm-rank">{item.rank}</span>
<span className="dm-row-name">{item.name}</span>
<span className="dm-row-tag">{item.family}</span>
<span className="dm-row-years">{item.years}</span>
<span className="dm-row-count">
{item.tracks.length || '—'}
</span>
<span className="dm-row-open" aria-hidden="true">
→
</span>
</Link>
<AuditionButton
id={item.id}
activeId={activeId}
status={audioStatus}
enabled={audioEnabled}
label={item.name}
onPlay={() =>
play({
id: item.id,
track: item.tracks[0],
entry: item,
})
}
/>
</li>
))}
</ul>
</>
)}
{allTracks.length > 0 && (
<details className="dm-trackindex">
<summary className="dm-summary">
FULL TRACK INDEX
<span className="dm-summary-count">
{allTracks.length} TRACKS
</span>
</summary>
<div className="dm-tablewrap">
<table className="dm-table">
<thead>
<tr>
<th scope="col">TRACK</th>
<th scope="col">ARTIST</th>
<th scope="col">GENRE</th>
</tr>
</thead>
<tbody>
{allTracks.map((track) => (
<tr key={`${track.id}-${track.title}`}>
<td>{track.title}</td>
<td className="dm-td-artist">{track.artist}</td>
<td className="dm-td-genre">
<Link to={`${BASE_PATH}/${track.id}`}>
{track.genre}
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
)}
</>
)}
</DemystifyShell>
);
};
export default GenreCollectionPage;