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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/web/e2e/__screenshots__/desktop-chromium/event-profile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/web/e2e/__screenshots__/mobile-chromium/event-profile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
83 changes: 65 additions & 18 deletions apps/web/src/app/_components/event-public-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ export type PublicEventPreview = {
communitySlug?: string;
summary?: string;
posterImageUrl?: string;
bannerImageUrl?: string;
thumbnailImageUrl?: string;
communityImageUrl?: string;
source: {
sourceType: EventSourceType;
label: string;
Expand All @@ -72,6 +75,9 @@ export type PublicEventPreview = {
export type PublicEvent = Omit<PublicEventPreview, "worlds"> & {
slug: string;
notes?: string;
watchSurfaceEnabled: boolean;
authoredBannerImageUrl?: string;
authoredThumbnailImageUrl?: string;
authoredMediaLinks: Array<{
type: EventMediaLinkType;
label: string;
Expand Down Expand Up @@ -101,6 +107,7 @@ export type PublicEvent = Omit<PublicEventPreview, "worlds"> & {
displayName: string;
roleLabel: string;
trustLabel: ProfileTrustLabel;
imageUrl?: string;
source: {
sourceType: EventSourceType;
label: string;
Expand All @@ -118,6 +125,7 @@ export type PublicEvent = Omit<PublicEventPreview, "worlds"> & {
slug: string;
displayName: string;
trustLabel: ProfileTrustLabel;
imageUrl?: string;
};
source: {
sourceType: EventSourceType;
Expand All @@ -128,6 +136,7 @@ export type PublicEvent = Omit<PublicEventPreview, "worlds"> & {
};

const eventPosterOverlay = "linear-gradient(135deg, rgba(25, 17, 31, 0.72), rgba(105, 56, 169, 0.2))";
const eventEntityImageOverlay = "linear-gradient(135deg, rgba(27, 18, 37, 0.28), rgba(105, 56, 169, 0.14))";

function safeHttpsUrl(url: string | undefined): string | null {
if (!url) {
Expand All @@ -142,6 +151,34 @@ function safeHttpsUrl(url: string | undefined): string | null {
}
}

function initialsFor(name: string): string {
const initials = name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join("");

return initials || "V";
}

function EntityImage({ imageUrl, label }: { imageUrl?: string; label: string }) {
const imageStyle = safeImageBackground(imageUrl, eventEntityImageOverlay);

return (
<span
aria-hidden="true"
className={cn(
"flex size-14 flex-none items-center justify-center overflow-hidden rounded-control border border-accent/20 bg-cover bg-center text-xs font-semibold text-accent-strong",
imageStyle ? "bg-slate-950" : "bg-accent/10",
)}
style={imageStyle}
>
{imageStyle ? null : initialsFor(label)}
</span>
);
}

function EventTimeDefinition({ label, timestamp }: { label: string; timestamp: number }) {
return (
<div className="grid gap-1 border-b border-border pb-4 sm:grid-cols-[7rem_1fr] sm:gap-4">
Expand All @@ -155,14 +192,14 @@ function EventTimeDefinition({ label, timestamp }: { label: string; timestamp: n

export function EventPreviewCard({ event }: { event: PublicEventPreview }) {
const sourceUrl = safeHttpsUrl(event.source.url);
const posterStyle = safeImageBackground(event.posterImageUrl, eventPosterOverlay);
const thumbnailStyle = safeImageBackground(event.thumbnailImageUrl, eventPosterOverlay);
const details = event.worlds.map((world) => world.displayName);

return (
<article className="group overflow-hidden rounded-card border border-border bg-surface-strong text-sm transition hover:-translate-y-0.5">
<div
className="min-h-28 bg-[radial-gradient(circle_at_top_left,rgba(214,106,77,0.22),transparent_34%),linear-gradient(135deg,#2c1d29,#60429a)] bg-cover bg-center px-4 py-4 text-white"
style={posterStyle}
style={thumbnailStyle}
>
<div className="flex flex-wrap items-center gap-2 text-sm font-medium text-white/84">
<ViewerLocalEventDateTime timestamp={event.startAt} />
Expand Down Expand Up @@ -224,7 +261,7 @@ export function EventBackendNotice({ kind }: { kind: "missing-url" | "error" })
}

export function EventPublicPage({ event, showEditLink = false }: { event: PublicEvent; showEditLink?: boolean }) {
const posterStyle = safeImageBackground(event.posterImageUrl, eventPosterOverlay);
const bannerStyle = safeImageBackground(event.bannerImageUrl, eventPosterOverlay);
const sourceUrl = safeHttpsUrl(event.source.url);

return (
Expand All @@ -247,7 +284,7 @@ export function EventPublicPage({ event, showEditLink = false }: { event: Public
<section className="overflow-hidden rounded-hero border border-purple-950/10 bg-slate-950 shadow-hero">
<div
className="relative min-h-56 bg-[radial-gradient(circle_at_top_right,rgba(198,153,255,0.32),transparent_30%),linear-gradient(135deg,#17111f,#5d3b8e_52%,#20142f)] bg-cover bg-center p-5 text-white sm:p-6 lg:p-8"
style={posterStyle}
style={bannerStyle}
>
<div className="flex min-h-44 flex-col justify-end">
<div className="max-w-4xl">
Expand Down Expand Up @@ -282,6 +319,7 @@ export function EventPublicPage({ event, showEditLink = false }: { event: Public
<EventWatchSurface
doorsOpenAt={event.doorsOpenAt}
endAt={event.endAt}
enabled={event.watchSurfaceEnabled}
mediaLinks={event.mediaLinks}
startAt={event.startAt}
/>
Expand All @@ -290,11 +328,14 @@ export function EventPublicPage({ event, showEditLink = false }: { event: Public
<Eyebrow>Place</Eyebrow>
<div className="mt-5 grid gap-3 text-sm">
{event.communitySlug ? (
<Link className={actionCardVariants({ variant: "accent" })} href={`/c/${event.communitySlug}`}>
<span className={actionLabelClassName}>
{event.communityName ?? "Community profile"}
<Link className={cn(actionCardVariants({ variant: "accent" }), "flex items-center gap-3")} href={`/c/${event.communitySlug}`}>
<EntityImage imageUrl={event.communityImageUrl} label={event.communityName ?? "Community profile"} />
<span className="min-w-0">
<span className={actionLabelClassName}>
{event.communityName ?? "Community profile"}
</span>
<span className={actionMetaClassName}>Host</span>
</span>
<span className={actionMetaClassName}>Host</span>
</Link>
) : event.communityName ? (
<div className="rounded-control border border-border bg-surface px-4 py-3 font-medium">
Expand All @@ -304,12 +345,15 @@ export function EventPublicPage({ event, showEditLink = false }: { event: Public
<p className="leading-6 text-muted">No host listed.</p>
)}
{event.worlds.map((world) => (
<Link className={actionCardVariants({ variant: "accent" })} href={`/w/${world.slug}`} key={world.slug}>
<span className={actionLabelClassName}>
{world.displayName}
<Link className={cn(actionCardVariants({ variant: "accent" }), "flex items-center gap-3")} href={`/w/${world.slug}`} key={world.slug}>
<EntityImage imageUrl={world.heroImageUrl} label={world.displayName} />
<span className="min-w-0">
<span className={actionLabelClassName}>
{world.displayName}
</span>
{world.summary ? <span className="mt-1 block text-muted">{world.summary}</span> : null}
<span className={actionMetaClassName}>World</span>
</span>
{world.summary ? <span className="mt-1 block text-muted">{world.summary}</span> : null}
<span className={actionMetaClassName}>World</span>
</Link>
))}
</div>
Expand Down Expand Up @@ -379,12 +423,15 @@ export function EventPublicPage({ event, showEditLink = false }: { event: Public
<p className="text-sm leading-6 text-muted">No lineup yet.</p>
) : (
event.participants.map((participant) => (
<Link className={actionCardVariants({ padding: "lg", variant: "accent" })} href={`/p/${participant.slug}`} key={participant.slug}>
<span className="block text-lg font-semibold tracking-[-0.03em] text-accent-strong underline decoration-accent/45 underline-offset-4 group-hover:decoration-accent">
{participant.displayName}
<Link className={cn(actionCardVariants({ padding: "lg", variant: "accent" }), "flex items-center gap-3")} href={`/p/${participant.slug}`} key={participant.slug}>
<EntityImage imageUrl={participant.imageUrl} label={participant.displayName} />
<span className="min-w-0">
<span className="block text-lg font-semibold tracking-[-0.03em] text-accent-strong underline decoration-accent/45 underline-offset-4 group-hover:decoration-accent">
{participant.displayName}
</span>
<span className="mt-2 block text-muted">{participant.roleLabel}</span>
<span className={actionMetaClassName}>Profile</span>
</span>
<span className="mt-2 block text-muted">{participant.roleLabel}</span>
<span className={actionMetaClassName}>Profile</span>
</Link>
))
)}
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/app/_components/event-watch-surface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -444,19 +444,21 @@ function WatchHlsVideo({ embed }: { embed: Extract<WatchEmbed, { kind: "hls" }>
export function EventWatchSurface({
doorsOpenAt,
endAt,
enabled,
mediaLinks,
startAt,
}: {
doorsOpenAt?: number;
endAt?: number;
enabled: boolean;
mediaLinks: EventWatchMediaLink[];
startAt: number;
}) {
const browserHostname = useBrowserHostname();
const currentTimestamp = useCurrentTimestamp();
const primaryWatchLink = selectPrimaryWatchLink(mediaLinks);

if (!primaryWatchLink || !isInScheduledWatchWindow({ doorsOpenAt, endAt, now: currentTimestamp, startAt })) {
if (!enabled || !primaryWatchLink || !isInScheduledWatchWindow({ doorsOpenAt, endAt, now: currentTimestamp, startAt })) {
return null;
}

Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/app/_components/world-public-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ type PublicWorldEventPreview = {
communityName?: string;
summary?: string;
posterImageUrl?: string;
bannerImageUrl?: string;
thumbnailImageUrl?: string;
mediaLinks: Array<{
type: "event_page" | "watch" | "stream" | "vrcdn" | "discord" | "ticket" | "other";
label: string;
Expand Down Expand Up @@ -203,7 +205,7 @@ function EventList({
<div className="grid gap-3">
{events.map((event) => {
const sourceUrl = event.source.url ? safeHttpsUrl(event.source.url) : null;
const posterStyle = safeImageBackground(event.posterImageUrl, worldHeroOverlay);
const posterStyle = safeImageBackground(event.thumbnailImageUrl, worldHeroOverlay);
const posterTextClass = posterStyle ? "text-white/76" : "text-muted";

return (
Expand Down
34 changes: 31 additions & 3 deletions apps/web/src/app/api/e2e/fixture-assets/[assetId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type FixtureAsset = {
from: string;
to: string;
accent: string;
showText?: boolean;
};

const fixtureAssets: Record<string, FixtureAsset> = {
Expand Down Expand Up @@ -60,6 +61,28 @@ const fixtureAssets: Record<string, FixtureAsset> = {
to: "#0e7490",
accent: "#fb7185",
},
"fixture-afterglow-event-banner": {
title: "Afterglow Harbor banner",
subtitle: "Hero artwork",
initials: "",
width: 1600,
height: 700,
from: "#121629",
to: "#134e67",
accent: "#fb7185",
showText: false,
},
"fixture-afterglow-event-thumbnail": {
title: "Afterglow Harbor card",
subtitle: "Event thumbnail",
initials: "AG",
width: 960,
height: 960,
from: "#2b1721",
to: "#0e7490",
accent: "#fb7185",
showText: false,
},
};

function fixtureError(message: string, status = 403) {
Expand Down Expand Up @@ -88,6 +111,13 @@ function renderSvg(asset: FixtureAsset) {
const initials = escapeSvgText(asset.initials);
const radius = Math.min(asset.width, asset.height) * 0.24;

const text = asset.showText === false
? ""
: `
<text x="8%" y="48%" fill="white" font-family="Inter, Arial, sans-serif" font-size="${Math.round(asset.height * 0.24)}" font-weight="800" letter-spacing="-6">${initials}</text>
<text x="8%" y="72%" fill="white" font-family="Inter, Arial, sans-serif" font-size="${Math.round(asset.height * 0.1)}" font-weight="750" letter-spacing="-2">${title}</text>
<text x="8%" y="84%" fill="white" fill-opacity="0.72" font-family="Inter, Arial, sans-serif" font-size="${Math.round(asset.height * 0.046)}" font-weight="600" letter-spacing="2">${subtitle.toUpperCase()}</text>`;

return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${asset.width} ${asset.height}" role="img" aria-label="${title}">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
Expand All @@ -103,9 +133,7 @@ function renderSvg(asset: FixtureAsset) {
<rect width="${asset.width}" height="${asset.height}" fill="url(#bg)" rx="${radius}"/>
<rect width="${asset.width}" height="${asset.height}" fill="url(#glow)" rx="${radius}"/>
<circle cx="${asset.width * 0.18}" cy="${asset.height * 0.2}" r="${Math.min(asset.width, asset.height) * 0.16}" fill="none" stroke="white" stroke-opacity="0.26" stroke-width="10"/>
<text x="8%" y="48%" fill="white" font-family="Inter, Arial, sans-serif" font-size="${Math.round(asset.height * 0.24)}" font-weight="800" letter-spacing="-6">${initials}</text>
<text x="8%" y="72%" fill="white" font-family="Inter, Arial, sans-serif" font-size="${Math.round(asset.height * 0.1)}" font-weight="750" letter-spacing="-2">${title}</text>
<text x="8%" y="84%" fill="white" fill-opacity="0.72" font-family="Inter, Arial, sans-serif" font-size="${Math.round(asset.height * 0.046)}" font-weight="600" letter-spacing="2">${subtitle.toUpperCase()}</text>
${text}
</svg>`;
}

Expand Down
34 changes: 32 additions & 2 deletions apps/web/src/app/events/event-editor-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ const userSafeErrorPatterns = [
/Event end time must be after the start time\./,
/Time zone must be a valid IANA time zone\./,
/Time zone is required when event slots are provided\./,
/(?:Event source URL|Poster image URL|Media link URL|Participant source URL|Slot source URL) must (?:use https|be a valid URL)\./,
/(?:Event source URL|Poster image URL|Banner image URL|Thumbnail image URL|Media link URL|Participant source URL|Slot source URL) must (?:use https|be a valid URL)\./,
/(?:Slot start time|Slot end time) must be a valid timestamp\./,
/(?:Slot count|Slot offset minutes|Slot duration minutes|Break duration minutes) must be a whole number\./,
/Slot end time must be after the start time\./,
Expand Down Expand Up @@ -579,6 +579,9 @@ function ConnectedEventEditorForm({ event }: { event?: PublicEvent }) {
sourceLabel: optionalString(stringField(formData.get("sourceLabel"))),
sourceUrl: optionalString(stringField(formData.get("sourceUrl"))),
posterImageUrl: optionalString(stringField(formData.get("posterImageUrl"))),
bannerImageUrl: optionalString(stringField(formData.get("bannerImageUrl"))),
thumbnailImageUrl: optionalString(stringField(formData.get("thumbnailImageUrl"))),
watchSurfaceEnabled: formData.get("watchSurfaceEnabled") === "on",
mediaLinks: parseMediaLinks(mediaLinksText),
participantLinks: parseParticipantLinks(stringField(formData.get("participantLinks"))),
slotLinks: parseSlotLinks(slotText, startAt),
Expand Down Expand Up @@ -682,18 +685,45 @@ function ConnectedEventEditorForm({ event }: { event?: PublicEvent }) {
</Field>
</div>

<div className="grid gap-4 sm:grid-cols-2">
<Field>
Banner image URL
<Input defaultValue={event?.authoredBannerImageUrl} name="bannerImageUrl" placeholder="https://..." />
<FieldText>Wide event-page hero image. Falls back to the poster image.</FieldText>
</Field>
<Field>
Thumbnail image URL
<Input defaultValue={event?.authoredThumbnailImageUrl} name="thumbnailImageUrl" placeholder="https://..." />
<FieldText>Compact event-card image. Falls back to the poster or banner image.</FieldText>
</Field>
</div>

<Field>
Media links
<Textarea className="min-h-28" name="mediaLinks" onChange={(changeEvent) => setMediaLinksText(changeEvent.currentTarget.value)} placeholder="watch | Twitch watch link | https://... | open&#10;vrcdn | VRCDN Quest link | https://stream.vrcdn.live/live/name.live.ts | copy&#10;vrcdn | VRCDN PC link | rtspt://stream.vrcdn.live/live/name | copy" value={mediaLinksText} />
<FieldText>One per line: type | label | URL | open or copy. VRCDN variants derive Quest and PC player links automatically.</FieldText>
</Field>
<label className="flex gap-3 rounded-control border border-border bg-surface-strong p-4 text-sm leading-6">
<input
className="mt-1 h-4 w-4 flex-none accent-accent"
defaultChecked={event?.watchSurfaceEnabled ?? false}
name="watchSurfaceEnabled"
type="checkbox"
/>
<span>
<span className="block font-medium text-foreground">Promote a watch surface during the event window</span>
<span className="mt-1 block text-xs leading-5 text-muted">
Keep this off unless stream capacity is ready for public viewers. Links still appear in the normal links section.
</span>
</span>
</label>
<VrcdnMediaLinkAssistant mediaLinksText={mediaLinksText} />

{event === undefined ? null : (
<Card className="grid gap-4" padding="sm" surface="strong">
<div>
<h3 className="text-xl font-semibold tracking-[-0.03em]">VRCDN output</h3>
<p className="mt-2 text-xs leading-5 text-muted">Managed output links are shown on the event page during the watch window.</p>
<p className="mt-2 text-xs leading-5 text-muted">Managed output links can be promoted when the event watch surface is enabled.</p>
</div>

<div className="grid gap-4 sm:grid-cols-2">
Expand Down
Loading