Skip to content
Open
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
Expand Up @@ -29,9 +29,10 @@
* the caller so this card stays ignorant of hackathon roles and
* permissions.
*
* There is no "View" link any more: participant profiles have no page of
* their own, and a button onto `#` is a control that looks live and does
* nothing. Add one back when a profile route exists.
* The participants list passes a View link onto
* `/my/hackathon/[id]/participants/[participantId]` this way; the card
* itself is not a link, so a caller stays free to render no control at
* all.
*/
actions?: Snippet;
} = $props();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,7 @@ export const load: PageServerLoad = async (event) => {
roleLabel: membershipBadgeLabel(m.isWaiting, m.role),
}))

return { participants }
// The route id is returned rather than read from `$page` in the component, so
// the View link's target comes from the same load that produced the rows.
return { hackathonId: event.params.id, participants }
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { Search } from 'lucide-svelte';
import ParticipantCard from '$lib/components/hackathon/ParticipantCard.svelte';
import type { PageData } from './$types';
Expand Down Expand Up @@ -27,8 +28,9 @@
<!--
Who is here, and nothing to act on: Approve and Remove live on the Manage
Participants page (see $lib/navigation's manageNav), which is the one place an
owner's extra capabilities are collected. No "View" link either — participant
profiles have no page of their own yet.
owner's extra capabilities are collected. The only control on a row is View,
onto that participant's profile — the row is a name and a role chip, which is
not enough to know who you are about to team up with.
-->
<div class="flex flex-col gap-6 px-4 py-8 sm:px-10 md:px-20">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
Expand Down Expand Up @@ -67,7 +69,18 @@
</p>
{:else}
{#each filtered as participant (participant.id)}
<ParticipantCard name={participant.name} role={participant.roleLabel} />
<ParticipantCard name={participant.name} role={participant.roleLabel}>
{#snippet actions()}
<a
href={resolve(
`/my/hackathon/${data.hackathonId}/participants/${participant.id}`
)}
class="btn btn-sm btn-ghost no-underline"
>
View
</a>
{/snippet}
</ParticipantCard>
{/each}
{/if}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { PageServerLoad } from "./$types"
import { requireGrpc } from "$lib/server/grpc/client"
import { membershipBadgeLabel } from "$lib/utils/hackathonRole"
import { error } from "@sveltejs/kit"

/**
* One participant, as everyone else in the hackathon sees them. Reached from the
* participants list, which is a name and a role chip and not enough to know who
* you are about to team up with.
*
* No access check of its own, and no `user.get`: the layout's `hackathon.get`
* already enforced `hackathon:read` and already returned every member with their
* casbin role, waitlist flag and join date. This page is a projection of that
* list, so it cannot show anyone the list would have hidden.
*/
export const load: PageServerLoad = async (event) => {
const { hackathon } = await event.parent()
const { team } = requireGrpc(event.locals.grpc)

// Same filter as the list: `user` present and not waitlisted. Who has applied
// and not been accepted is between the applicant and the organizer, so a
// waitlisted id reads as "not here" rather than resolving — otherwise this
// route would be the way around the list's own privacy rule. Manage
// Participants is where a waitlisted person is visible, to the people who act
// on them.
const member = hackathon.members.find(
(m) => m.user?.id === event.params.participantId && !m.isWaiting,
)
if (!member?.user) {
error(404, "That participant is not in this hackathon.")
}
const user = member.user

// Teams need an RPC of their own — `Hackathon` carries no teams edge. `List`
// gates on hackathon-scoped `hackathon:read` (`team_service.go:59`), the same
// permission the layout already passed, so every viewer who can open this page
// can make this call.
//
// A failure here is reported rather than swallowed: an empty teams list and a
// list that failed to load look identical on the page, and "not on a team yet"
// is a claim about this person that we would have no basis for.
const projectTitles = new Map(hackathon.projects.map((p) => [p.id, p.title]))
let teams: { id: string; name: string; projectTitle: string | null }[] = []
let teamsFailed = false
try {
const { teams: all } = await team.list({ hackathonId: event.params.id })
teams = all
.filter((t) => t.members.some((m) => m.id === user.id))
.map((t) => ({
id: t.id,
name: t.name,
projectTitle: projectTitles.get(t.projectId) ?? null,
}))
} catch (err) {
event.locals.logger.warn(
{ err },
"PARTICIPANT: team list failed, rendering the profile without teams",
)
teamsFailed = true
}

return {
hackathonId: event.params.id,
participant: {
name: user.displayName || user.username,
username: user.username,
roleLabel: membershipBadgeLabel(member.isWaiting, member.role),
joinedAt: member.joinedAt,
},
teams,
teamsFailed,
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { PageData } from './$types';

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

const initials = $derived(
data.participant.name
.split(' ')
.filter(Boolean)
.map((w) => w[0])
.join('')
.toUpperCase()
.slice(0, 2)
);

const joined = $derived(
data.participant.joinedAt
? data.participant.joinedAt.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})
: null
);
</script>

<!--
Page shell: px-4 py-8 sm:px-10 md:px-20 (matches participants/teams/projects).

A read-only profile: nothing here acts on the person. Approve, Remove and
Promote live on Manage Participants (see $lib/navigation's manageNav), which is
the one place an owner's extra capabilities are collected, and duplicating them
here would mean two surfaces to keep in step with the same casbin rules.

Email is deliberately absent. `User` carries one, but today it is shown only on
the platform admin page (/manage/users) — never to a peer — and this page is
readable by every confirmed member of the hackathon.

TODO(backend: user-profile-fields): name, role, join date and teams are all
there is to show. `User` carries only username, displayName, email and
keycloakId, so there is no affiliation, bio, avatar, skill list or LinkedIn URL
to put here. Sections for those go in below the header once the fields land;
nothing is stubbed in the meantime, because an empty "About" card tells a
reader this person filled nothing in rather than that the platform cannot ask.
-->
<div class="flex w-full flex-col gap-6 px-4 py-8 sm:px-10 md:px-20">
<a
href={resolve(`/my/hackathon/${data.hackathonId}/participants`)}
class="w-fit text-xs font-semibold text-accent-ink no-underline hover:underline"
>
&larr; Back to participants
</a>

<div class="card card-raised box-border w-full px-5 py-4">
<div class="flex w-full items-start gap-4">
<div
class="flex size-16 shrink-0 items-center justify-center rounded-full
border-2 border-line bg-overlay text-xs font-bold text-ink"
>
{initials}
</div>

<div class="flex min-w-0 flex-1 flex-col gap-1.5">
<h2 class="m-0 text-title leading-snug text-ink">{data.participant.name}</h2>
<p class="m-0 text-xs leading-snug text-ink-3">@{data.participant.username}</p>
<div class="flex flex-wrap items-center gap-2">
<!-- Always the confirmed variant: a waitlisted member never
resolves to this page at all. -->
<span class="badge badge-success">{data.participant.roleLabel}</span>
{#if joined}
<span class="text-xs text-ink-3">Joined {joined}</span>
{/if}
</div>
</div>
</div>
</div>

<div class="flex flex-col gap-2">
<h3 class="m-0 text-sm font-semibold text-ink">Teams</h3>
{#if data.teamsFailed}
<!-- Said outright rather than shown as an empty list: "not on a team"
is a claim about this person, and a failed load is no basis for
it. -->
<p class="m-0 text-xs text-ink-3">
Teams could not be loaded. Reload the page to try again.
</p>
{:else if data.teams.length === 0}
<p class="m-0 text-xs text-ink-3">
{data.participant.name} is not on a team in this hackathon yet.
</p>
{:else}
{#each data.teams as team (team.id)}
<a
href={resolve(`/my/hackathon/${data.hackathonId}/teams/${team.id}`)}
class="card card-raised box-border flex w-full flex-col gap-1 px-5 py-4
no-underline hover:border-accent"
>
<span class="text-sm leading-snug text-ink">{team.name}</span>
{#if team.projectTitle}
<span class="text-xs leading-snug text-ink-2">{team.projectTitle}</span>
{/if}
</a>
{/each}
{/if}
</div>
</div>
Loading