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}

Nothing to preview yet.

{/if} diff --git a/components/frontend/src/lib/components/hackathon/HackathonRow.svelte b/components/frontend/src/lib/components/hackathon/HackathonRow.svelte index 027b7bdc..5c27fb3f 100644 --- a/components/frontend/src/lib/components/hackathon/HackathonRow.svelte +++ b/components/frontend/src/lib/components/hackathon/HackathonRow.svelte @@ -1,17 +1,17 @@ - +
- {#if imageUrl} - - {/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/ProjectEditForm.svelte b/components/frontend/src/lib/components/hackathon/ProjectEditForm.svelte index 4006252a..74ad525a 100644 --- a/components/frontend/src/lib/components/hackathon/ProjectEditForm.svelte +++ b/components/frontend/src/lib/components/hackathon/ProjectEditForm.svelte @@ -1,5 +1,6 @@ +{#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/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 @@ @@ -70,12 +56,15 @@ class="relative flex min-h-[30rem] flex-col items-center justify-center gap-6 overflow-hidden px-4 pt-8 pb-12 text-center sm:px-10 md:px-20" > + -
+

@@ -154,15 +143,14 @@ {#snippet rows(items: Hackathon[])}
- {#each items as h, i (h.id)} + {#each items as h (h.id)} {/each}
diff --git a/components/frontend/src/routes/(public)/hackathon/[id]/+page.server.ts b/components/frontend/src/routes/(public)/hackathon/[id]/+page.server.ts index 157816bc..a656247c 100644 --- a/components/frontend/src/routes/(public)/hackathon/[id]/+page.server.ts +++ b/components/frontend/src/routes/(public)/hackathon/[id]/+page.server.ts @@ -102,6 +102,7 @@ export const load: PageServerLoad = async (event) => { id: hackathon.id, name: hackathon.name, description: hackathon.description ?? "", + logo: hackathon.logo, startsAt: hackathon.startsAt, endsAt: hackathon.endsAt, status: hackathon.status, diff --git a/components/frontend/src/routes/(public)/hackathon/[id]/+page.svelte b/components/frontend/src/routes/(public)/hackathon/[id]/+page.svelte index 4f1c28ba..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} -
- - - -