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 @@
+
+
+
+ The organizers have not written a description for this hackathon yet. +
+ {/if} +- The organizers have not written a description for this hackathon yet. -
- {/if} -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'); ++ The name, dates, picture and description everyone sees — visitors on the + hackathon's public page, and members on About. +