Skip to content

Commit d1577e4

Browse files
authored
Merge pull request #81 from gtmun/intro-documents
Create author's panel page
2 parents b4aa3bb + b771a70 commit d1577e4

11 files changed

Lines changed: 581 additions & 376 deletions

File tree

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
<!--
2+
@component The page for standard speakers list, consisting of:
3+
- A timer panel with a timer (delegate speaking time)
4+
- An editable speakers list
5+
-->
6+
<script lang="ts">
7+
import { slide } from "svelte/transition";
8+
9+
import TimerPanel from "$lib/components/motions/TimerPanel.svelte";
10+
import SpeakerList from "$lib/components/SpeakerList.svelte";
11+
import { getSessionContext } from "$lib/context/index.svelte";
12+
import { Delegate, findDelegate } from "$lib/db/delegates";
13+
import { db } from "$lib/db/index.svelte";
14+
import type { SpeakerFA } from "$lib/types";
15+
import { a11yLabel } from "$lib/util";
16+
import { parseTime } from "$lib/util/time";
17+
import MdiMinus from "~icons/mdi/minus";
18+
import MdiThumbUp from "~icons/mdi/thumb-up";
19+
20+
interface Props {
21+
delegates: Delegate[],
22+
order: SpeakerFA[],
23+
duration?: number
24+
}
25+
let { delegates, order = $bindable(), duration = $bindable(60) }: Props = $props();
26+
27+
const sessionData = getSessionContext();
28+
let timerPanel = $state<TimerPanel>();
29+
let speakersList = $state<SpeakerList>();
30+
let durInput: string = $state("");
31+
32+
function reset() {
33+
timerPanel?.reset();
34+
}
35+
function setDuration(e: SubmitEvent) {
36+
e.preventDefault();
37+
38+
let secs = parseTime(durInput);
39+
if (typeof secs !== "undefined") {
40+
duration = secs;
41+
}
42+
durInput = "";
43+
}
44+
45+
function invertFavor(s: SpeakerFA["stance"]) {
46+
return s !== "for" ? "for" : "against";
47+
}
48+
function presetCls(s: SpeakerFA) {
49+
if (s.completed) return "preset-ui-depressed";
50+
51+
if (s.stance === "for") return "preset-filled-success-200-800 hover:preset-filled-success-500";
52+
if (s.stance === "against") return "preset-filled-error-200-800 hover:preset-filled-error-500";
53+
return "preset-filled-surface-200-800 hover:preset-filled-surface-500";
54+
}
55+
function rotateCls(stance: SpeakerFA["stance"]) {
56+
return ["transition-transform", stance !== "for" && "rotate-180"];
57+
}
58+
59+
$effect(() => {
60+
sessionData.updateTabTitleExtras(
61+
timerPanel?.getRunState(0) ?? false,
62+
timerPanel?.secsRemaining(0)
63+
);
64+
});
65+
</script>
66+
67+
<div class="flex flex-col lg:flex-row h-full gap-8 items-stretch">
68+
<!--
69+
Under mobile, the timer encompasses the whole page
70+
and the speakers list can be accessed by scrolling down.
71+
72+
Under desktop, both are on the same screen,
73+
with the left side being the timer and the right side being the speakers list.
74+
-->
75+
<!-- Left/Top -->
76+
<div class="flex flex-col grow shrink-0 basis-full lg:basis-auto">
77+
<TimerPanel
78+
{delegates}
79+
{speakersList}
80+
durations={[duration]}
81+
onDurationUpdate={(_, d) => duration = d}
82+
bind:this={timerPanel}
83+
editable
84+
>
85+
{#snippet label(name)}
86+
{@const speaker: SpeakerFA | undefined = speakersList?.selectedSpeaker()}
87+
{#if speaker}
88+
<div class="flex items-center">
89+
<h2 class="h2">{name}</h2>
90+
{#if speaker?.stance}
91+
<div transition:slide={{ duration: 150, axis: "x" }}>
92+
<MdiThumbUp class={["size-8 ml-3", rotateCls(speaker.stance)]} />
93+
</div>
94+
{/if}
95+
</div>
96+
{/if}
97+
{/snippet}
98+
</TimerPanel>
99+
</div>
100+
<!-- Right/Bottom -->
101+
<div class="flex flex-col gap-4 h-full lg:overflow-hidden xl:min-w-100 lg:max-w-[33%]">
102+
<!-- List -->
103+
<SpeakerList
104+
{delegates}
105+
bind:order
106+
bind:this={speakersList}
107+
onBeforeSpeakerUpdate={reset}
108+
onMarkComplete={(key, isRepeat) => { if (!isRepeat) db.updateDelegate(key, d => { d.stats.timesSpoken++; }) }}
109+
>
110+
{#snippet extra(speaker: SpeakerFA, index)}
111+
{@const speakerLabel = findDelegate(delegates, speaker.key)?.name ?? "unknown"}
112+
{@const invertedFavor = invertFavor(speaker.stance)}
113+
114+
<button
115+
class={["btn-icon-std transition", presetCls(speaker)]}
116+
onclick={() => order[index].stance = invertedFavor}
117+
{...a11yLabel(`Set ${speakerLabel} to ${invertedFavor}`)}
118+
disabled={speaker.completed}
119+
>
120+
{#if speaker.stance}
121+
<MdiThumbUp class={rotateCls(speaker.stance)} />
122+
{:else}
123+
<MdiMinus />
124+
{/if}
125+
</button>
126+
{/snippet}
127+
</SpeakerList>
128+
<!-- Timer config -->
129+
<div class="flex flex-row gap-5">
130+
<form class="contents" onsubmit={setDuration}>
131+
<label class="flex grow items-center">
132+
<span>Speaker Time</span>
133+
<input class="input grow" bind:value={durInput} placeholder="mm:ss" disabled={timerPanel?.getRunState(0)} />
134+
</label>
135+
</form>
136+
</div>
137+
</div>
138+
</div>
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
<!-- A page which has multiple steps, used for roll call voting procedure and introduction of resolutions. -->
2+
<script lang="ts">
3+
import type { Snippet } from "svelte";
4+
import { fly, slide } from "svelte/transition";
5+
6+
import PaginatorDots, { type DotPage } from "$lib/components/controls/PaginatorDots.svelte";
7+
import MdiChevronLeft from "~icons/mdi/chevron-left";
8+
import MdiChevronRight from "~icons/mdi/chevron-right";
9+
10+
interface Props {
11+
/**
12+
* List of pages defined in this multipage.
13+
*/
14+
pages: DotPage[],
15+
/**
16+
* The current page index.
17+
*
18+
* Though bindable, setting this value may allow disabled/hidden pages to be bypassed.
19+
*/
20+
pageIndex?: number,
21+
/**
22+
* The main content. The page index is provided to this snippet,
23+
* allowing you to configure different parameters for different pages.
24+
* The snippet itself should consist of {#if page == n}{/if} blocks.
25+
*/
26+
children?: Snippet<[number]>,
27+
/**
28+
* The content for the top-right status bar.
29+
* The page index is provided to this snippet.
30+
*/
31+
topTail?: Snippet<[number]>
32+
}
33+
let {
34+
pages,
35+
pageIndex: page = $bindable(0),
36+
children,
37+
topTail
38+
}: Props = $props();
39+
40+
// Pagination:
41+
let paginator = $state<PaginatorDots>();
42+
let pageIncreased = $state(true);
43+
44+
// Animations
45+
const flyIn = (e: Element) => fly(e, { x: pageIncreased ? "100%" : "-100%", y: 0, duration: 300 });
46+
const flyOut = (e: Element) => fly(e, { x: pageIncreased ? "-100%" : "100%", y: 0, duration: 300 });
47+
</script>
48+
49+
<div class="flex flex-col h-full gap-3">
50+
<!-- Top bar -->
51+
<div class="grid grid-cols-3 items-center">
52+
<div class="flex">
53+
{#key page}
54+
<div class="text-nowrap overflow-hidden" transition:slide={{ axis: "x" }}>
55+
{pages[page].name}
56+
</div>
57+
{/key}
58+
</div>
59+
<PaginatorDots
60+
bind:this={paginator}
61+
bind:page={() => page, np => {
62+
pageIncreased = Math.sign(np - page) >= 0;
63+
page = np;
64+
}}
65+
{pages}
66+
/>
67+
<div class="flex justify-end items-center h-6">
68+
{@render topTail?.(page)}
69+
</div>
70+
</div>
71+
<hr class="hr" />
72+
<!-- Main content -->
73+
<div class="grow overflow-x-hidden overflow-y-auto">
74+
<div class="relative h-full">
75+
{#key page}
76+
<div class="w-full h-full absolute" in:flyIn out:flyOut>
77+
{@render children?.(page)}
78+
</div>
79+
{/key}
80+
</div>
81+
</div>
82+
<hr class="hr" />
83+
<!-- Bottom buttons -->
84+
<div class="flex justify-between">
85+
<button
86+
class="btn preset-filled-primary-500"
87+
disabled={page <= 0}
88+
onclick={() => paginator?.decrementPage()}
89+
>
90+
<MdiChevronLeft />
91+
Previous
92+
</button>
93+
<button
94+
class="btn preset-filled-primary-500"
95+
disabled={page >= pages.length - 1}
96+
onclick={() => paginator?.incrementPage()}
97+
>
98+
Next
99+
<MdiChevronRight />
100+
</button>
101+
</div>
102+
</div>
103+
104+
<svelte:window
105+
onkeydown={e => {
106+
if (document.activeElement == null || document.activeElement == document.body) {
107+
if (e.code === "ArrowLeft") {
108+
paginator?.decrementPage();
109+
}
110+
if (e.code === "ArrowRight") {
111+
paginator?.incrementPage();
112+
}
113+
}
114+
}}
115+
/>

src/lib/components/controls/PaginatorDots.svelte

Lines changed: 42 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,37 @@
1+
<!--
2+
Set of dots that allow you to traverse through various pages.
3+
Similar to Zag.js's "Tabs" or "Steps".
4+
-->
15
<script lang="ts">
26
import { a11yLabel } from "$lib/util";
37
import MdiChevronLeft from "~icons/mdi/chevron-left";
48
import MdiChevronRight from "~icons/mdi/chevron-right";
59
10+
export type DotPage = {
11+
/** The display name of the page. */
12+
name: string,
13+
/**
14+
* Whether this page is disabled.
15+
* If set to true, this page is visible but cannot be accessed or traversed to
16+
* by the typical controls.
17+
*/
18+
disabled?: boolean,
19+
/**
20+
* Whether this page is hidden.
21+
* If set to true, this page cannot be seen by the typical controls.
22+
*/
23+
hidden?: boolean
24+
};
25+
626
interface Props {
7-
totalPages: number,
27+
/** The current page index. */
828
page: number,
9-
disabled?: boolean[] | ((i: number) => boolean)
29+
/** Set of pages available to paginator dots */
30+
pages: DotPage[],
1031
}
1132
let {
12-
totalPages,
1333
page = $bindable(),
14-
disabled = undefined
34+
pages,
1535
}: Props = $props();
1636
1737
const prevPage = $derived.by(() => {
@@ -26,7 +46,7 @@
2646
}
2747
2848
const nextPage = $derived.by(() => {
29-
for (let i = page + 1; i <= totalPages - 1; i++) {
49+
for (let i = page + 1; i <= pages.length - 1; i++) {
3050
if (!getDisabled(i)) return i;
3151
}
3252
});
@@ -37,11 +57,7 @@
3757
}
3858
3959
function getDisabled(i: number) {
40-
if (Array.isArray(disabled)) {
41-
return disabled[i];
42-
} else {
43-
return disabled?.(i);
44-
}
60+
return pages[i].disabled || pages[i].hidden;
4561
}
4662
</script>
4763
<div class="flex gap-3 justify-center items-center">
@@ -53,22 +69,24 @@
5369
<MdiChevronLeft />
5470
</button>
5571
<!-- eslint-disable-next-line svelte/require-each-key -->
56-
{#each Array.from({ length: totalPages }) as _, i}
72+
{#each pages as { name: pageName, hidden }, i}
5773
{@const pressed = page == i}
5874
{@const disabled = getDisabled(i)}
59-
<button
60-
class={[
61-
"rounded-full size-3 transition",
62-
pressed
63-
? "preset-filled-primary-500 scale-150"
64-
: "preset-filled-surface-300-700",
65-
disabled && "cursor-not-allowed"
66-
]}
67-
onclick={() => {if (!disabled) page = i}}
68-
{...a11yLabel(`Go to Page ${i}`)}
69-
aria-pressed={pressed}
70-
{disabled}
71-
></button>
75+
{#if !hidden}
76+
<button
77+
class={[
78+
"rounded-full size-3 transition",
79+
pressed
80+
? "preset-filled-primary-500 scale-150"
81+
: "preset-filled-surface-300-700",
82+
disabled && "cursor-not-allowed"
83+
]}
84+
onclick={() => {if (!disabled) page = i}}
85+
{...a11yLabel(`Go to ${pageName}`)}
86+
aria-pressed={pressed}
87+
{disabled}
88+
></button>
89+
{/if}
7290
{/each}
7391
<button
7492
class="btn btn-sm preset-tonal"

src/lib/components/motions/TimerPanel.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
/**
3333
* The speakers list component (to implement logic for).
3434
*/
35-
speakersList: SpeakerList | undefined,
35+
speakersList?: SpeakerList,
3636
3737
/**
3838
* The duration (in seconds) for the timers.

src/lib/components/motions/form/MotionForm.svelte

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
import { formatValidationError } from "$lib/motions/form_validation";
1919
import type { MotionInput, MotionInputWithFields } from "$lib/motions/types";
2020
import type { DelegateID, Motion } from "$lib/types";
21-
import { hasKey } from "$lib/util";
21+
import { hasKey, NO_FIGURE } from "$lib/util";
2222
import { proxify } from '$lib/util/sv.svelte';
2323
import { parseTime } from "$lib/util/time";
2424
import MdiPlus from "~icons/mdi/plus";
@@ -241,7 +241,7 @@
241241
<!-- Number of speakers display -->
242242
{#if hasField(inputMotion, ["totalTime", "speakingTime"])}
243243
<div class="text-center">
244-
<strong>Number of speakers</strong>: {numSpeakersStr(inputMotion.totalTime, inputMotion.speakingTime) ?? '-'}
244+
<strong>Number of speakers</strong>: {numSpeakersStr(inputMotion.totalTime, inputMotion.speakingTime) ?? NO_FIGURE}
245245
</div>
246246
{/if}
247247

src/lib/util/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,4 +139,9 @@ export function a11yLabel(label?: string) {
139139
"aria-label": label,
140140
title: label
141141
}
142-
}
142+
}
143+
144+
/**
145+
* Used to mark a missing number.
146+
*/
147+
export const NO_FIGURE = "\u{2012}";

0 commit comments

Comments
 (0)