Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts">
import { ArrowRight, Check } from 'lucide-svelte';
import { resolve } from '$app/paths';
import { capabilityHref } from '$lib/navigation/capabilityLinks';
import { PHASE_CAPABILITIES, capabilityDescription, formatPhaseRange } from '$lib/utils/phase';
import { nextBoundary } from '$lib/utils/relativeTime';
Expand Down Expand Up @@ -50,8 +51,6 @@
} = $props();

const open = $derived(PHASE_CAPABILITIES.filter((c) => enabled.includes(c.value)));
const closed = $derived(PHASE_CAPABILITIES.filter((c) => !enabled.includes(c.value)));
const closedLine = $derived(closed.map((c) => c.label).join(' · '));

// Links only where following one leads somewhere the viewer is let in.
//
Expand All @@ -72,7 +71,6 @@
const phaseBadge = $derived(declared ? 'Current phase' : 'In progress');

const openHeading = $derived(organiserVoice ? 'Participants can now' : 'You can now');
const closedHeading = $derived(organiserVoice ? 'Not open to participants' : 'Not open yet');

// Which boundary to count down to, decided here so the card knows whether
// there is one at all. `new Date()` is only ever compared against, never
Expand All @@ -98,9 +96,14 @@

The open capabilities are rows rather than pills because they are this page's
real navigation: each is the way in to the thing a participant came to do. As
pills they were 20px targets carrying the same visual weight as the closed list
beside them. The closed ones are now one quiet line — worth knowing, not worth
a third of the card.
pills they were 20px targets carrying the same visual weight as the list of
closed ones beside them.

What is *closed* is not listed at all any more. Five of the six capabilities are
shut for most of a hackathon, so the line was always present and never told
anyone anything they could act on — and the question behind it is never "is
voting closed" but "when does it open", which this card cannot answer and the
timeline can. The "Next" line at the foot now leads there.

`border-line-strong` rather than the plain `card` its siblings use: this one
leads the page and is different in kind from the cards below it. Same
Expand Down Expand Up @@ -215,23 +218,27 @@
: 'Nothing is open right now.'}
</p>
{/if}

