Skip to content

Commit 85bc4ec

Browse files
committed
Allow multiple concert selection through shift-click
1 parent 3b84b0f commit 85bc4ec

5 files changed

+210
-96
lines changed

src/App.svelte

+8-6
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,25 @@
44
import Overview from "src/lib/Overview.svelte";
55
import Details from "src/lib/Details.svelte";
66
import { type Concert } from "src/lib/bindings/Concert";
7+
import { type HashedConcert, hashConcert } from "src/utils";
78
import { type FiltersType, satisfies } from "src/lib/filters";
89
910
// Initialise list of concerts
1011
import rawConcerts from "src/assets/concerts.json";
11-
const concerts = rawConcerts as Concert[];
12+
const concerts: HashedConcert[] = rawConcerts.map(hashConcert);
1213
1314
// Filter concerts
1415
let filters: FiltersType = {
1516
searchTerm: "",
1617
wigmoreU35: false,
1718
};
18-
let concertsToShow: Concert[];
19+
let concertsToShow: HashedConcert[];
20+
let selectedConcertHashes: string[] = [];
21+
let selectedConcerts: HashedConcert[];
1922
$: {
2023
concertsToShow = concerts.filter((c) => satisfies(c, filters));
24+
selectedConcerts = concertsToShow.filter((c) => selectedConcertHashes.includes(c.hash));
2125
}
22-
23-
let selectedConcert: Concert | null = null;
2426
</script>
2527

2628
<body>
@@ -30,8 +32,8 @@
3032
<Filters bind:filters />
3133
</div>
3234
<div class="bottom">
33-
<Overview concerts={concertsToShow} bind:selectedConcert />
34-
<Details bind:selectedConcert />
35+
<Overview concerts={concertsToShow} bind:selectedConcertHashes />
36+
<Details bind:selectedConcerts />
3537
</div>
3638
</main>
3739
</body>

src/lib/Details.svelte

+71-82
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,61 @@
11
<script lang="ts">
2-
import { type Concert } from "src/lib/bindings/Concert";
3-
import Tags from "src/lib/Tags.svelte";
4-
import { formatDate, getPriceString } from "src/utils";
2+
import { type HashedConcert } from "src/utils";
3+
import SelectedConcertDetails from "src/lib/SelectedConcertDetails.svelte";
54
6-
export let selectedConcert: Concert | null;
5+
export let selectedConcerts: HashedConcert[];
6+
7+
let copyButton: HTMLButtonElement;
8+
function copyTextToClipboard() {
9+
navigator.clipboard.writeText(shareCode);
10+
let w = copyButton.offsetWidth;
11+
copyButton.style.width = w + "px";
12+
copyButton.textContent = "Copied!";
13+
setTimeout(() => {
14+
copyButton.textContent = "Copy to clipboard";
15+
copyButton.style.width = "";
16+
}, 1000);
17+
}
18+
19+
let shareCode: string;
20+
$: {
21+
shareCode = selectedConcerts.map((concert) => concert.hash).join(",");
22+
}
723
</script>
824

