Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -190,7 +176,7 @@
<p class="text-sm text-ink-3">You are not connected to any hackathons yet.</p>
{:else}
<div class="card overflow-hidden">
{#each myHackathons as h, i (h.id)}
{#each myHackathons as h (h.id)}
{@const mem = h.viewerMembership}
<!-- Two questions, not one: whether the row is a link at
all, and which page it opens. Someone who runs this
Expand Down Expand Up @@ -229,11 +215,10 @@
<HackathonRow
{href}
name={h.name}
imageUrl={h.logo}
meta={formatMeta(h)}
badge={statusLabel(h.status)}
badgeVariant={statusBadgeVariant(h.status)}
gradFrom={gradient(i).from}
gradTo={gradient(i).to}
/>
</div>
<!-- The row's only trailing element. Editing a
Expand Down Expand Up @@ -276,7 +261,7 @@
<p class="text-sm text-ink-3">No other hackathons available.</p>
{:else}
<div class="card overflow-hidden">
{#each otherHackathons as h, i (h.id)}
{#each otherHackathons as h (h.id)}
<div class="flex items-center border-b border-line last:border-0">
<div class="flex-1">
<!-- No href: a non-member holds no `hackathon:read`
Expand All @@ -285,11 +270,10 @@
403. Joining is the only thing offered here. -->
<HackathonRow
name={h.name}
imageUrl={h.logo}
meta={formatMeta(h)}
badge={statusLabel(h.status)}
badgeVariant={statusBadgeVariant(h.status)}
gradFrom={gradient(i).from}
gradTo={gradient(i).to}
/>
</div>
<!-- A finished hackathon gets no button and no label:
Expand Down
158 changes: 158 additions & 0 deletions components/frontend/src/lib/components/forms/ImageUrlField.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
<script lang="ts">
import { adviseImageUrl, usableImage } from '$lib/utils/imageUrl';
import { isHttpUrl } from '$lib/utils/url';
import StoredImage from '$lib/components/hackathon/StoredImage.svelte';

let {
name,
label,
value = $bindable(''),
placeholder = 'https://…',
fieldClass = 'field',
labelClass = 'field-label',
class: wrapperClass = '',
}: {
name: string;
/** The caption above the input, e.g. "Logo URL (optional)". */
label: string;
value?: string;
placeholder?: string;
/** The host form's input and label classes — two of the four forms using
* this still carry their own pair rather than the `field`/`field-label`
* utilities, and a field ignoring them would be the one odd control on
* the page. */
fieldClass?: string;
labelClass?: string;
class?: string;
} = $props();

// A wrapping <label> would have to contain the button below, and a label
// holding interactive content is a trap for anyone reaching it by keyboard.
// Explicit `for` instead — the same shape the markdown fields already use.
const fieldId = `image-url-${name}`;

// There is no upload anywhere on the platform: the picture is whatever URL
// this field ends up holding. So the field has to be where a bad link is
// caught — after the save there is nothing left but a broken image on
// somebody else's page, with no hint of which link did it.
//
// `value` is that text and it is bindable, so a page previewing what it is
// about to save reads the address from the field itself rather than keeping
// a second copy of it that can drift.

// The address the preview is currently trying. A beat behind the field, so a
// half-typed host is not requested character by character and the verdict
// below does not contradict itself mid-word.
let probed = $state(value.trim());
$effect(() => {
const next = value.trim();
const timer = setTimeout(() => (probed = next), 500);
return () => clearTimeout(timer);
});

// Loading it is the only honest test. Pattern-matching names the share links
// people actually paste, but it cannot tell a direct URL that 404s from one
// that works, and those are half the failures. An `<img>` is also the one
// probe with no CORS to satisfy and no server of ours in the middle: exactly
// what the real page will do, done early enough to fix.
//
// Both recorded as *which* address settled rather than as a boolean, so
// correcting the field leaves the verdict behind on its own.
let loaded = $state('');
let failed = $state('');

const advice = $derived(adviseImageUrl(probed));
const previewable = $derived(probed !== '' && isHttpUrl(probed));
const showImage = $derived(previewable && usableImage(probed, failed));
const checking = $derived(showImage && loaded !== probed);
</script>

<div class="{labelClass} {wrapperClass}">
<label for={fieldId}>{label}</label>
<input
id={fieldId}
type="url"
{name}
{placeholder}
bind:value={value}
class={fieldClass}
/>

<!-- The instruction, not just the rule. "A link to the image file itself"
describes the destination without saying how to get one, and the way to
get one is a menu item most people have never had a reason to notice. -->
<span class="font-normal text-ink-3">
A link to the image file itself — a share or page link will not render. In
most browsers: right-click the picture and choose
<span class="text-ink-2">Copy image address</span>
(Firefox calls it <span class="text-ink-2">Copy Image Link</span>).
</span>

{#if probed !== ''}
<!-- Deliberately not `aria-live`: this settles a beat after typing stops
and would interrupt somebody still filling the form in. It sits
beside the field they are already looking at. -->
<div class="flex flex-col items-start gap-2 pt-1 font-normal">
{#if showImage}
<!-- The same component the public page and About draw with, at a
smaller height cap. A 64px square box stood here saying "this
is what will be shown", which was true of no shape but a
square: a wide banner was boxed and a poster shrunk to a
stamp, and neither looked like the page it was previewing.
Kept in the tree while it loads rather than swapped in on
success — the load *is* the check, so something has to be
doing it, which is what the opacity is for. -->
<StoredImage
src={probed}
maxHeight="max-h-40"
class="max-w-sm {checking ? 'opacity-0' : ''}"
onload={() => (loaded = probed)}
onerror={() => (failed = probed)}
/>
{/if}

<span class="flex min-w-0 flex-col items-start gap-1">
{#if checking}
<span class="text-ink-3">Checking the link…</span>
{:else if showImage}
<!-- What this field can honestly claim: the address loads, and
this is the picture it loads. Not "this is what will be
shown" — that is a promise about a page, and it only held
where the page draws with `StoredImage` too. A project's
image is still cropped to a round thumbnail, so the
sentence was false there. The page-level claim belongs to
the preview on Manage Public Page, which is an actual
rendering of the page. -->
<span class="text-ink-2">The link works — this is the picture.</span>
{:else}
<!-- Two separate claims, so two lines: the first is
demonstrated, the second is a guess at the cause and is
only offered for an address whose shape we recognise. -->
<span class="text-danger-ink">
{previewable
? 'This link does not load as an image.'
: 'This is not a link a browser can open.'}
</span>
{/if}

{#if advice.problem}
<span class="text-ink-3">{advice.problem}</span>
{/if}

{#if advice.direct}
<!-- Offered, not applied. Rewriting the field under someone
mid-form takes the address away from them; a button they
press leaves them holding the decision, with the preview
beside it showing what pressing it gets. -->
<button
type="button"
class="btn btn-sm btn-outline"
onclick={() => (value = advice.direct ?? value)}
>
Use the direct link
</button>
{/if}
</span>
</div>
{/if}
</div>
153 changes: 153 additions & 0 deletions components/frontend/src/lib/components/forms/ImageUrlField.test.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLImageElement>("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")
})
})
Loading
Loading