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
2 changes: 1 addition & 1 deletion apps/web/src/lib/bookings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export type BookingGroup = (typeof BOOKING_GROUPS)[number]['value'];

/** Where a thread sits for `viewer`: a live one waits on them when someone on the other side wrote in it last. */
export function bookingGroup(
thread: Pick<BookingThreadView, 'status' | 'lastAuthor' | 'participants'>,
thread: Pick<BookingThreadView, 'status' | 'lastAuthor'> & { participants: { did: string; role: string }[] },
viewer: string
): BookingGroup {
if (thread.status === 'confirmed') return 'confirmed';
Expand Down
33 changes: 33 additions & 0 deletions apps/web/src/lib/components/NeedsList.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<script lang="ts">
import type { NeedsRow } from '#lib/home.ts';
import Button from '#lib/ui/Button.svelte';
import Monogram from '#lib/ui/Monogram.svelte';

/**
* What's waiting on someone, most pressing first. The first row is the page's one primary action unless `lead` is
* off. Each row's button stretches over the whole row, so there's one link to tab through and all of it clicks.
* `loading` holds the room of that many rows.
*/
let { rows = [], lead = true, loading = 0 }: { rows?: NeedsRow[]; lead?: boolean; loading?: number } = $props();
</script>

<ul class="card m-0 list-none divide-y divide-line overflow-hidden p-0" aria-busy={loading > 0 || undefined}>
{#each { length: loading }, row (row)}
{@const leads = lead && row === 0}
<li class={['flex items-center gap-3 px-4 sm:gap-4 sm:px-5', leads ? 'py-4 sm:py-5' : 'py-3 sm:py-3.5']}>
<span class={['shrink-0 bg-well motion-safe:animate-pulse', leads ? 'size-10 rounded-lg' : 'size-8 rounded-[9px]']}></span>
<span class="flex flex-1 flex-col gap-2"><span class="h-4 w-1/2 rounded-md bg-well motion-safe:animate-pulse"></span><span class="h-3 w-3/5 rounded-md bg-well motion-safe:animate-pulse"></span></span>
</li>
{/each}
{#each rows as row, index (row.key)}
{@const leads = lead && index === 0}
<li class={['relative flex items-center gap-3 px-4 transition-colors hover:bg-raised sm:gap-4 sm:px-5', leads ? 'py-4 sm:py-5' : 'py-3 sm:py-3.5']}>
<Monogram name={row.scope} size={leads ? 'md' : 'sm'} />
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<p class={['m-0 text-fg', leads ? 'text-emphasis font-semibold' : 'text-small font-medium']}>{row.title}</p>
<p class="m-0 text-label text-fg-muted">{row.detail}</p>
</div>
<Button href={row.href} size="sm" variant={leads ? 'primary' : 'secondary'} aria-label="{row.action}: {row.title}" class="shrink-0 after:absolute after:inset-0">{row.action}</Button>
</li>
{/each}
</ul>
43 changes: 43 additions & 0 deletions apps/web/src/lib/components/NightList.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<script lang="ts">
import type { NightRow } from '#lib/home.ts';
import DateBlock from '#lib/ui/DateBlock.svelte';

/** Nights in date order, each led by its date, dashed while it's not settled. `loading` holds their room. */
let { nights = [], loading = false }: { nights?: NightRow[]; loading?: boolean } = $props();
</script>

<ul class={['card m-0 list-none divide-y divide-line overflow-hidden p-0', !loading && 'list-in']} aria-busy={loading || undefined}>
{#if loading}
{#each { length: 3 }, row (row)}
<li class="flex items-center gap-3 px-4 py-2.5 sm:gap-4 sm:px-5">
<span class="h-[4.375rem] w-12 shrink-0 rounded-lg bg-well motion-safe:animate-pulse"></span>
<span class="flex flex-1 flex-col gap-2"><span class="h-4 w-2/5 rounded-md bg-well motion-safe:animate-pulse"></span><span class="h-3 w-3/5 rounded-md bg-well motion-safe:animate-pulse"></span></span>
</li>
{/each}
{/if}
{#each nights as night (night.key)}
<li class="relative flex items-center gap-3 px-4 py-2.5 transition-colors hover:bg-raised sm:gap-4 sm:px-5">
<DateBlock date={night.date} tentative={night.tentative} compact />
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<a href={night.href} class="text-small font-medium text-fg no-underline after:absolute after:inset-0">{night.title}</a>
<p class="m-0 text-label text-fg-muted">{night.detail}</p>
</div>
{#if night.when}<span class="pill shrink-0 bg-well text-fg-soft">{night.when}</span>{/if}
</li>
{/each}
</ul>

<style>
/* Results fade over the placeholders they replace, in the same room, so nothing moves. */
@media (prefers-reduced-motion: no-preference) {
.list-in {
animation: list-in 180ms ease-out;
}
}

@keyframes list-in {
from {
opacity: 0;
}
}
</style>
2 changes: 1 addition & 1 deletion apps/web/src/lib/components/ProfileSetupCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
<div class="flex items-start gap-3">
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-raised text-fg-muted"><UserRound class="size-5" aria-hidden="true" /></span>
<div class="flex min-w-0 flex-col gap-1">
<h2 id="setup" class="m-0 text-emphasis font-semibold">Set up your profile</h2>
<h2 id="setup" class="m-0 text-emphasis font-semibold">Start with your name</h2>
<p class="m-0 text-small text-fg-muted">Your page on Local Network. Start from your Bluesky profile and add the rest later.</p>
</div>
</div>
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/lib/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,12 @@ export function formatShowDate(iso: string | null | undefined, withTime = false)
return formatDate(iso, options);
}

/** A night the way lists lead with it, like "Sat 14 Nov", from a show date, which is in UTC. */
export const nightLabel = (iso: string): string =>
new Intl.DateTimeFormat('en-GB', { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
.format(new Date(iso))
.replace(',', '');

/** A profile's fee levels, reading a record written before `fees` as one level. */
export function feeLevels(profile: Pick<ProjectProfile, 'fees' | 'fee'>): ProjectFee[] {
return profile.fees ?? (profile.fee ? [profile.fee] : []);
Expand Down
20 changes: 20 additions & 0 deletions apps/web/src/lib/home.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/** Something waiting on the viewer, as Home lists it. `scope` is the act or thing it belongs to, for its monogram. */
export type NeedsRow = { key: string; scope: string; title: string; detail: string; href: string; action: string };

/** A night the viewer has something to do with. `tentative` nights aren't settled yet; `when` says how close it is. */
export type NightRow = {
key: string;
date: string;
title: string;
detail: string;
href: string;
tentative: boolean;
when?: 'Tonight' | 'Tomorrow';
};

/** Home's heading: the one count of what's waiting on the viewer. */
export const statusLine = (count: number): string => {
if (!count) return 'Nothing needs you.';

return count === 1 ? '1 thing needs you.' : `${count} things need you.`;
};
26 changes: 26 additions & 0 deletions apps/web/src/lib/resolved.svelte.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* A load's value that may still be on its way: what it resolved to once it has, and `initial` until then, so a streamed
* value can join what's on the page instead of replacing a block of it. A value that's already here counts from the
* first render, the server's included. A newer promise takes over from one still waiting.
*/
export function resolved<T, I = T>(load: () => T | Promise<T>, initial: I) {
const first = load();
let value = $state<T | I>(first instanceof Promise ? initial : first);

$effect(() => {
let live = true;
void Promise.resolve(load()).then((next) => {
if (live) value = next;
});

return () => {
live = false;
};
});

return {
get value() {
return value;
}
};
}
Loading
Loading