925
<div class="details">
10-
{#if selectedConcert !== null}
11-
<div id="selected">
12-
<Tags concert={selectedConcert} />
13-
<h2>
14-
{selectedConcert.title}
15-
{#if selectedConcert.subtitle}
16-
— {selectedConcert.subtitle}
17-
{/if}
18-
</h2>
19-
<p>
20-
{formatDate(new Date(selectedConcert.datetime))}
21-
|
22-
{getPriceString(selectedConcert)}
23-
<br />
24-
<a href={selectedConcert.url}>Link to concert</a>
25-
{#if selectedConcert.programme_pdf_url}
26-
| <a href={selectedConcert.programme_pdf_url}
27-
>Link to programme (PDF)</a
28-
>
29-
{/if}
26+
{#if selectedConcerts.length === 1}
27+
<SelectedConcertDetails selectedConcert={selectedConcerts[0]} />
28+
{:else if selectedConcerts.length === 0}
29+
<div id="centred-text">
30+
<h2>No concert selected</h2>
31+
<p>Select a concert from the list on the left to view details :)</p>
32+
<p class="italic">
33+
(Tip: Use shift-click to select multiple concerts)
3034
</p>
31-
32-
<h3>Performer(s)</h3>
33-
{#if selectedConcert.performers.length === 0}
34-
None listed.
35-
{:else}
36-
<div class="two-col-grid">
37-
{#each selectedConcert.performers as performer}
38-
<span>{performer.name}</span>
39-
<span
40-
>{performer.instrument
41-
? performer.instrument
42-
: ""}</span
43-
>
44-
{/each}
45-
</div>
46-
{/if}
47-
48-
<h3>Programme</h3>
49-
{#if selectedConcert.pieces.length === 0}
50-
None provided.
51-
{:else}
52-
<div class="two-col-grid">
53-
{#each selectedConcert.pieces as piece}
54-
<span>{piece.composer}</span><span
55-
>{@html piece.title}</span
56-
>
57-
{/each}
58-
</div>
59-
{/if}
60-
61-
<h3>Description</h3>
62-
{#if selectedConcert.description}
63-
<div id="description">
64-
{#each selectedConcert.description.split("\n") as paragraph}
65-
<p>{paragraph}</p>
66-
{/each}
67-
</div>
68-
{:else}
69-
None provided.
70-
{/if}
7135
</div>
7236
{:else}
73-
<div id="none-selected">
74-
<h2>No concert selected</h2>
75-
<p>Select a concert from the list on the left to view details :)</p>
37+
<div id="centred-text">
38+
<h2>{selectedConcerts.length} concerts selected</h2>
39+
<div id="selected-concerts-summary">
40+
{#each selectedConcerts as concert}
41+
<p>
42+
<a href={concert.url}>
43+
{concert.title}
44+
</a>
45+
</p>
46+
{/each}
47+
</div>
48+
<p>
49+
Share this list of concerts with somebody by sending them the
50+
text below.
51+
(They don't have any way of inputting this code into the website
52+
yet, but one day...)
53+
</p>
54+
<p></p>
55+
<p class="code">{shareCode}</p>
56+
<button bind:this={copyButton} on:click={copyTextToClipboard}>
57+
Copy to clipboard
58+
</button>
7659
</div>
7760
{/if}
7861
</div>
@@ -90,30 +73,36 @@
9073
border-radius: 10px;
9174
}
9275
93-
#selected > *:first-child {
94-
margin-top: 0;
76+
div#centred-text {
77+
display: grid;
78+
place-items: center;
79+
gap: 5px;
9580
}
9681
97-
#selected > *:last-child {
98-
margin-bottom: 0;
82+
div#selected-concerts-summary {
83+
display: flex;
84+
flex-direction: column;
85+
gap: 5px;
86+
width: 70%;
87+
margin-bottom: 20px;
9988
}
10089
101-
h3 {
102-
margin-bottom: 5px;
90+
p {
91+
margin: 0;
10392
}
10493
105-
div#none-selected {
106-
display: grid;
107-
place-items: center;
94+
.italic {
95+
font-style: italic;
10896
}
10997
110-
div.two-col-grid {
111-
display: grid;
112-
grid-template-columns: max-content 1fr;
113-
gap: 4px 30px;
98+
.code {
99+
font-family: monospace;
100+
width: max-content;
101+
max-width: 80%;
114102
}
115103
116-
div#description > *:first-child {
117-
margin-top: 0;
104+
button {
105+
margin-top: 5px;
106+
font-family: inherit;
118107
}
119108
</style>

src/lib/Overview.svelte

+29-8
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,42 @@
11
<script lang="ts">
2-
import { type Concert } from "src/lib/bindings/Concert";
32
import Tags from "src/lib/Tags.svelte";
4-
import { formatDate, getPriceString } from "src/utils";
3+
import { type HashedConcert, formatDate, getPriceString } from "src/utils";
54
6-
export let concerts: Concert[];
7-
export let selectedConcert: Concert | null;
5+
export let concerts: HashedConcert[];
6+
export let selectedConcertHashes: string[];
7+
8+
// Event handler when a concert is clicked. The behaviour is chosen to
9+
// provide as intuitive a UI as possible
10+
function addOrRemove(event: MouseEvent, hash: string) {
11+
if (selectedConcertHashes.includes(hash)) {
12+
if (event.shiftKey) {
13+
selectedConcertHashes = selectedConcertHashes.filter(
14+
(h) => h !== hash,
15+
);
16+
} else {
17+
if (selectedConcertHashes.length === 1) {
18+
selectedConcertHashes = [];
19+
} else {
20+
selectedConcertHashes = [hash];
21+
}
22+
}
23+
} else {
24+
if (event.shiftKey) {
25+
selectedConcertHashes = [...selectedConcertHashes, hash];
26+
} else {
27+
selectedConcertHashes = [hash];
28+
}
29+
}
30+
}
831
</script>
932

1033
<div class="overview">
1134
{#each concerts as concert}
1235
<button
1336
class="concert"
14-
class:active={selectedConcert === concert}
37+
class:active={selectedConcertHashes.includes(concert.hash)}
1538
class:wigmoreU35={concert.is_wigmore_u35}
16-
on:click={() => {
17-
selectedConcert = selectedConcert === concert ? null : concert;
18-
}}
39+
on:click={(event) => addOrRemove(event, concert.hash)}
1940
>
2041
<Tags {concert} />
2142
<h3>{concert.title}</h3>

src/lib/SelectedConcertDetails.svelte

+86
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
<script lang="ts">
2+
import Tags from "src/lib/Tags.svelte";
3+
import { type HashedConcert, formatDate, getPriceString } from "src/utils";
4+
5+
export let selectedConcert: HashedConcert;
6+
</script>
7+
8+
<div id="selected">
9+
<Tags concert={selectedConcert} />
10+
<h2>
11+
{selectedConcert.title}
12+
{#if selectedConcert.subtitle}
13+
— {selectedConcert.subtitle}
14+
{/if}
15+
</h2>
16+
<p>
17+
{formatDate(new Date(selectedConcert.datetime))}
18+
|
19+
{getPriceString(selectedConcert)}
20+
<br />
21+
<a href={selectedConcert.url}>Link to concert</a>
22+
{#if selectedConcert.programme_pdf_url}
23+
| <a href={selectedConcert.programme_pdf_url}
24+
>Link to programme (PDF)</a
25+
>
26+
{/if}
27+
</p>
28+
29+
<h3>Performer(s)</h3>
30+
{#if selectedConcert.performers.length === 0}
31+
None listed.
32+
{:else}
33+
<div class="two-col-grid">
34+
{#each selectedConcert.performers as performer}
35+
<span>{performer.name}</span>
36+
<span>{performer.instrument ? performer.instrument : ""}</span>
37+
{/each}
38+
</div>
39+
{/if}
40+
41+
<h3>Programme</h3>
42+
{#if selectedConcert.pieces.length === 0}
43+
None provided.
44+
{:else}
45+
<div class="two-col-grid">
46+
{#each selectedConcert.pieces as piece}
47+
<span>{piece.composer}</span><span>{@html piece.title}</span>
48+
{/each}
49+
</div>
50+
{/if}
51+
52+
<h3>Description</h3>
53+
{#if selectedConcert.description}
54+
<div id="description">
55+
{#each selectedConcert.description.split("\n") as paragraph}
56+
<p>{paragraph}</p>
57+
{/each}
58+
</div>
59+
{:else}
60+
None provided.
61+
{/if}
62+
</div>
63+
64+
<style>
65+
#selected > *:first-child {
66+
margin-top: 0;
67+
}
68+
69+
#selected > *:last-child {
70+
margin-bottom: 0;
71+
}
72+
73+
h3 {
74+
margin-bottom: 5px;
75+
}
76+
77+
div.two-col-grid {
78+
display: grid;
79+
grid-template-columns: max-content 1fr;
80+
gap: 4px 30px;
81+
}
82+
83+
div#description > *:first-child {
84+
margin-top: 0;
85+
}
86+
</style>

src/utils.ts

+16
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
import { type Concert } from 'src/lib/bindings/Concert';
22

3+
export type HashedConcert = Concert & { hash: string };
4+
5+
export function hashConcert(concert: Concert): HashedConcert {
6+
let hash = 0;
7+
let hashString = `${concert.title}_${concert.venue}_${concert.datetime}`;
8+
for (let i = 0; i < hashString.length; i++) {
9+
let char = hashString.charCodeAt(i);
10+
hash = ((hash << 5) - hash) + char;
11+
hash |= 0;
12+
}
13+
return {
14+
...concert,
15+
hash: (hash >>> 0).toString(16)
16+
}
17+
}
18+
319
export function formatDate(date: Date): string {
420
let day_of_week = date.toLocaleString(undefined, { weekday: 'long' });
521
let date_long = date.toLocaleString(undefined, { day: 'numeric', month: 'long', year: 'numeric' });

0 commit comments

Comments
 (0)