{#if closed.length > 0}
<!-- One line, not a pill each. Closed is the normal state of most of
these for most of the hackathon, so this wants to be readable at
a glance and then ignored. -->
<p class="m-0 flex flex-wrap items-baseline gap-x-2 gap-y-1 text-xs text-ink-3">
<span class="meta">{closedHeading}</span>
{closedLine}
</p>
{/if}
{/if}

{#if nextPhase}
<span class="tnum border-t border-line pt-3 text-xs text-ink-3">
Next: <span class="font-semibold text-ink-2">{nextPhase.name}</span>
· {formatPhaseRange(nextPhase.startsAt, nextPhase.endsAt)}
</span>
<!-- A link rather than a statement: this is the one forward-looking line on
the card, and the timeline is where the rest of the answer is — the
whole sequence, and how long until this phase hands over. Everyone who
can read this card can read that page. -->
<a
href={resolve(`/my/hackathon/${hackathonId}/timeline`)}
class="group flex flex-wrap items-baseline gap-x-2 border-t border-line pt-3
text-xs text-ink-3 no-underline"
>
<span class="tnum group-hover:underline">
Next: <span class="font-semibold text-ink-2">{nextPhase.name}</span>
· {formatPhaseRange(nextPhase.startsAt, nextPhase.endsAt)}
</span>
<ArrowRight
class="h-3 w-3 shrink-0 self-center text-ink-3 group-hover:text-accent-ink"
aria-hidden="true"
/>
</a>
{/if}

{#if noCurrentPhase || phaseEnded}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
<script lang="ts">
/**
* Above this, the description clamps to two lines and offers to expand.
*
* A character count rather than a measurement: knowing whether text actually
* overflows means reading `scrollHeight` after layout, which costs an effect
* and a second render to save nothing. At two lines of `text-xs` in this
* column the threshold is wrong only for descriptions sitting right on the
* boundary, where both answers look the same.
*/
const CLAMP_CHARS = 180;

let {
title,
dates,
Expand All @@ -8,6 +19,7 @@
participantCapacity,
organizers,
badges = [],
description = '',
}: {
title: string;
dates: string;
Expand All @@ -22,12 +34,25 @@
participantCapacity?: number;
organizers: { name: string; logoUrl: string; logoDarkUrl?: string }[];
badges?: { label: string; variant: string }[];
/**
* The hackathon's own description, or empty.
*
* It used to be an About card at the foot of the overview, which is the
* page for what has changed since the last visit — and a description
* changes never. It is identity, so it belongs with the rest of the
* identity, where a first visit lands and a returning one skims past.
* The dashboard card a member clicks to get here already shows it too.
*/
description?: string;
} = $props();

let expanded = $state(false);
const clampable = $derived(description.length > CLAMP_CHARS);
</script>

<section
class="flex flex-col gap-4 bg-raised px-4 py-4 sm:px-10 sm:py-6 md:flex-row md:items-center
md:gap-8 md:px-20 md:py-0 min-h-0 md:h-44"
md:gap-8 md:px-20 md:py-4 min-h-0 md:min-h-44"
>
{#if imageUrl}
<div
Expand All @@ -48,6 +73,26 @@
{/each}
</div>
{/if}
<!-- Clamped, because the hero is identity at a glance and a long
description would push the count off a phone. The toggle expands it
in place — there is nowhere else in the member subtree that carries
the full text. -->
{#if description}
<div class="flex flex-col items-start gap-0.5">
<p class="prose m-0 text-xs text-ink-2 {expanded ? '' : 'line-clamp-2'}">
{description}
</p>
{#if clampable}
<button
type="button"
class="text-xs font-semibold text-accent-ink hover:underline"
onclick={() => (expanded = !expanded)}
>
{expanded ? 'Less' : 'More'}
</button>
{/if}
</div>
{/if}
<!-- Both guarded rather than always rendered: `venue` is the empty string
and `organizers` is empty on every hackathon there is (Hackathon
carries neither field), and an empty span plus an empty flex row still
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@
approvedCount === 1 ? '1 project' : `${approvedCount} projects`,
);
const trackLabel = $derived(tracks.length === 1 ? '1 track' : `${tracks.length} tracks`);
// The count is already the figure when there are no bars, so the link does not
// repeat it — three renderings of the same number in one card is what this
// card is trying to stop being.
const linkLabel = $derived(tracks.length > 0 ? projectLabel : 'projects');
</script>

<!--
Expand All @@ -35,19 +39,33 @@
parity, so the colour changed with position and meant nothing. Length carries
the magnitude, and a track with no projects still shows its name — an empty
track is worth knowing about, especially for whoever created it.

With no tracks there is nothing to group by and the count is the whole content,
so it is shown as a figure rather than under an apology for the missing bars: a
participant cannot create a track and does not need to be told grouping is off,
and an organiser has Manage Tracks with its own empty state. The overview drops
this card altogether when there is neither a track nor an approved project —
see the guard there, which is what stops an empty box holding half the row.
-->
<section class="card flex flex-col gap-4 p-5" aria-labelledby="projects-heading">
<div class="flex flex-wrap items-baseline justify-between gap-2">
<h2 class="m-0 text-section" id="projects-heading">Projects</h2>
<span class="tnum text-xs text-ink-3">
{projectLabel}{tracks.length > 0 ? ` · ${trackLabel}` : ''}
</span>
<!-- Only alongside the bars. Without them the figure below says the same
number, and saying it twice in one card reads as two facts. -->
{#if tracks.length > 0}
<span class="tnum text-xs text-ink-3">{projectLabel} · {trackLabel}</span>
{/if}
</div>

{#if tracks.length === 0}
<p class="prose m-0 text-xs">
No tracks have been defined, so projects are not grouped.
</p>
{#if approvedCount > 0}
<div class="flex items-baseline gap-2">
<span class="tnum text-display text-ink">{approvedCount}</span>
<span class="text-xs text-ink-3">
approved {approvedCount === 1 ? 'project' : 'projects'}
</span>
</div>
{/if}
{:else}
<ul class="m-0 flex list-none flex-col gap-2 p-0">
{#each tracks as track (track.id)}
Expand Down Expand Up @@ -77,7 +95,7 @@
href={resolve(`/my/hackathon/${hackathonId}/projects`)}
class="text-xs font-semibold text-accent-ink no-underline hover:underline"
>
View all {projectLabel} →
View all {linkLabel} →
</a>
{/if}
</section>
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@
{participantCount}
organizers={[]}
badges={heroBadges}
description={hackathon.description}
/>

{#if phases.length > 0}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
import type { PageData } from './$types';

let { data }: { data: PageData } = $props();

// Whether there is a Projects card at all. With no track to group by and no
// approved project to count it would be an empty box holding half the row —
// and the way in to proposing one is already a row on the state card above.
const showProjects = $derived(data.trackCounts.length > 0 || data.approvedCount > 0);
</script>

<!--
Expand All @@ -15,9 +20,11 @@
here: their queues and switches live on Settings, the organiser's own page,
which badges each count onto the tile that clears it.

Order is by how fast it changes. "Right now" leads because it is the only thing
on the page that differs from one visit to the next; About is last because it is
read once, ever, and had been sitting between two things that are not.
Order is by how fast it changes, and only things that change are here at all.
"Right now" leads because it differs from one visit to the next. The About
section is gone: a description is read once, ever, so it sits with the rest of
the hackathon's identity in the hero — and the dashboard card a member clicks to
get here already carries it.

Page shell: px-4 py-8 sm:px-10 md:px-20 (matches participants/teams/projects).
No width cap of its own — `.prose` caps the one thing here that needs a measure.
Expand All @@ -44,8 +51,12 @@

<!-- Two-up from md: both of these are naturally narrow — a handful of labelled
values and a short bar chart — and stacked full width they were two
mostly-empty rectangles. -->
<div class="grid gap-6 md:grid-cols-2">
mostly-empty rectangles. One column when there is no Projects card, so the
team card takes the width rather than leaving half the row empty.

`items-start`, so the shorter card keeps its own height instead of being
stretched to the taller one's and floating in its own whitespace. -->
<div class="grid items-start gap-6 {showProjects ? 'md:grid-cols-2' : ''}">
{#if data.myTeam}
<ParticipationCard
hackathonId={data.hackathon.id}
Expand Down Expand Up @@ -83,19 +94,12 @@
</section>
{/if}

<TrackBreakdown
hackathonId={data.hackathon.id}
approvedCount={data.approvedCount}
tracks={data.trackCounts}
/>
</div>

<section class="card p-5" aria-labelledby="about">
<h2 class="m-0 mb-3 text-section" id="about">About</h2>
{#if data.hackathon.description}
<p class="prose m-0 text-sm">{data.hackathon.description}</p>
{:else}
<p class="m-0 text-sm text-ink-3">No description provided.</p>
{#if showProjects}
<TrackBreakdown
hackathonId={data.hackathon.id}
approvedCount={data.approvedCount}
tracks={data.trackCounts}
/>
{/if}
</section>
</div>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,16 @@ export const load: PageServerLoad = async (event) => {
isMe: myUserId !== undefined && m.user!.id === myUserId,
}))

return { hackathonId: hackathon.id, participants }
// The export drops members with no address, since a blank one is a row a
// mailing tool rejects (`User.email` is optional and defaults to empty). The
// count is surfaced so a file shorter than the roster is not a silent
// surprise; the addresses themselves stay out of this payload — the download
// endpoint reads them from its own `Get`.
const withoutEmail = hackathon.members.filter(
(m) => m.user !== undefined && m.user.email === "",
).length

return { hackathonId: hackathon.id, participants, withoutEmail }
}

/** The gRPC errors both write paths can return, as SvelteKit failures. */
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script lang="ts">
import { Search } from 'lucide-svelte';
import { Download, Search } from 'lucide-svelte';
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import { SvelteSet } from 'svelte/reactivity';
import ManageHubBackLink from '$lib/components/hackathon/ManageHubBackLink.svelte';
import ParticipantCard from '$lib/components/hackathon/ParticipantCard.svelte';
Expand Down Expand Up @@ -54,7 +55,9 @@
<h2 class="m-0 text-title text-ink">Manage Participants</h2>
<span class="text-xs text-ink-3">
{countLabel}{#if waitingCount > 0}
&middot; {waitingCount} awaiting approval{/if}
&middot; {waitingCount} awaiting approval{/if}{#if data.withoutEmail > 0}
&middot; {data.withoutEmail}
{data.withoutEmail === 1 ? 'has' : 'have'} no email address{/if}
</span>
</div>
<div
Expand All @@ -74,6 +77,20 @@
class="field pl-9 pr-3"
/>
</div>
<!-- The whole roster, deliberately not the searched subset: this
file goes into a mailing tool, and one whose contents depend on
what is typed in the box beside it would be a trap. The
endpoint names the download after the hackathon. -->
<a
href={resolve(
`/my/hackathon/${data.hackathonId}/participants/manage/export`
)}
class="btn btn-sm btn-ghost no-underline"
download
>
<Download class="h-3 w-3 shrink-0" aria-hidden="true" />
Download CSV
</a>
</div>
</div>

Expand Down
Loading
Loading