mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-30 23:27:55 -05:00
Differ clean for scrims routes; cookbook and manifest updates
This commit is contained in:
127
MIGRATION.md
127
MIGRATION.md
@@ -67,26 +67,77 @@ Auth: `requireUser()` / `getUser()` / `actorId()` from
|
||||
`#lib/features/auth/user.server.ts` read `event.locals.user`, resolved once per
|
||||
request in `hooks.server.ts`. No AsyncLocalStorage.
|
||||
|
||||
## Write path (action → command), first shape
|
||||
## Write path (action → command), the golden pattern
|
||||
|
||||
The full write-path pattern lands with the `/scrims` slice; `/leaderboards`
|
||||
established the command shape for fixed-field mutations:
|
||||
From the `/scrims` slice (`scrims.remote.ts`):
|
||||
|
||||
- Each `_action` branch of a React action union becomes its own `command()`
|
||||
with its own valibot schema (the `_action` discriminator disappears).
|
||||
- **Server-driven refresh is the default**: the handler calls
|
||||
`getX(args).refresh()` / `.set(result)` for exactly the queries it
|
||||
invalidated.
|
||||
- When the server genuinely can't know which query instance the client holds
|
||||
(filter/pagination args — the leaderboards case), the **client** rides the
|
||||
refresh on the mutation round trip instead:
|
||||
- **Single-flight refresh**: the client rides the refresh on the mutation round
|
||||
trip by passing the query *function* — `acceptScrimRequest(args)
|
||||
.updates(getScrimPosts)` — and the handler fulfils it with
|
||||
`await requested(getScrimPosts, 5).refreshAll()`. Kit resolves the client's
|
||||
active instances of that query (whatever args they hold), re-runs them
|
||||
server-side, and the fresh data rides back on the command response.
|
||||
- Mutations that live queries observe skip `.updates()` entirely: the handler
|
||||
publishes to the event bus (`Events.publish(Events.scrimChannel(id))`) and
|
||||
every subscribed stream — including the actor's own tab — re-yields.
|
||||
- React's `errorToast`/`errorToastIfFalsy`/`notFoundIfNullish` port as
|
||||
`#lib/utils/respond.server.ts` (they `error(400/404, message)` the command).
|
||||
Handlers that used `{ fieldErrors }` returns keep them — `SendouForm` shows
|
||||
them under the fields.
|
||||
- `requirePermission(obj, permission)` is ported in
|
||||
`#lib/modules/permissions/guards.server.ts`.
|
||||
|
||||
```ts
|
||||
skipTeam({ season, identifier }).updates(getLeaderboards(queryArgs));
|
||||
```
|
||||
## SendouForm (schema-driven forms)
|
||||
|
||||
Never leave a mutation without one of the two — the refresh-everything default
|
||||
must not ship.
|
||||
`~/form/` ports as `#lib/form/`: `SendouForm.svelte`, `FormField.svelte`
|
||||
(dispatch by schema metadata), `fields.ts` (valibot builders + a WeakMap
|
||||
registry replacing zod's `formRegistry`), per-type field components in
|
||||
`fields/`. The React data flow is kept — values in `$state`, client-side
|
||||
valibot validation with translated `forms:*` message keys, submit sends the
|
||||
raw values — but the transport is a remote **command whose arg schema is the
|
||||
form schema** (server-side validation for free). Kit's remote `form()` stays
|
||||
for simple fixed-field forms (the sidenav pattern); the complex fields (Date
|
||||
values, tuple/union shapes) don't fit its FormData-backed field model, and
|
||||
these forms were JS-dependent in React too.
|
||||
|
||||
- Page wiring: `onSubmit={async (values) => { const r = await
|
||||
createScrimPost(values as v.InferInput<typeof schema>); if (r?.fieldErrors)
|
||||
return r; await goto(...); }}` plus `onSuccess` for closing dialogs.
|
||||
- Cross-field `superRefine` → `v.pipe(v.object(...), v.transform(...),
|
||||
v.forward(v.check(...), [path]), ...)`; `.overwrite` → `v.transform` in the
|
||||
same pipe.
|
||||
- Fields render through context (`form-context.ts`); custom fields are
|
||||
`<FormField name="x">{#snippet children(props)}...{/snippet}</FormField>`
|
||||
with the same `CustomFieldRenderProps` contract as React.
|
||||
- The unsaved-changes guard is `#lib/form/UnsavedChangesGuard.svelte` in the
|
||||
root layout + a `registerDirtyChecker` registry (`beforeNavigate` replaces
|
||||
`useBlocker`; a `willUnload` cancel triggers the native prompt).
|
||||
- `DatePicker.svelte` replaces react-aria's DatePicker: contenteditable
|
||||
`spinbutton` segments named `"{segment}, {label}"` (the e2e contract), built
|
||||
from `Intl.DateTimeFormat.formatToParts`. Segment quirks that pixel parity
|
||||
depends on: no `white-space: pre` (flex whitespace collapsing is what glues
|
||||
react-aria's literals), no explicit font-size (inherits like react-aria),
|
||||
month/day/hour unpadded, minute 2-digit.
|
||||
|
||||
## Live queries (`query.live`) + the event bus
|
||||
|
||||
- `#lib/server/events.ts` is the in-process bus: mutations `publish(channel)`,
|
||||
live-query generators `for await (const _ of Events.subscribe(channel))`
|
||||
re-yield a fresh snapshot per (coalesced) wake-up. Always snapshots, never
|
||||
event logs. A built-in heartbeat keeps proxies from idle-closing streams.
|
||||
- Live queries stream over GET + SSE — they never interfere with the e2e
|
||||
helpers' POST waits, and `fetch()` resolving on headers keeps the
|
||||
hydration-indicator busy counter honest.
|
||||
- SSR awaits the stream's first value; generators must yield before blocking
|
||||
on the bus.
|
||||
- Consumers: `getNotifications` (bell, per-user channel), `getChatRoom`/
|
||||
`getChatRooms` (chat), `getScrim` (both teams' scrim pages update live —
|
||||
replaces skalop's revalidation broadcasts).
|
||||
|
||||
Never leave a mutation without a refresh story — the refresh-everything
|
||||
default must not ship.
|
||||
|
||||
## Search params
|
||||
|
||||
@@ -193,6 +244,23 @@ zod codec.
|
||||
`IS_E2E_TEST_RUN` only; it counts in-flight remote fetches for the busy
|
||||
state.
|
||||
|
||||
## Popovers, menus, selects (mount semantics)
|
||||
|
||||
react-aria unmounts popover/menu content when closed; the handwritten
|
||||
`Popover`/`Menu` components now do the same (`{#if open}` around children).
|
||||
This is what keeps strict-mode e2e selectors (and ARIA snapshots) from seeing
|
||||
N hidden copies of testids/labels. `Select` keeps its *items* mounted (the
|
||||
trigger text comes from item registration) but mounts the search field only
|
||||
while open; its listbox is labelled only while open so a closed select exposes
|
||||
exactly one element under its label. Search-item locators in the e2e helpers
|
||||
filter `{ visible: true }` for the same reason.
|
||||
|
||||
Icon-only buttons: React applies the size classes to the icon element itself
|
||||
(an 18px icon inside the 20px slot ends up 20×18); the Svelte `Button` wraps
|
||||
icons in a span whose `svg { width: 100%; height: auto }` wins on specificity.
|
||||
Where parity matters, out-specify it with a tripled class
|
||||
(`:global(.x.x.x) { height: 18px; }`) — see `ScrimTeamMembersPopover.svelte`.
|
||||
|
||||
## One-offs ledger (discoveries)
|
||||
|
||||
- Kit 3 moved `Handle` to `@sveltejs/kit/hooks` and `getRequestEvent` to
|
||||
@@ -222,3 +290,34 @@ zod codec.
|
||||
back to `.avif` while the oracle baked `.png` from `apps/web-react/.env`
|
||||
(broken sidebar event logos). One shared `.env` is the source of truth until
|
||||
the cutover; tooling overrides passed via `process.env` still win over it.
|
||||
- Kit 3 param matchers are Standard Schemas in a single `src/params.ts`
|
||||
exporting `params = defineParams({ integer: v.pipe(v.string(),
|
||||
v.regex(/^\d+$/)) })` — the `src/params/<name>.ts` convention is gone.
|
||||
- Files named `*.remote.server.ts` are treated as remote-function modules by
|
||||
Kit (everything exported must be a remote function) — hence
|
||||
`#lib/utils/respond.server.ts`, not `remote.server.ts`.
|
||||
- adapter-node 6 bakes `ORIGIN` (from `paths.origin`) at build time, and a
|
||||
bare request guesses `https://`. One e2e/differ build serves many worker
|
||||
ports, so the tooling spawns `apps/web/scripts/e2e-server.mjs` — a shim that
|
||||
injects `x-forwarded-proto: http` with `PROTOCOL_HEADER` set — and
|
||||
remote-function CSRF (which ignores `csrf.trustedOrigins`) sees the right
|
||||
per-port origin.
|
||||
- Context in async components must be set *before* the first `await`
|
||||
(`set_context_after_init` is fatal in prod). Pattern: `setUserCardContext({
|
||||
userCards: () => data.userCards })` above `const data = $derived(await …)` —
|
||||
the getter only runs after the await resolves.
|
||||
- Mutating `$state` synchronously inside a `$derived`-triggered fetch is a
|
||||
`state_unsafe_mutation` — the e2e fetch-counter patch defers its counter
|
||||
writes a microtask (`HydrationTestIndicator.svelte`).
|
||||
- Routes without their own meta render `<DefaultMetaTags />` — the React root
|
||||
supplies default title/description/og tags to meta-less routes, and the SSR
|
||||
head diff flags their absence.
|
||||
- The census walk must use an index entry's own `path` (from `prefix()`);
|
||||
`/scrims` was silently missing from the census before.
|
||||
- `RENDER_GIT_COMMIT` comes from `apps/web-react/.env` via `loadEnv` in
|
||||
vite.config (plain `process.env` misses it and the footer loses its Version
|
||||
line — a differ pixel diff).
|
||||
- `notify`/`resolveNotifications` port to
|
||||
`#lib/features/notifications/core/`, publishing to per-user bus channels
|
||||
instead of skalop pings; web push is still unported (`xxx:` in
|
||||
`notify.server.ts`).
|
||||
|
||||
@@ -372,6 +372,8 @@
|
||||
"chat.newMessages": "",
|
||||
"chat.sidebar.title": "",
|
||||
"chat.sidebar.noActiveChats": "",
|
||||
"chat.sidebar.inactive": "",
|
||||
"chat.room.scrim": "",
|
||||
"fc.title": "",
|
||||
"fc.whereToFind": "",
|
||||
"fc.onceSetStaffOnly": "",
|
||||
|
||||
@@ -372,6 +372,8 @@
|
||||
"chat.newMessages": "",
|
||||
"chat.sidebar.title": "",
|
||||
"chat.sidebar.noActiveChats": "",
|
||||
"chat.sidebar.inactive": "",
|
||||
"chat.room.scrim": "",
|
||||
"fc.title": "",
|
||||
"fc.whereToFind": "",
|
||||
"fc.onceSetStaffOnly": "",
|
||||
|
||||
@@ -372,6 +372,8 @@
|
||||
"chat.newMessages": "Nuevos mensajes",
|
||||
"chat.sidebar.title": "Chat",
|
||||
"chat.sidebar.noActiveChats": "No hay chats activos",
|
||||
"chat.sidebar.inactive": "",
|
||||
"chat.room.scrim": "",
|
||||
"fc.title": "Clave de amigo",
|
||||
"fc.whereToFind": "Encuentra tu clave de amigo en tu Nintendo Switch (2)",
|
||||
"fc.onceSetStaffOnly": "Una vez guardada, solo un miembro del staff puede modificarla.",
|
||||
|
||||
@@ -372,6 +372,8 @@
|
||||
"chat.newMessages": "Nuevo mensajes",
|
||||
"chat.sidebar.title": "",
|
||||
"chat.sidebar.noActiveChats": "",
|
||||
"chat.sidebar.inactive": "",
|
||||
"chat.room.scrim": "",
|
||||
"fc.title": "Clave de amigo",
|
||||
"fc.whereToFind": "",
|
||||
"fc.onceSetStaffOnly": "",
|
||||
|
||||
@@ -372,6 +372,8 @@
|
||||
"chat.newMessages": "",
|
||||
"chat.sidebar.title": "",
|
||||
"chat.sidebar.noActiveChats": "",
|
||||
"chat.sidebar.inactive": "",
|
||||
"chat.room.scrim": "",
|
||||
"fc.title": "",
|
||||
"fc.whereToFind": "",
|
||||
"fc.onceSetStaffOnly": "",
|
||||
|
||||
@@ -372,6 +372,8 @@
|
||||
"chat.newMessages": "Nouveau message",
|
||||
"chat.sidebar.title": "",
|
||||
"chat.sidebar.noActiveChats": "",
|
||||
"chat.sidebar.inactive": "",
|
||||
"chat.room.scrim": "",
|
||||
"fc.title": "Code ami ",
|
||||
"fc.whereToFind": "",
|
||||
"fc.onceSetStaffOnly": "",
|
||||
|
||||
@@ -372,6 +372,8 @@
|
||||
"chat.newMessages": "",
|
||||
"chat.sidebar.title": "",
|
||||
"chat.sidebar.noActiveChats": "",
|
||||
"chat.sidebar.inactive": "",
|
||||
"chat.room.scrim": "",
|
||||
"fc.title": "",
|
||||
"fc.whereToFind": "",
|
||||
"fc.onceSetStaffOnly": "",
|
||||
|
||||
@@ -372,6 +372,8 @@
|
||||
"chat.newMessages": "Nuovi messaggi",
|
||||
"chat.sidebar.title": "",
|
||||
"chat.sidebar.noActiveChats": "",
|
||||
"chat.sidebar.inactive": "",
|
||||
"chat.room.scrim": "",
|
||||
"fc.title": "Codice amico",
|
||||
"fc.whereToFind": "",
|
||||
"fc.onceSetStaffOnly": "",
|
||||
|
||||
@@ -372,6 +372,8 @@
|
||||
"chat.newMessages": "新着メッセージ",
|
||||
"chat.sidebar.title": "チャット",
|
||||
"chat.sidebar.noActiveChats": "使用中のチャットはありません",
|
||||
"chat.sidebar.inactive": "",
|
||||
"chat.room.scrim": "",
|
||||
"fc.title": "フレンドコード",
|
||||
"fc.whereToFind": "フレンドコードは Nintendo Switch から見ることができます",
|
||||
"fc.onceSetStaffOnly": "フレンドコードは一度設定すると、変更できるのはスタッフのみです。",
|
||||
|
||||
@@ -372,6 +372,8 @@
|
||||
"chat.newMessages": "",
|
||||
"chat.sidebar.title": "",
|
||||
"chat.sidebar.noActiveChats": "",
|
||||
"chat.sidebar.inactive": "",
|
||||
"chat.room.scrim": "",
|
||||
"fc.title": "",
|
||||
"fc.whereToFind": "",
|
||||
"fc.onceSetStaffOnly": "",
|
||||
|
||||
@@ -372,6 +372,8 @@
|
||||
"chat.newMessages": "",
|
||||
"chat.sidebar.title": "",
|
||||
"chat.sidebar.noActiveChats": "",
|
||||
"chat.sidebar.inactive": "",
|
||||
"chat.room.scrim": "",
|
||||
"fc.title": "",
|
||||
"fc.whereToFind": "",
|
||||
"fc.onceSetStaffOnly": "",
|
||||
|
||||
@@ -372,6 +372,8 @@
|
||||
"chat.newMessages": "",
|
||||
"chat.sidebar.title": "",
|
||||
"chat.sidebar.noActiveChats": "",
|
||||
"chat.sidebar.inactive": "",
|
||||
"chat.room.scrim": "",
|
||||
"fc.title": "",
|
||||
"fc.whereToFind": "",
|
||||
"fc.onceSetStaffOnly": "",
|
||||
|
||||
@@ -372,6 +372,8 @@
|
||||
"chat.newMessages": "",
|
||||
"chat.sidebar.title": "",
|
||||
"chat.sidebar.noActiveChats": "",
|
||||
"chat.sidebar.inactive": "",
|
||||
"chat.room.scrim": "",
|
||||
"fc.title": "Código de amigo",
|
||||
"fc.whereToFind": "",
|
||||
"fc.onceSetStaffOnly": "",
|
||||
|
||||
@@ -372,6 +372,8 @@
|
||||
"chat.newMessages": "Новые сообщения",
|
||||
"chat.sidebar.title": "",
|
||||
"chat.sidebar.noActiveChats": "",
|
||||
"chat.sidebar.inactive": "",
|
||||
"chat.room.scrim": "",
|
||||
"fc.title": "Код друга",
|
||||
"fc.whereToFind": "",
|
||||
"fc.onceSetStaffOnly": "",
|
||||
|
||||
@@ -372,6 +372,8 @@
|
||||
"chat.newMessages": "新消息",
|
||||
"chat.sidebar.title": "聊天",
|
||||
"chat.sidebar.noActiveChats": "暂无活跃聊天",
|
||||
"chat.sidebar.inactive": "",
|
||||
"chat.room.scrim": "",
|
||||
"fc.title": "好友编号",
|
||||
"fc.whereToFind": "在您的 Nintendo Switch (2) 上找到好友编号",
|
||||
"fc.onceSetStaffOnly": "设置完成后,只有工作人员能修改",
|
||||
|
||||
@@ -92,9 +92,11 @@ async function buildCensus(): Promise<CensusRow[]> {
|
||||
|
||||
const walk = (entries: RouteEntry[], parentPath: string) => {
|
||||
for (const entry of entries) {
|
||||
const fullPath = entry.index
|
||||
? parentPath
|
||||
: joinPaths(parentPath, entry.path);
|
||||
// an index entry inside `prefix()` carries the prefix as its own path
|
||||
const fullPath =
|
||||
entry.path !== undefined
|
||||
? joinPaths(parentPath, entry.path)
|
||||
: parentPath;
|
||||
|
||||
if (entry.children) {
|
||||
walk(entry.children, fullPath);
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
// from `x-forwarded-proto` + Host keeps remote-function CSRF checks passing.
|
||||
// Run with PROTOCOL_HEADER=x-forwarded-proto (set by the tooling).
|
||||
import { createServer } from "node:http";
|
||||
import { handler } from "../build/handler.js";
|
||||
|
||||
// the build output only exists after `pnpm run build`; the indirection keeps
|
||||
// svelte-check from following (and type-checking) the generated chunks
|
||||
const buildDir = "../build";
|
||||
const { handler } = await import(`${buildDir}/handler.js`);
|
||||
|
||||
const port = Number(process.env.PORT ?? 3000);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Calendar } from "@lucide/svelte";
|
||||
import { getLocale } from "#lib/paraglide/runtime.js";
|
||||
import FormMessage from "./FormMessage.svelte";
|
||||
import Label from "./Label.svelte";
|
||||
@@ -184,10 +185,9 @@ function segmentValue(type: EditableSegmentType): number | string | null {
|
||||
function segmentText(type: EditableSegmentType): string {
|
||||
const segValue = segmentValue(type);
|
||||
if (segValue === null) return PLACEHOLDERS[type];
|
||||
if (type === "dayPeriod") return String(segValue);
|
||||
if (type === "hour") return String(segValue);
|
||||
if (type === "minute") return String(segValue).padStart(2, "0");
|
||||
if (type === "year") return String(segValue).padStart(4, "0");
|
||||
return String(segValue).padStart(2, "0");
|
||||
return String(segValue);
|
||||
}
|
||||
|
||||
function setSegmentValue(type: EditableSegmentType, next: number | string) {
|
||||
@@ -535,30 +535,16 @@ const popoverId = `${uid}-calendar-popover`;
|
||||
aria-label="Open calendar"
|
||||
onclick={openCalendar}
|
||||
>
|
||||
<svg
|
||||
class="icon"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M8 2v4" />
|
||||
<path d="M16 2v4" />
|
||||
<rect width="18" height="18" x="3" y="4" rx="2" />
|
||||
<path d="M3 10h18" />
|
||||
</svg>
|
||||
<Calendar class="icon" />
|
||||
</button>
|
||||
</div>
|
||||
{#if errorText}
|
||||
<FormMessage type="error" spaced={false} id={errorId}>
|
||||
<FormMessage type="error" id={errorId}>
|
||||
{errorText}
|
||||
</FormMessage>
|
||||
{/if}
|
||||
{#if bottomText}
|
||||
<FormMessage type="info" spaced={false}>{bottomText}</FormMessage>
|
||||
<FormMessage type="info">{bottomText}</FormMessage>
|
||||
{/if}
|
||||
<div
|
||||
bind:this={calendarPopover}
|
||||
@@ -648,7 +634,6 @@ const popoverId = `${uid}-calendar-popover`;
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1-5);
|
||||
width: 100%;
|
||||
|
||||
&[data-open] .group {
|
||||
@@ -674,11 +659,9 @@ const popoverId = `${uid}-calendar-popover`;
|
||||
|
||||
.dateInput {
|
||||
display: flex;
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
.segment {
|
||||
white-space: pre;
|
||||
caret-color: transparent;
|
||||
|
||||
&:not([data-type="literal"]) {
|
||||
@@ -704,11 +687,9 @@ const popoverId = `${uid}-calendar-popover`;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.icon {
|
||||
.button :global(.icon) {
|
||||
color: var(--color-text-high);
|
||||
width: var(--field-size-icon);
|
||||
height: var(--field-size-icon);
|
||||
|
||||
11
apps/web/src/lib/components/DefaultMetaTags.svelte
Normal file
11
apps/web/src/lib/components/DefaultMetaTags.svelte
Normal file
@@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
import MetaTags from "./MetaTags.svelte";
|
||||
|
||||
/** The site-wide fallback meta for routes without their own (the React app's root defaults). */
|
||||
</script>
|
||||
|
||||
<MetaTags
|
||||
title="sendou.ink"
|
||||
ogTitle="sendou.ink - Competitive Splatoon Hub"
|
||||
description="Sendou.ink is the home of competitive Splatoon featuring daily tournaments and a seasonal ladder. Variety of tools and the largest collection of builds by top players allow you to level up your skill in Splatoon 3."
|
||||
/>
|
||||
@@ -197,6 +197,11 @@ const composedAriaLabel = $derived(
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.root :global(button:disabled .selectValue) {
|
||||
color: var(--color-text-high);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.itemTextsContainer {
|
||||
line-height: 1.1;
|
||||
font-size: var(--font-sm);
|
||||
|
||||
@@ -186,6 +186,11 @@ const composedAriaLabel = $derived(
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.root :global(button:disabled .selectValue) {
|
||||
color: var(--color-text-high);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.itemTextsContainer {
|
||||
line-height: 1.1;
|
||||
font-size: var(--font-sm);
|
||||
|
||||
@@ -117,14 +117,13 @@ const timePopoverFooterText = $derived(
|
||||
{#if post.isPrivate}
|
||||
<Popover>
|
||||
{#snippet trigger(triggerProps)}
|
||||
<button
|
||||
type="button"
|
||||
class="iconButton"
|
||||
data-testid="limited-visibility-popover"
|
||||
<Button
|
||||
variant="minimal"
|
||||
testId="limited-visibility-popover"
|
||||
{...triggerProps}
|
||||
>
|
||||
<EyeOff class="usersIcon" />
|
||||
</button>
|
||||
{#snippet icon()}<EyeOff class="scrimUsersIcon" />{/snippet}
|
||||
</Button>
|
||||
{/snippet}
|
||||
{m.scrims_limitedVisibility()}
|
||||
</Popover>
|
||||
@@ -209,10 +208,10 @@ const timePopoverFooterText = $derived(
|
||||
{@const tournament = post.mapsTournament}
|
||||
<Popover>
|
||||
{#snippet trigger(triggerProps)}
|
||||
<button
|
||||
type="button"
|
||||
class="iconButton tournamentPopoverTrigger"
|
||||
data-testid="tournament-popover-trigger"
|
||||
<Button
|
||||
variant="minimal"
|
||||
class="tournamentPopoverTrigger"
|
||||
testId="tournament-popover-trigger"
|
||||
{...triggerProps}
|
||||
>
|
||||
<Avatar
|
||||
@@ -220,7 +219,7 @@ const timePopoverFooterText = $derived(
|
||||
url={tournament.avatarUrl ?? undefined}
|
||||
alt={tournament.name}
|
||||
/>
|
||||
</button>
|
||||
</Button>
|
||||
{/snippet}
|
||||
<div class="stack sm text-center">
|
||||
<a
|
||||
@@ -341,25 +340,9 @@ const timePopoverFooterText = $derived(
|
||||
{/snippet}
|
||||
|
||||
<style>
|
||||
.iconButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
color: var(--color-text);
|
||||
border-radius: var(--radius-field);
|
||||
|
||||
&:focus-visible {
|
||||
outline: var(--focus-ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.iconButton :global(.usersIcon) {
|
||||
width: 18px;
|
||||
/* the React app sizes the icon itself (18px) inside the 20px button icon
|
||||
slot; the tripled class out-specifies Button's own svg sizing */
|
||||
:global(.scrimUsersIcon.scrimUsersIcon.scrimUsersIcon) {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
@@ -392,7 +375,7 @@ const timePopoverFooterText = $derived(
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
|
||||
.tournamentPopoverTrigger {
|
||||
:global(.tournamentPopoverTrigger) {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Users } from "@lucide/svelte";
|
||||
import { Popover } from "@sendou/components";
|
||||
import { Button, Popover } from "@sendou/components";
|
||||
import Avatar from "#lib/components/Avatar.svelte";
|
||||
import NoteAvatar from "#lib/components/NoteAvatar.svelte";
|
||||
import UserCard from "#lib/features/user-page/components/UserCard.svelte";
|
||||
@@ -18,9 +18,9 @@ const cards = getUserCardContext();
|
||||
|
||||
<Popover>
|
||||
{#snippet trigger(triggerProps)}
|
||||
<button type="button" class="minimalButton" {...triggerProps}>
|
||||
<Users class="usersIcon" />
|
||||
</button>
|
||||
<Button variant="minimal" {...triggerProps}>
|
||||
{#snippet icon()}<Users class="scrimUsersIcon" />{/snippet}
|
||||
</Button>
|
||||
{/snippet}
|
||||
<div class="stack md">
|
||||
{#each users as user (user.id)}
|
||||
@@ -41,25 +41,9 @@ const cards = getUserCardContext();
|
||||
</Popover>
|
||||
|
||||
<style>
|
||||
.minimalButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
color: var(--color-text);
|
||||
border-radius: var(--radius-field);
|
||||
|
||||
&:focus-visible {
|
||||
outline: var(--focus-ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.minimalButton :global(.usersIcon) {
|
||||
width: 18px;
|
||||
/* the React app sizes the icon itself (18px) inside the 20px button icon
|
||||
slot; the tripled class out-specifies Button's own svg sizing */
|
||||
:global(.scrimUsersIcon.scrimUsersIcon.scrimUsersIcon) {
|
||||
height: 18px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -186,6 +186,17 @@ const fieldId = $props.id();
|
||||
{selectedKey}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
>
|
||||
{#snippet valueContent(key)}
|
||||
{@const option = options.find((candidate) => candidate.key === key)}
|
||||
{#if option}
|
||||
<div class="option">
|
||||
{option.label}
|
||||
{#if option.users}
|
||||
<span class="optionUsers">{option.users}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#each options as option (option.key)}
|
||||
<SelectItem id={option.key} textValue={option.textValue}>
|
||||
<div class="option">
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
<script lang="ts">
|
||||
import DefaultMetaTags from "#lib/components/DefaultMetaTags.svelte";
|
||||
import ScrimPage from "#lib/features/scrims/components/ScrimPage.svelte";
|
||||
import { page } from "$app/state";
|
||||
|
||||
const scrimPostId = $derived(Number(page.params.id));
|
||||
</script>
|
||||
|
||||
<DefaultMetaTags />
|
||||
|
||||
{#key scrimPostId}
|
||||
<ScrimPage {scrimPostId} />
|
||||
{/key}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<script lang="ts">
|
||||
import DefaultMetaTags from "#lib/components/DefaultMetaTags.svelte";
|
||||
import Main from "#lib/components/Main.svelte";
|
||||
import NewScrimForm from "#lib/features/scrims/components/NewScrimForm.svelte";
|
||||
</script>
|
||||
|
||||
<DefaultMetaTags />
|
||||
|
||||
<Main>
|
||||
<NewScrimForm />
|
||||
</Main>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { paraglideVitePlugin } from "@inlang/paraglide-js";
|
||||
import adapter from "@sveltejs/adapter-node";
|
||||
import { sveltekit } from "@sveltejs/kit/vite";
|
||||
import { defineConfig } from "vite";
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
|
||||
const IS_E2E_BUILD = process.env.VITE_E2E_TEST_RUN === "true";
|
||||
|
||||
@@ -10,7 +10,11 @@ export default defineConfig(({ mode }) => ({
|
||||
// apps/web-react/.env is the single source of truth for them
|
||||
envDir: "../web-react",
|
||||
define: {
|
||||
__GIT_COMMIT__: JSON.stringify(process.env.RENDER_GIT_COMMIT ?? ""),
|
||||
__GIT_COMMIT__: JSON.stringify(
|
||||
process.env.RENDER_GIT_COMMIT ??
|
||||
loadEnv(mode, "../web-react", "").RENDER_GIT_COMMIT ??
|
||||
"",
|
||||
),
|
||||
},
|
||||
plugins: [
|
||||
paraglideVitePlugin({
|
||||
|
||||
@@ -46,7 +46,8 @@
|
||||
"status": "pending"
|
||||
},
|
||||
"chat": {
|
||||
"status": "pending"
|
||||
"status": "migrated",
|
||||
"notes": "rebuilt (not ported) on ChatRoom/ChatRoomMessage/ChatRoomRead + query.live per plan; scrim rooms are the first consumer. UI is a redesign so no differ parity gate; sendouq/tournament rooms + SplatNet QR links arrive with their features"
|
||||
},
|
||||
"comp-analyzer": {
|
||||
"status": "pending"
|
||||
@@ -105,7 +106,8 @@
|
||||
"status": "pending"
|
||||
},
|
||||
"notifications": {
|
||||
"status": "pending"
|
||||
"status": "pending",
|
||||
"notes": "bell peek/live stream/mark-seen/10s grace dot + notify/resolve server core shipped with the scrims slice (notifications e2e green against apps/web); /notifications page, web push and remaining surface still pending"
|
||||
},
|
||||
"object-damage-calculator": {
|
||||
"status": "pending"
|
||||
@@ -126,7 +128,8 @@
|
||||
"status": "pending"
|
||||
},
|
||||
"scrims": {
|
||||
"status": "pending"
|
||||
"status": "verified",
|
||||
"notes": "phase 3 vertical slice; scrims e2e (9 tests) green against apps/web and differ clean (pixel/head/resource) for /scrims, /scrims/new, /scrims/:id 2026-08-17"
|
||||
},
|
||||
"search": {
|
||||
"status": "pending"
|
||||
|
||||
Reference in New Issue
Block a user