From c2b1d1a6876f347d8957511031d970e77d5be159 Mon Sep 17 00:00:00 2001 From: Sabine Maennel <5292683+sabinem@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:34:22 +0200 Subject: [PATCH 01/10] feat(frontend): image fields catch a share link before it is saved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing on the platform is uploaded. `Hackathon.logo` and `Project.image` are single string columns with no file storage behind them, so every picture is a URL somebody typed — and the commonest thing typed is a *share* link, the page a cloud drive shows after pressing Share. That serves HTML rather than bytes: dropped into an `` it fails, and the only sign of it is a broken-image glyph on somebody else's page, long after the form was submitted and with no hint of which link did it. The four forms taking a picture — create hackathon, edit hackathon, propose project and the project editor — now share one `ImageUrlField`. It loads the address as it is typed, which is the only honest test: pattern-matching names the hosts people paste, but cannot tell a direct URL that 404s from one that works, and those are half the failures. An `` is also the one probe with no CORS to satisfy and no server of ours in the middle. `adviseImageUrl` reports only what is certain — Drive, Photos, OneDrive, SharePoint, Imgur, Flickr and Unsplash page URLs are known to answer with a web page. GitHub `blob` and Dropbox links get the direct address offered as a one-press correction, never applied silently: the address stays the one the person put there until they say otherwise. Nothing is rewritten where no documented, stable direct form exists. --- .../lib/components/forms/ImageUrlField.svelte | 141 ++++++++++++++++ .../components/forms/ImageUrlField.test.ts | 150 ++++++++++++++++++ .../hackathon/ProjectEditForm.svelte | 20 +-- .../frontend/src/lib/utils/imageUrl.test.ts | 126 +++++++++++++++ components/frontend/src/lib/utils/imageUrl.ts | 148 +++++++++++++++++ .../(app)/hackathons/create/+page.svelte | 6 +- .../hackathon/[id]/manage/edit/+page.svelte | 19 ++- .../[id]/projects/propose/+page.svelte | 13 +- 8 files changed, 589 insertions(+), 34 deletions(-) create mode 100644 components/frontend/src/lib/components/forms/ImageUrlField.svelte create mode 100644 components/frontend/src/lib/components/forms/ImageUrlField.test.ts create mode 100644 components/frontend/src/lib/utils/imageUrl.test.ts create mode 100644 components/frontend/src/lib/utils/imageUrl.ts diff --git a/components/frontend/src/lib/components/forms/ImageUrlField.svelte b/components/frontend/src/lib/components/forms/ImageUrlField.svelte new file mode 100644 index 00000000..2a02dd5a --- /dev/null +++ b/components/frontend/src/lib/components/forms/ImageUrlField.svelte @@ -0,0 +1,141 @@ + + +
+ + + + + A link to the image file itself — a share or page link will not render. + + + {#if probed !== ''} + +
+ {#if showImage} + + + (loaded = probed)} + onerror={() => (failed = probed)} + class="h-full w-full object-contain" + class:opacity-0={checking} + /> + + {/if} + + + {#if checking} + Checking the link… + {:else if showImage} + This is what will be shown. + {:else} + + + {previewable + ? 'This link does not load as an image.' + : 'This is not a link a browser can open.'} + + {/if} + + {#if advice.problem} + {advice.problem} + {/if} + + {#if advice.direct} + + + {/if} + +
+ {/if} +
diff --git a/components/frontend/src/lib/components/forms/ImageUrlField.test.ts b/components/frontend/src/lib/components/forms/ImageUrlField.test.ts new file mode 100644 index 00000000..152c1e6b --- /dev/null +++ b/components/frontend/src/lib/components/forms/ImageUrlField.test.ts @@ -0,0 +1,150 @@ +import { fireEvent, render, screen } from "@testing-library/svelte" +import { tick } from "svelte" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import ImageUrlField from "./ImageUrlField.svelte" + +/* + * The advice itself is covered in utils/imageUrl.test.ts. What is left to prove + * here is the part that cannot be a pure function: that the preview is what + * decides, that its verdict follows the field rather than sticking to the first + * address typed, and that the field warns without ever blocking the submit. + * + * jsdom loads nothing, so neither `load` nor `error` ever fires on its own. The + * events are dispatched by hand, which is what a real browser does a moment + * later; the component cannot tell the difference. + */ + +beforeEach(() => vi.useFakeTimers()) +afterEach(() => vi.useRealTimers()) + +/** Renders, types `value`, and lets the debounce elapse. */ +async function typing(value: string, initial = "") { + const { container } = render(ImageUrlField, { + props: { name: "logo", label: "Logo URL (optional)", value: initial }, + }) + const input = container.querySelector("input")! + + if (value !== initial) await fireEvent.input(input, { target: { value } }) + await vi.advanceTimersByTimeAsync(600) + await tick() + + return { input, container } +} + +const preview = (container: HTMLElement) => + container.querySelector("img") + +describe("ImageUrlField", () => { + it("posts the typed address under the given name", async () => { + const { input } = await typing("https://example.org/logo.png") + + expect(input.name).toBe("logo") + expect(input.value).toBe("https://example.org/logo.png") + }) + + it("says nothing at all while the field is empty", async () => { + const { container } = await typing("") + + expect(preview(container)).toBeNull() + expect(screen.queryByText(/does not load/)).toBeNull() + }) + + it("tries to load what was typed", async () => { + const { container } = await typing("https://example.org/logo.png") + + expect(preview(container)?.src).toBe("https://example.org/logo.png") + expect(screen.getByText(/Checking the link/)).toBeTruthy() + }) + + it("waits for typing to stop before requesting anything", async () => { + const { container } = render(ImageUrlField, { + props: { name: "logo", label: "Logo URL" }, + }) + const input = container.querySelector("input")! + + await fireEvent.input(input, { target: { value: "https://exa" } }) + await vi.advanceTimersByTimeAsync(200) + await tick() + + expect(preview(container)).toBeNull() + }) + + it("shows the picture once it loads", async () => { + const { container } = await typing("https://example.org/logo.png") + + await fireEvent.load(preview(container)!) + await tick() + + expect(screen.getByText(/what will be shown/)).toBeTruthy() + expect(preview(container)).not.toBeNull() + }) + + it("reports an address that will not load, and drops the preview", async () => { + const { container } = await typing("https://example.org/not-an-image") + + await fireEvent.error(preview(container)!) + await tick() + + expect(screen.getByText(/does not load as an image/)).toBeTruthy() + expect(preview(container)).toBeNull() + }) + + it("explains a share link once the preview has failed", async () => { + const { container } = await typing( + "https://drive.google.com/file/d/1AbC/view", + ) + + await fireEvent.error(preview(container)!) + await tick() + + expect(screen.getByText(/Google Drive share link/)).toBeTruthy() + }) + + it("tries again when the address is corrected", async () => { + const { input, container } = await typing("https://example.org/bad.png") + + await fireEvent.error(preview(container)!) + await tick() + expect(preview(container)).toBeNull() + + await fireEvent.input(input, { + target: { value: "https://example.org/good.png" }, + }) + await vi.advanceTimersByTimeAsync(600) + await tick() + + expect(preview(container)?.src).toBe("https://example.org/good.png") + }) + + it("swaps in the direct link when offered one, and only when pressed", async () => { + const { input } = await typing( + "https://github.com/acme/site/blob/main/logo.png", + ) + + expect(input.value).toBe("https://github.com/acme/site/blob/main/logo.png") + + await fireEvent.click(screen.getByText(/Use the direct link/)) + await tick() + + expect(input.value).toBe( + "https://raw.githubusercontent.com/acme/site/main/logo.png", + ) + }) + + it("refuses to preview something that is not a web address", async () => { + const { container } = await typing("logo.png") + + expect(preview(container)).toBeNull() + expect(screen.getByText(/not a link a browser can open/)).toBeTruthy() + }) + + it("previews an address it was given, without waiting to be typed into", async () => { + const { container } = await typing( + "https://example.org/saved.png", + "https://example.org/saved.png", + ) + + expect(preview(container)?.src).toBe("https://example.org/saved.png") + }) +}) diff --git a/components/frontend/src/lib/components/hackathon/ProjectEditForm.svelte b/components/frontend/src/lib/components/hackathon/ProjectEditForm.svelte index 4006252a..fab6005c 100644 --- a/components/frontend/src/lib/components/hackathon/ProjectEditForm.svelte +++ b/components/frontend/src/lib/components/hackathon/ProjectEditForm.svelte @@ -1,5 +1,6 @@ - +
- - {#if hasImage} - (failedSrc = imageUrl)} - class="pointer-events-none absolute inset-0 h-full w-full object-cover opacity-55 dark:opacity-40" - /> - {/if} - -
+ -
- + - {#if status} - - - {status} - - {/if} + {#if status} + + + {status} + + {/if} -

- {title} -

+

+ {title} +

- {#if hasMeta} -
- {#if dates} - - - {dates} - - {/if} - {#if venue} - - - {venue} - - {/if} - {#if hasCounts} - - - {registered} / {capacity} registered - - {/if} -
- {/if} -
+ {#if hasMeta} +
+ {#if dates} + + + {dates} + + {/if} + {#if venue} + + + {venue} + + {/if} + {#if hasCounts} + + + {registered} / {capacity} registered + + {/if} +
+ {/if}
diff --git a/components/frontend/src/lib/components/hackathon/StoredImage.svelte b/components/frontend/src/lib/components/hackathon/StoredImage.svelte new file mode 100644 index 00000000..fba0181f --- /dev/null +++ b/components/frontend/src/lib/components/hackathon/StoredImage.svelte @@ -0,0 +1,81 @@ + + + +{#if usable} +
+ onload?.()} + onerror={() => { + failedSrc = src; + onerror?.(); + }} + class="block h-auto w-auto max-w-full rounded-card border border-line + bg-raised {maxHeight}" + /> +
+{/if} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/about/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/about/+page.svelte index 9311c87a..f4e98506 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/about/+page.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/about/+page.svelte @@ -1,16 +1,9 @@
- {#if hasImage} - - (failedSrc = data.logo)} - class="aspect-[3/1] w-full max-w-3xl rounded-card border border-line - object-cover" - /> - {/if} + +

About {data.name}

From 147e811eaf21a825ff8add09344637520aeb559b Mon Sep 17 00:00:00 2001 From: Sabine Maennel <5292683+sabinem@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:55:51 +0200 Subject: [PATCH 06/10] feat(frontend): the public hackathon page is defined in one place The public route's body moves into `PublicHackathonView`, leaving the route a twenty-line caller of it. On its own that is a refactor; the reason for it is the organiser's preview in the next commit, which renders this same component rather than a copy of the markup. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A preview built from its own markup looks right the day it is written and lies by the end of the month, and it lies worst exactly where it matters here — the picture, whose shape nobody can predict from the URL they pasted. Dates and the status label are derived inside the component for the same reason, rather than passed in. `preview` marks the organiser's rendering: the view goes `inert`, so nothing in it can be clicked, and the Join block is left out — it says the same thing for every hackathon and nothing on the editor's form changes it. --- .../hackathon/PublicHackathonView.svelte | 95 +++++++++++++++++++ .../(public)/hackathon/[id]/+page.svelte | 55 +++-------- 2 files changed, 107 insertions(+), 43 deletions(-) create mode 100644 components/frontend/src/lib/components/hackathon/PublicHackathonView.svelte diff --git a/components/frontend/src/lib/components/hackathon/PublicHackathonView.svelte b/components/frontend/src/lib/components/hackathon/PublicHackathonView.svelte new file mode 100644 index 00000000..3612a740 --- /dev/null +++ b/components/frontend/src/lib/components/hackathon/PublicHackathonView.svelte @@ -0,0 +1,95 @@ + + + +
+ + +
+
+ {#if description} + +
+ +
+ {:else} + +

+ The organizers have not written a description for this hackathon yet. +

+ {/if} +
+ + + {#if !preview} + + {/if} +
+
diff --git a/components/frontend/src/routes/(public)/hackathon/[id]/+page.svelte b/components/frontend/src/routes/(public)/hackathon/[id]/+page.svelte index 911c27c0..2a114b06 100644 --- a/components/frontend/src/routes/(public)/hackathon/[id]/+page.svelte +++ b/components/frontend/src/routes/(public)/hackathon/[id]/+page.svelte @@ -1,9 +1,5 @@ - + - -
-
- {#if hackathon.description} - -
- -
- {:else} - -

- The organizers have not written a description for this hackathon yet. -

- {/if} -
- - - -
From 8c110e730c6bf2437028fc96f95866988c26c708 Mon Sep 17 00:00:00 2001 From: Sabine Maennel <5292683+sabinem@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:56:49 +0200 Subject: [PATCH 07/10] =?UTF-8?q?feat(frontend):=20the=20public=20page=20e?= =?UTF-8?q?ditor=20shows=20what=20it=20is=20about=20to=20publish=20Editing?= =?UTF-8?q?=20a=20hackathon=20meant=20typing=20a=20logo=20URL=20into=20a?= =?UTF-8?q?=20field=20and=20finding=20out=20what=20it=20looked=20like=20by?= =?UTF-8?q?=20visiting=20the=20page=20afterwards.=20The=20form=20now=20sit?= =?UTF-8?q?s=20beside=20the=20public=20page=20itself,=20rendered=20from=20?= =?UTF-8?q?what=20is=20currently=20typed=20and=20updating=20as=20it=20is?= =?UTF-8?q?=20typed=20=E2=80=94=20the=20same=20component=20the=20public=20?= =?UTF-8?q?route=20renders,=20not=20an=20impression=20of=20it.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `value` becomes a `$bindable` prop on ImageUrlField and MarkdownEditor so the preview reads the fields rather than keeping a second copy that can disagree with them. Callers passing `value` one-way are unaffected. The field's own preview was a 64px square box captioned "This is what will be shown", which was true of no shape but a square. It draws with `StoredImage` now, so the form, About and the public page agree. The help text also says how to get a direct link at all — right-click the picture and choose Copy image address — because that menu item is the whole difference between a working URL and a share link, and it is not something most people have had a reason to notice. The page was still written against the Skeleton palette it was built on — `text-surface-950-50`, `border-surface-200-800`, `preset-filled-primary-500` — none of which exist as utilities any more, so the form had been rendering unstyled. It is on the theme's own `field`, `field-label`, `btn-solid` and `--hk-*` tokens now. --- .../lib/components/forms/ImageUrlField.svelte | 55 ++-- .../components/forms/MarkdownEditor.svelte | 19 +- .../hackathon/[id]/manage/edit/+page.svelte | 244 +++++++++++------- 3 files changed, 192 insertions(+), 126 deletions(-) diff --git a/components/frontend/src/lib/components/forms/ImageUrlField.svelte b/components/frontend/src/lib/components/forms/ImageUrlField.svelte index 2a02dd5a..bd1e9258 100644 --- a/components/frontend/src/lib/components/forms/ImageUrlField.svelte +++ b/components/frontend/src/lib/components/forms/ImageUrlField.svelte @@ -1,11 +1,12 @@
@@ -220,7 +219,7 @@ {id} {name} bind:this={area} - bind:value={text} + bind:value={value} {rows} {placeholder} {required} @@ -246,8 +245,8 @@ markup behind `display: none`. On the 10,000-character descriptions four of these forms allow, that is not free. --> {#if mode === 'preview'} - {#if text.trim()} - + {#if value.trim()} + {:else}

Nothing to preview yet.

{/if} diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/edit/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/edit/+page.svelte index ba2256a7..8536120e 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/edit/+page.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/edit/+page.svelte @@ -2,6 +2,7 @@ import { resolve } from '$app/paths'; import ImageUrlField from '$lib/components/forms/ImageUrlField.svelte'; import MarkdownEditor from '$lib/components/forms/MarkdownEditor.svelte'; + import PublicHackathonView from '$lib/components/hackathon/PublicHackathonView.svelte'; import type { ActionData, PageData } from './$types'; let { data, form }: { data: PageData; form: ActionData } = $props(); @@ -17,127 +18,184 @@ return new Date(date.getTime() - offset * 60_000).toISOString().slice(0, 10); } + // Built as a local date rather than through `new Date('2026-10-24')`, which + // parses as UTC midnight and can render as the day before in a western + // timezone. The preview would then show a date the form does not. + function fromDateInputValue(v: string): Date | undefined { + const m = v.match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (!m) return undefined; + return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])); + } + // TODO(backend: hackathon-edit-clear-dates): once a hackathon has dates, // emptying both fields is silently ignored rather than clearing them — see // the matching TODO in +page.server.ts. Naming the limitation here beats // letting someone clear the fields and believe it worked. const hasDates = $derived(Boolean(hackathon.startsAt || hackathon.endsAt)); - const FIELD_CLASS = - 'h-9 w-full rounded-none border border-surface-200-800 bg-surface-50-950 px-3 text-xs ' + - 'text-surface-950-50 placeholder:text-surface-700-300 focus:border-primary-500 ' + - 'focus:outline-none'; - const LABEL_CLASS = 'flex flex-col gap-1 text-xs font-semibold text-surface-500'; + // Every field the public page reads, held here so the preview beside the + // form can be driven by what is typed rather than by what was last saved. + // The inputs keep their `name` attributes and the form still posts normally + // — this is a second reader of the same controls, not a replacement for + // them. + let draft = $state({ + name: hackathon.name, + visibility: hackathon.visibility === PRIVATE ? 'private' : 'public', + startsAt: toDateInputValue(hackathon.startsAt), + endsAt: toDateInputValue(hackathon.endsAt), + logo: hackathon.logo ?? '', + description: hackathon.description ?? '', + }); + + const isPublic = $derived(draft.visibility === 'public'); +
← Back to Settings -

Edit Hackathon

+

Public page

+

+ The name, dates, picture and description everyone sees — visitors on the + hackathon's public page, and members on About. +

- -
- {#if form?.message} - - {/if} - -
- +
+ + + {#if form?.message} + + {/if} -
- Visibility -
- - + + {#if hasDates} +

+ Dates can be changed but not removed. +

+ {/if} + + - - -
+
+ +

What visitors see

- -
- - -
+ +

+ {#if isPublic} + Updates as you type. Nothing here is saved until you press Save + changes. + {:else} + This hackathon is private, so nobody can reach this page yet — it + is how it would look once you make it public. + {/if} +

- - +
+ +
+
+
From c7105d349881d3ccd022b80069feeff9c88cb9c9 Mon Sep 17 00:00:00 2001 From: Sabine Maennel <5292683+sabinem@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:58:14 +0200 Subject: [PATCH 08/10] feat(frontend): Manage Public Page is a tab rather than a pencil The editor was reached from a pencil button beside the Settings heading. That was the right shape while it was a form for correcting a name; it is the wrong one now that it carries a preview of the page, which is somewhere an organiser goes back to rather than a correction to the heading it sat on. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It carries its own gate, which is the per-entry check `manageNav`'s own note anticipated: `canEditHackathon` additionally requires the owner be confirmed, and that route's load answers anyone else with a 403. Listing it unconditionally would break the stronger promise the section keeps — never offer a link that then refuses. Two tests recorded the old arrangement and now record the new one: a waitlisted owner is withheld exactly this one entry and no other, and `/manage/edit` lights the tab rather than leaving Settings lit beneath it. --- .../frontend/src/lib/navigation/items.test.ts | 25 ++++++++----- .../frontend/src/lib/navigation/items.ts | 36 +++++++++++++++---- .../my/hackathon/[id]/manage/+page.svelte | 21 +++-------- 3 files changed, 51 insertions(+), 31 deletions(-) diff --git a/components/frontend/src/lib/navigation/items.test.ts b/components/frontend/src/lib/navigation/items.test.ts index d040fffa..f0e605e4 100644 --- a/components/frontend/src/lib/navigation/items.test.ts +++ b/components/frontend/src/lib/navigation/items.test.ts @@ -498,10 +498,19 @@ describe("manageNav", () => { // Mirrors the backend, which does not consult isWaiting for track:write or // phase:write either. - it("does not withhold management from a waitlisted owner", () => { - expect( - idsOf(manageNav("hack-1", { role: ROLE_OWNER, isWaiting: true }, false)), - ).toEqual(idsOf(manageNav("hack-1", owner, false))) + // Manage Public Page is the one entry a waitlisted owner does not get, because + // its page answers anyone unconfirmed with a 403 (`canEditHackathon`, enforced + // in that route's load). Withholding it is the nav keeping its stronger + // promise — never offer a link that then refuses. Every other entry is offered + // to a waitlisted owner exactly as to a confirmed one. + it("withholds only the public-page entry from a waitlisted owner", () => { + const waiting = idsOf( + manageNav("hack-1", { role: ROLE_OWNER, isWaiting: true }, false), + ) + const confirmed = idsOf(manageNav("hack-1", owner, false)) + + expect(confirmed).toContain("manage:public") + expect(waiting).toEqual(confirmed.filter((id) => id !== "manage:public")) }) // The exact paths are the compiler's job — `resolve()` takes SvelteKit's @@ -558,11 +567,11 @@ describe("manageNav", () => { "manage:participants", ], ["/my/hackathon/hack-1/teams/manage", "manage:teams"], - // The hackathon's own edit form, for the same reason as the phase forms - // below: it is reached from Settings and nests under it, so that entry - // stays lit rather than nothing being lit at all. ["/my/hackathon/hack-1/manage", "manage:settings"], - ["/my/hackathon/hack-1/manage/edit", "manage:settings"], + // The public page editor has an entry of its own now, so it lights that + // rather than leaving Settings lit beneath it. It still nests under + // `/manage`, which is what longest-match has to resolve correctly. + ["/my/hackathon/hack-1/manage/edit", "manage:public"], // Nested under Settings too, but with an entry of its own — longest match // is what keeps Settings from swallowing it. ["/my/hackathon/hack-1/manage/forms", "manage:forms"], diff --git a/components/frontend/src/lib/navigation/items.ts b/components/frontend/src/lib/navigation/items.ts index ffdd3bfb..8428d640 100644 --- a/components/frontend/src/lib/navigation/items.ts +++ b/components/frontend/src/lib/navigation/items.ts @@ -13,6 +13,7 @@ import type { ComponentType } from "svelte" import { resolve } from "$app/paths" import { + canEditHackathon, canManageHackathon, type ViewerMembership, } from "$lib/utils/hackathonRole" @@ -28,6 +29,7 @@ import UserRoundCog from "lucide-svelte/icons/user-round-cog" import Send from "lucide-svelte/icons/send" import Ticket from "lucide-svelte/icons/ticket" import CalendarClock from "lucide-svelte/icons/calendar-clock" +import Globe from "lucide-svelte/icons/globe" import CalendarCog from "lucide-svelte/icons/calendar-cog" import FileText from "lucide-svelte/icons/file-text" import Info from "lucide-svelte/icons/info" @@ -307,13 +309,14 @@ export function platformNav(roles: { isGlobalAdmin: boolean }): NavItem[] { * management route's own load, all reduce to owner-or-admin — casbin grants * `hackathon:write` / `phase:write` / `page:write` / `track:write` to `Owner` * outright and to an admin through the global escape hatch, with no capability - * gating any of them. So this never offers a link that then refuses. Add a - * per-entry gate the day an entry needs a narrower one rather than widening this - * one: `/edit` would be the first, since `canEditHackathon` also requires the - * owner be confirmed — which is why it is offered on the Settings page itself, - * behind that check of its own, rather than listed here. It nests under - * `/manage` like every other single-record form, so Settings stays lit while it - * is open. `isWaiting` is deliberately not consulted; nor does the backend. + * gating any of them. So this never offers a link that then refuses. Where an + * entry needs a narrower gate it carries its own rather than widening this one: + * Manage Public Page is the one such entry, because `canEditHackathon` also + * requires the owner be confirmed. It nests under `/manage` like every other + * single-record form, but it is a destination of its own, so it is listed here + * rather than reached from a control on the Settings page. `isWaiting` is not + * consulted for any other entry, and nor does the backend consult it for the + * capabilities behind them. * * Entries follow the order of the participant entries they extend, and most are * nested under that entry's route (`/teams/manage` under `/teams`) so @@ -388,6 +391,25 @@ export function manageNav( ? { badge: "!", badgeVariant: "badge-warning" as const } : {}), }, + // The hackathon as everyone outside it sees it: the name, dates, picture and + // description on the public page, with that page previewed beside the fields + // as they are typed. A tab of its own rather than a pencil tucked into + // Settings — it is a destination an organiser returns to, not a one-off + // correction to the row it sat on. + // Gated on its own rather than on the section gate above, which is the + // per-entry check the note on `manageNav` anticipated: `canEditHackathon` + // additionally requires the owner be confirmed, so listing it unconditionally + // would offer a manager a form the backend then refuses. + ...(canEditHackathon(membership, isGlobalAdmin) + ? [ + { + id: "manage:public", + label: "Manage Public Page", + icon: Globe, + href: resolve(`/my/hackathon/${hackathonId}/manage/edit`), + }, + ] + : []), // Both halves of the roster: the confirmed people, and the queue waiting to // be let in, as two tabs of the one destination. The participant page lists // the same confirmed people and offers nothing to act on — every decision diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/+page.svelte index d41bb8e5..f41d477d 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/+page.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/+page.svelte @@ -1,9 +1,8 @@ @@ -157,7 +143,7 @@ {#snippet rows(items: Hackathon[])}
- {#each items as h, i (h.id)} + {#each items as h (h.id)} {/each}
From d838a2609684b142002c0d597db314fbf4378cbf Mon Sep 17 00:00:00 2001 From: Sabine Maennel <5292683+sabinem@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:07:14 +0200 Subject: [PATCH 10/10] fix(frontend): the image field claims only what it can see MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "This is what will be shown" was a promise about a page, and the field is in no position to make one: a hackathon's picture is drawn whole, and a project's is cropped to a round thumbnail, from the same field. It now says the link works and shows the picture it loads, which is true wherever the field appears. The claim about a page belongs to the preview on Manage Public Page, which is an actual rendering of that page. Both project forms name the field for what it holds — a project image rather than an image — since the hackathon forms next door ask for a logo. --- .../src/lib/components/forms/ImageUrlField.svelte | 10 +++++++++- .../src/lib/components/forms/ImageUrlField.test.ts | 5 ++++- .../lib/components/hackathon/ProjectEditForm.svelte | 2 +- .../my/hackathon/[id]/projects/propose/+page.svelte | 2 +- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/components/frontend/src/lib/components/forms/ImageUrlField.svelte b/components/frontend/src/lib/components/forms/ImageUrlField.svelte index bd1e9258..d01393a1 100644 --- a/components/frontend/src/lib/components/forms/ImageUrlField.svelte +++ b/components/frontend/src/lib/components/forms/ImageUrlField.svelte @@ -115,7 +115,15 @@ {#if checking} Checking the link… {:else if showImage} - This is what will be shown. + + The link works — this is the picture. {:else}