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
1,582 changes: 1,524 additions & 58 deletions client/package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,11 @@
"react-day-picker": "^9.8.0",
"react-dom": "^18.3.1",
"react-hook-form": "^7.60.0",
"react-markdown": "^9.1.0",
"react-resizable-panels": "^2.1.9",
"react-router-dom": "^6.26.2",
"recharts": "^2.12.7",
"remark-gfm": "^4.0.1",
"sonner": "^1.7.4",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
Expand Down
91 changes: 79 additions & 12 deletions client/src/components/admin/ProgramFormModal.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import {
Dialog,
DialogContent,
Expand All @@ -17,7 +17,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Loader2, Plus, X } from "lucide-react";
import { Loader2, Plus, Upload, X } from "lucide-react";
import { api, type ApiProgram } from "@/lib/api";
import { DEFAULT_PRIZE_TIERS } from "@/lib/constants";
import { useToast } from "@/hooks/use-toast";
Expand Down Expand Up @@ -107,6 +107,8 @@ export function ProgramFormModal({
const [prizeTierRows, setPrizeTierRows] = useState<PrizeTierRow[]>([]);
const [errors, setErrors] = useState<Record<string, string>>({});
const [submitting, setSubmitting] = useState(false);
const [uploadingCover, setUploadingCover] = useState(false);
const coverFileRef = useRef<HTMLInputElement>(null);
const { toast } = useToast();

useEffect(() => {
Expand Down Expand Up @@ -186,6 +188,38 @@ export function ProgramFormModal({
return Object.keys(e).length === 0;
};

const handleCoverFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
// Reset the input so picking the same file again re-fires onChange.
e.target.value = "";
if (!file) return;
if (!program?.slug) {
toast({
title: "Save the program first",
description: "Create the program, then upload a cover image.",
variant: "destructive",
});
return;
}
setUploadingCover(true);
try {
if (!signAuthHeader) throw new Error("No admin auth available");
const authHeader = await signAuthHeader();
const { data } = await api.uploadProgramCover(program.slug, file, authHeader);
setCoverImageUrl(data.url);
setErrors((prev) => ({ ...prev, coverImageUrl: "" }));
toast({ title: "Cover image uploaded", description: "Save the program to apply it." });
} catch (err) {
toast({
title: "Couldn't upload image",
description: (err as Error)?.message || "Unknown error",
variant: "destructive",
});
} finally {
setUploadingCover(false);
}
};

const handleSubmit = async () => {
if (!validate()) return;
setSubmitting(true);
Expand Down Expand Up @@ -426,16 +460,49 @@ export function ProgramFormModal({
</div>

<div className="sm:col-span-2 space-y-1.5">
<Label htmlFor="pf-cover" className="label-hw-dim">·COVER IMAGE URL (PUBLIC PAGE BANNER)</Label>
<Input
id="pf-cover"
type="url"
placeholder="https://…/cover.png"
value={coverImageUrl}
onChange={(e) => setCoverImageUrl(e.target.value)}
aria-invalid={errors.coverImageUrl ? true : undefined}
className="font-mono text-sm"
/>
<Label htmlFor="pf-cover" className="label-hw-dim">·COVER IMAGE (PUBLIC PAGE BANNER)</Label>
<div className="flex items-center gap-2">
<Input
id="pf-cover"
type="url"
placeholder="https://…/cover.png"
value={coverImageUrl}
onChange={(e) => setCoverImageUrl(e.target.value)}
aria-invalid={errors.coverImageUrl ? true : undefined}
className="font-mono text-sm"
/>
<input
ref={coverFileRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
onChange={handleCoverFile}
className="hidden"
/>
<button
type="button"
onClick={() => coverFileRef.current?.click()}
disabled={uploadingCover || !editing}
title={editing ? "Upload an image file" : "Save the program first, then upload"}
className="shrink-0 font-mono text-[10px] tracking-[0.14em] border border-hairline text-display hover:bg-panel-deep disabled:opacity-50 px-3 py-2 inline-flex items-center gap-1.5"
>
{uploadingCover ? (
<Loader2 className="h-3 w-3 animate-spin" aria-hidden="true" />
) : (
<Upload className="h-3 w-3" aria-hidden="true" />
)}
UPLOAD
</button>
</div>
<p className="label-hw-dim">
Paste a public URL or upload a PNG, JPEG, WebP, or GIF (max 5MB). Uploading is available after the program is created.
</p>
{coverImageUrl.trim() && (
<img
src={coverImageUrl}
alt="Cover preview"
className="mt-1 max-h-32 w-auto rounded-sm border border-hairline object-contain"
/>
)}
{errors.coverImageUrl && (
<p className="label-hw text-destructive">·{errors.coverImageUrl.toUpperCase()}</p>
)}
Expand Down
10 changes: 6 additions & 4 deletions client/src/components/program-spaces.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,12 @@ export function ProgramSpaces({ programs }: { programs: ApiProgram[] }) {
<span className="led led-sm led-pulse" aria-hidden="true" /> ·EXPLORE PAST PROGRAMS
</div>
</div>
<p className="text-body text-sm md:text-base max-w-2xl leading-relaxed mb-3">
WebZero is where the best people come together in the same room to explore their creative
potential and build something cool. Each program is tied to a specific event, with prizes
ranging from cash to event tickets to exclusive merch.
<p className="text-body text-sm md:text-base leading-relaxed mb-4">
WebZero creates vibrant spaces where the best people come together in the same room to
explore their creative potential and build something innovative. Stadium is the entry point
for participating in programs organized by WebZero and a place for these people to leave
their mark. Each program is tied to a specific event, with prizes ranging from cash to event
tickets to exclusive merch.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{SPACES.map(({ type, label, blurb, href, Icon }) => {
Expand Down
80 changes: 80 additions & 0 deletions client/src/components/program/MarkdownBody.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";

/**
* Renders trusted, first-party markdown (program content, submission terms) in
* the console's visual language: black underlined links that open in a new tab,
* mono code blocks on the recessed panel, and brand type styles for headings and
* lists. No raw HTML is allowed (react-markdown skips it by default), so this is
* safe for curated content authored by the team.
*/
export function MarkdownBody({ children }: { children: string }) {
return (
<div className="space-y-3">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
p: ({ children }) => (
<p className="text-body text-base leading-relaxed">{children}</p>
),
a: ({ children, href }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-display underline underline-offset-2 hover:text-display-dim break-words"
>
{children}
</a>
),
strong: ({ children }) => (
<strong className="text-display font-semibold">{children}</strong>
),
em: ({ children }) => <em className="italic">{children}</em>,
h1: ({ children }) => (
<h3 className="font-display text-xl tracking-tight text-display uppercase leading-tight">
{children}
</h3>
),
h2: ({ children }) => (
<h3 className="font-display text-lg tracking-tight text-display uppercase leading-tight">
{children}
</h3>
),
h3: ({ children }) => (
<h4 className="label-hw text-display">·{children}</h4>
),
ul: ({ children }) => (
<ul className="space-y-1.5 list-disc pl-5 marker:text-label-dim text-body text-base leading-relaxed">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="space-y-1.5 list-decimal pl-5 marker:text-label-dim text-body text-base leading-relaxed">
{children}
</ol>
),
li: ({ children }) => <li className="leading-relaxed">{children}</li>,
code: ({ children }) => (
<code className="font-mono text-[13px] bg-panel-deep border border-hairline-subtle rounded-sm px-1 py-[1px] text-display break-words">
{children}
</code>
),
pre: ({ children }) => (
<pre className="lcd p-3 overflow-x-auto font-mono text-[13px] text-display [&_code]:bg-transparent [&_code]:border-0 [&_code]:p-0">
{children}
</pre>
),
blockquote: ({ children }) => (
<blockquote className="border-l-2 border-hairline pl-3 text-body text-base leading-relaxed">
{children}
</blockquote>
),
hr: () => <hr className="border-hairline-subtle" />,
}}
>
{children}
</ReactMarkdown>
</div>
);
}
9 changes: 9 additions & 0 deletions client/src/components/program/ProgramContent.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ProgramContentSection } from "@/lib/api";
import { LCDStat } from "@/components/lcd-stat";
import { MarkdownBody } from "@/components/program/MarkdownBody";

/**
* Renders a program's templatable `content` (ordered, typed sections) as
Expand Down Expand Up @@ -37,6 +38,14 @@ function Section({ section }: { section: ProgramContentSection }) {
</div>
);

case "markdown":
return (
<div className="panel p-4 mb-4">
<PanelHeading title={section.title} />
<MarkdownBody>{section.body}</MarkdownBody>
</div>
);

case "steps":
return (
<div className="panel p-4 mb-4">
Expand Down
Loading