diff --git a/components/frontend/src/lib/components/dashboard/DashboardView.svelte b/components/frontend/src/lib/components/dashboard/DashboardView.svelte
index 09934437..8bfa33d2 100644
--- a/components/frontend/src/lib/components/dashboard/DashboardView.svelte
+++ b/components/frontend/src/lib/components/dashboard/DashboardView.svelte
@@ -23,6 +23,8 @@
interface HackathonEntry {
id: string;
name: string;
+ /** The hackathon's logo, drawn in the row's thumbnail when it loads. */
+ logo?: string;
startsAt?: Date;
endsAt?: Date;
status: number;
@@ -89,22 +91,6 @@
// the section vanish entirely for everyone else.
const adminItems = $derived(platformNav({ isGlobalAdmin }));
- // Decorative thumbnails for hackathons with no image of their own. Each
- // stop is derived from a theme token and darkened rather than naming a
- // palette step, so the set retunes with the theme instead of drifting from
- // it — and so it survives the secondary/tertiary palettes being removed.
- const GRADIENTS = [
- { from: 'var(--color-accent)', to: 'color-mix(in oklab, var(--color-accent) 35%, black)' },
- { from: 'var(--color-info)', to: 'color-mix(in oklab, var(--color-info) 35%, black)' },
- {
- from: 'var(--color-success)',
- to: 'color-mix(in oklab, var(--color-success) 35%, black)',
- },
- ];
-
- function gradient(i: number) {
- return GRADIENTS[i % GRADIENTS.length]!;
- }
function formatMeta(h: HackathonEntry): string {
const fmt = (d: Date) =>
@@ -190,7 +176,7 @@
You are not connected to any hackathons yet.
{:else}
- {#each myHackathons as h, i (h.id)}
+ {#each myHackathons as h (h.id)}
{@const mem = h.viewerMembership}
+
+ A link to the image file itself — a share or page link will not render. In
+ most browsers: right-click the picture and choose
+ Copy image address
+ (Firefox calls it Copy Image Link).
+
+
+ {#if probed !== ''}
+
+
+ {#if showImage}
+
+ (loaded = probed)}
+ onerror={() => (failed = probed)}
+ />
+ {/if}
+
+
+ {#if checking}
+ Checking the link…
+ {:else if showImage}
+
+ The link works — this is the picture.
+ {: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..9368b70d
--- /dev/null
+++ b/components/frontend/src/lib/components/forms/ImageUrlField.test.ts
@@ -0,0 +1,153 @@
+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()
+
+ // The wording is deliberately about the link and the picture rather than
+ // about the page: the field cannot promise how a given page draws it, and
+ // a project still crops its image to a round thumbnail.
+ expect(screen.getByText(/this is the picture/i)).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/forms/MarkdownEditor.svelte b/components/frontend/src/lib/components/forms/MarkdownEditor.svelte
index bea451e1..f895f70e 100644
--- a/components/frontend/src/lib/components/forms/MarkdownEditor.svelte
+++ b/components/frontend/src/lib/components/forms/MarkdownEditor.svelte
@@ -28,7 +28,7 @@
let {
id,
name,
- value = '',
+ value = $bindable(''),
rows = 8,
placeholder = '',
required = false,
@@ -46,7 +46,6 @@
maxlength?: number;
} = $props();
- let text = $state(value);
let mode: 'write' | 'preview' = $state('write');
let area: HTMLTextAreaElement | undefined = $state();
@@ -98,10 +97,10 @@
async function apply(transform: (edit: Edit) => Edit | null) {
if (!area) return;
- const next = transform({ value: text, start: area.selectionStart, end: area.selectionEnd });
+ const next = transform({ value, start: area.selectionStart, end: area.selectionEnd });
if (!next) return;
- const { from, to, text: inserted } = diffRange(text, next.value);
+ const { from, to, text: inserted } = diffRange(value, next.value);
// No transform currently returns its input unchanged, but if one ever
// does, `execCommand('delete')` on a collapsed selection below would eat
// the character before the caret instead of doing nothing.
@@ -116,7 +115,7 @@
? document.execCommand('insertText', false, inserted)
: document.execCommand('delete'));
- if (!undoable) text = next.value;
+ if (!undoable) value = next.value;
await tick();
area.setSelectionRange(next.start, next.end);
@@ -132,7 +131,7 @@
// the decision and the edit cannot disagree.
if (!area) return;
const next = continueList({
- value: text,
+ value,
start: area.selectionStart,
end: area.selectionEnd,
});
@@ -151,7 +150,7 @@
void apply(run);
}
- const remaining = $derived(maxlength ? maxlength - text.length : 0);
+ const remaining = $derived(maxlength ? maxlength - value.length : 0);
@@ -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}
+{/if}
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/lib/utils/imageUrl.test.ts b/components/frontend/src/lib/utils/imageUrl.test.ts
new file mode 100644
index 00000000..aa9846c0
--- /dev/null
+++ b/components/frontend/src/lib/utils/imageUrl.test.ts
@@ -0,0 +1,126 @@
+import { describe, it, expect } from "vitest"
+import { adviseImageUrl, usableImage } from "./imageUrl"
+
+describe("adviseImageUrl", () => {
+ it("says nothing about an empty field", () => {
+ expect(adviseImageUrl("")).toEqual({})
+ expect(adviseImageUrl(" ")).toEqual({})
+ })
+
+ it("says nothing about an ordinary image address", () => {
+ expect(adviseImageUrl("https://example.org/logo.png")).toEqual({})
+ })
+
+ it("ignores surrounding whitespace from a paste", () => {
+ expect(adviseImageUrl(" https://example.org/logo.png ")).toEqual({})
+ })
+
+ it("rejects what is not a web address", () => {
+ expect(adviseImageUrl("logo.png").problem).toMatch(/https:\/\//)
+ expect(adviseImageUrl("/images/logos/psi.png").problem).toMatch(
+ /https:\/\//,
+ )
+ })
+
+ it("rejects a non-http scheme", () => {
+ expect(adviseImageUrl("javascript:alert(1)").problem).toBeDefined()
+ expect(adviseImageUrl("data:image/png;base64,AAAA").problem).toBeDefined()
+ })
+
+ it("turns a GitHub blob page into its raw address", () => {
+ expect(
+ adviseImageUrl("https://github.com/acme/site/blob/main/docs/logo.png"),
+ ).toEqual({
+ problem: expect.stringContaining("GitHub"),
+ direct: "https://raw.githubusercontent.com/acme/site/main/docs/logo.png",
+ })
+ })
+
+ it("leaves a raw.githubusercontent address alone", () => {
+ expect(
+ adviseImageUrl(
+ "https://raw.githubusercontent.com/acme/site/main/logo.png",
+ ),
+ ).toEqual({})
+ })
+
+ it("leaves a GitHub address that is not a blob page alone", () => {
+ expect(adviseImageUrl("https://github.com/acme/site")).toEqual({})
+ })
+
+ it("turns a Dropbox share link into the inline form", () => {
+ const advice = adviseImageUrl("https://www.dropbox.com/s/abc/logo.png?dl=0")
+ expect(advice.problem).toContain("Dropbox")
+ expect(advice.direct).toBe("https://www.dropbox.com/s/abc/logo.png?raw=1")
+ })
+
+ it("leaves a Dropbox link that already asks for raw alone", () => {
+ expect(
+ adviseImageUrl("https://www.dropbox.com/s/abc/logo.png?raw=1"),
+ ).toEqual({})
+ })
+
+ it("refuses Google Drive without offering a rewrite", () => {
+ const advice = adviseImageUrl(
+ "https://drive.google.com/file/d/1AbC/view?usp=sharing",
+ )
+ expect(advice.problem).toContain("Drive")
+ expect(advice.direct).toBeUndefined()
+ })
+
+ it("names the other share-link hosts", () => {
+ expect(
+ adviseImageUrl("https://photos.app.goo.gl/abc").problem,
+ ).toBeDefined()
+ expect(adviseImageUrl("https://1drv.ms/i/s!abc").problem).toBeDefined()
+ expect(
+ adviseImageUrl("https://acme.sharepoint.com/sites/x/logo.png").problem,
+ ).toBeDefined()
+ expect(adviseImageUrl("https://imgur.com/a/abc").problem).toBeDefined()
+ expect(
+ adviseImageUrl("https://www.flickr.com/photos/someone/123").problem,
+ ).toBeDefined()
+ expect(
+ adviseImageUrl("https://unsplash.com/photos/a-cat-abc123").problem,
+ ).toBeDefined()
+ })
+
+ it("leaves the image hosts of those services alone", () => {
+ expect(adviseImageUrl("https://i.imgur.com/abc.png")).toEqual({})
+ expect(adviseImageUrl("https://live.staticflickr.com/1/2_b.jpg")).toEqual(
+ {},
+ )
+ expect(adviseImageUrl("https://images.unsplash.com/photo-123")).toEqual({})
+ })
+
+ it("does not mistake a lookalike domain for the real host", () => {
+ // `endsWith('.github.com')` rather than `includes('github.com')`: a host
+ // ending in `notgithub.com` is somebody else's site entirely.
+ expect(
+ adviseImageUrl("https://notgithub.com/acme/site/blob/main/logo.png"),
+ ).toEqual({})
+ })
+})
+
+describe("usableImage", () => {
+ it("refuses an absent or empty address", () => {
+ expect(usableImage(undefined, undefined)).toBe(false)
+ expect(usableImage("", undefined)).toBe(false)
+ })
+
+ it("accepts an address that has not failed", () => {
+ expect(usableImage("https://example.org/a.png", undefined)).toBe(true)
+ })
+
+ it("refuses the exact address that failed", () => {
+ expect(
+ usableImage("https://example.org/a.png", "https://example.org/a.png"),
+ ).toBe(false)
+ })
+
+ it("tries a corrected address even though the previous one failed", () => {
+ expect(
+ usableImage("https://example.org/b.png", "https://example.org/a.png"),
+ ).toBe(true)
+ })
+})
diff --git a/components/frontend/src/lib/utils/imageUrl.ts b/components/frontend/src/lib/utils/imageUrl.ts
new file mode 100644
index 00000000..22b8f4cc
--- /dev/null
+++ b/components/frontend/src/lib/utils/imageUrl.ts
@@ -0,0 +1,148 @@
+// Nobody can upload a picture: `Project.image` and `Hackathon.logo` are single
+// string columns, and there is no file storage behind them. So every image on
+// the platform is a URL somebody typed, and the commonest thing they type is a
+// *share* link — the page their cloud drive showed them after pressing Share.
+//
+// A share link serves HTML, not bytes. Dropped into an `` it fails, and
+// the only sign of it is a broken-image glyph on the page the picture was meant
+// for, long after the form was submitted. These helpers move that discovery back
+// into the form: `adviseImageUrl` names the mistake before the save, and
+// `usableImage` keeps the failure from ever reaching a reader.
+
+import { isHttpUrl } from "./url"
+
+export interface ImageUrlAdvice {
+ /**
+ * Why this address will not load as an image, in our words — absent when
+ * nothing is known to be wrong with it. Knowing nothing is not the same as
+ * approval: only the preview can actually tell whether a URL serves an image.
+ */
+ problem?: string
+ /**
+ * The same picture as bytes, when it can be derived from what was typed.
+ * Offered as a one-press correction, never applied silently — the address in
+ * the field should stay the one the person put there until they say so.
+ */
+ direct?: string
+}
+
+/** Whether `host` is `domain` itself or a subdomain of it. */
+function isHost(host: string, domain: string): boolean {
+ return host === domain || host.endsWith(`.${domain}`)
+}
+
+/**
+ * What is wrong with `value` as an image address, as far as can be told without
+ * fetching it.
+ *
+ * Only reports what is *certain*: every case below is a URL whose server is
+ * known to answer with a web page. A URL that passes still has to prove itself
+ * by loading — see `ImageUrlField`, where this advice explains a failure the
+ * preview has already demonstrated.
+ */
+export function adviseImageUrl(value: string): ImageUrlAdvice {
+ const trimmed = value.trim()
+ if (trimmed === "") return {}
+
+ if (!isHttpUrl(trimmed)) {
+ return {
+ problem: "This is not a web address — it has to start with https://",
+ }
+ }
+
+ const url = new URL(trimmed)
+ const host = url.hostname.toLowerCase()
+ const path = url.pathname
+
+ // GitHub's `blob` URL is the file *viewer*. The same path under
+ // raw.githubusercontent.com is the file, which is a documented, stable
+ // rewrite — hence the only two corrections offered here are this and Dropbox.
+ if (isHost(host, "github.com")) {
+ const blob = path.match(/^\/([^/]+)\/([^/]+)\/blob\/(.+)$/)
+ if (blob) {
+ return {
+ problem:
+ "A GitHub file page shows the picture inside GitHub's own page.",
+ direct: `https://raw.githubusercontent.com/${blob[1]}/${blob[2]}/${blob[3]}`,
+ }
+ }
+ }
+
+ if (isHost(host, "dropbox.com")) {
+ // `?dl=0` is the preview page and `?dl=1` forces a download; `raw=1` is the
+ // file served inline, which is the one an `` can use.
+ if (url.searchParams.get("raw") !== "1") {
+ const direct = new URL(trimmed)
+ direct.searchParams.delete("dl")
+ direct.searchParams.set("raw", "1")
+ return {
+ problem: "A Dropbox share link opens Dropbox's preview page.",
+ direct: direct.toString(),
+ }
+ }
+ return {}
+ }
+
+ // No correction for the rest. Google Drive's old `uc?export=view` trick is
+ // rate-limited and increasingly refused, and the others have no documented
+ // direct form at all — offering a rewrite that works today and breaks next
+ // month is worse than saying plainly that this kind of link cannot be used.
+ if (isHost(host, "drive.google.com") || isHost(host, "docs.google.com")) {
+ return {
+ problem:
+ "A Google Drive share link opens Drive, and Drive will not serve the " +
+ "file to another site. Put the picture somewhere it is published on its own.",
+ }
+ }
+ if (isHost(host, "photos.google.com") || isHost(host, "photos.app.goo.gl")) {
+ return {
+ problem:
+ "A Google Photos link opens the Photos viewer, not the picture itself.",
+ }
+ }
+ if (
+ isHost(host, "onedrive.live.com") ||
+ isHost(host, "1drv.ms") ||
+ isHost(host, "sharepoint.com")
+ ) {
+ return {
+ problem: "A OneDrive or SharePoint share link opens their viewer page.",
+ }
+ }
+ // i.imgur.com is the image host; imgur.com itself serves album and gallery
+ // pages, and a bare post page too.
+ if (isHost(host, "imgur.com") && host !== "i.imgur.com") {
+ return {
+ problem:
+ "This is an Imgur page rather than the image file on i.imgur.com.",
+ }
+ }
+ if (isHost(host, "flickr.com") && path.startsWith("/photos/")) {
+ return { problem: "A Flickr photo page is a web page, not the image file." }
+ }
+ if (isHost(host, "unsplash.com") && path.startsWith("/photos/")) {
+ return {
+ problem:
+ "An Unsplash photo page is a web page — the Download button gives the image address.",
+ }
+ }
+
+ return {}
+}
+
+/**
+ * Whether `src` should be put in front of a reader.
+ *
+ * `failedSrc` is the address whose `` last raised `error`, recorded by the
+ * component drawing it. Comparing against it — rather than keeping a boolean —
+ * is what lets a corrected address be tried afresh: a component reused down a
+ * list of rows, or re-rendered after an edit, must not stay failed because some
+ * earlier URL was.
+ *
+ * Every surface showing a stored image address goes through this, because the
+ * alternative is the browser's broken-image glyph, which reads as a bug in the
+ * app rather than as a bad link somebody typed.
+ */
+export function usableImage(src?: string, failedSrc?: string): boolean {
+ return src !== undefined && src !== "" && failedSrc !== src
+}
diff --git a/components/frontend/src/routes/(app)/hackathons/create/+page.svelte b/components/frontend/src/routes/(app)/hackathons/create/+page.svelte
index bf088500..d01b1810 100644
--- a/components/frontend/src/routes/(app)/hackathons/create/+page.svelte
+++ b/components/frontend/src/routes/(app)/hackathons/create/+page.svelte
@@ -1,5 +1,6 @@
+