diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/account-signed-out.png b/apps/web/e2e/__screenshots__/desktop-chromium/account-signed-out.png index 50ebe08..7224b41 100644 Binary files a/apps/web/e2e/__screenshots__/desktop-chromium/account-signed-out.png and b/apps/web/e2e/__screenshots__/desktop-chromium/account-signed-out.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/appearance-demo.png b/apps/web/e2e/__screenshots__/desktop-chromium/appearance-demo.png new file mode 100644 index 0000000..1a60487 Binary files /dev/null and b/apps/web/e2e/__screenshots__/desktop-chromium/appearance-demo.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/community-profile.png b/apps/web/e2e/__screenshots__/desktop-chromium/community-profile.png index a928101..8fd7349 100644 Binary files a/apps/web/e2e/__screenshots__/desktop-chromium/community-profile.png and b/apps/web/e2e/__screenshots__/desktop-chromium/community-profile.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/deployment.png b/apps/web/e2e/__screenshots__/desktop-chromium/deployment.png index f6e9f1d..3f582dc 100644 Binary files a/apps/web/e2e/__screenshots__/desktop-chromium/deployment.png and b/apps/web/e2e/__screenshots__/desktop-chromium/deployment.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/event-edit-signed-out.png b/apps/web/e2e/__screenshots__/desktop-chromium/event-edit-signed-out.png index ac55419..7e7d2c8 100644 Binary files a/apps/web/e2e/__screenshots__/desktop-chromium/event-edit-signed-out.png and b/apps/web/e2e/__screenshots__/desktop-chromium/event-edit-signed-out.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/event-new-signed-out.png b/apps/web/e2e/__screenshots__/desktop-chromium/event-new-signed-out.png index 2e8b441..7e6f963 100644 Binary files a/apps/web/e2e/__screenshots__/desktop-chromium/event-new-signed-out.png and b/apps/web/e2e/__screenshots__/desktop-chromium/event-new-signed-out.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/event-profile.png b/apps/web/e2e/__screenshots__/desktop-chromium/event-profile.png index 9bb1d62..6c4c596 100644 Binary files a/apps/web/e2e/__screenshots__/desktop-chromium/event-profile.png and b/apps/web/e2e/__screenshots__/desktop-chromium/event-profile.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/event-watch-surface.png b/apps/web/e2e/__screenshots__/desktop-chromium/event-watch-surface.png index f47dc54..bcbb8c8 100644 Binary files a/apps/web/e2e/__screenshots__/desktop-chromium/event-watch-surface.png and b/apps/web/e2e/__screenshots__/desktop-chromium/event-watch-surface.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/home.png b/apps/web/e2e/__screenshots__/desktop-chromium/home.png index 6dbdd61..6adec9d 100644 Binary files a/apps/web/e2e/__screenshots__/desktop-chromium/home.png and b/apps/web/e2e/__screenshots__/desktop-chromium/home.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/person-profile.png b/apps/web/e2e/__screenshots__/desktop-chromium/person-profile.png index 69a63d0..78ca299 100644 Binary files a/apps/web/e2e/__screenshots__/desktop-chromium/person-profile.png and b/apps/web/e2e/__screenshots__/desktop-chromium/person-profile.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/privacy-suppression.png b/apps/web/e2e/__screenshots__/desktop-chromium/privacy-suppression.png index 4245517..10172f4 100644 Binary files a/apps/web/e2e/__screenshots__/desktop-chromium/privacy-suppression.png and b/apps/web/e2e/__screenshots__/desktop-chromium/privacy-suppression.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/search.png b/apps/web/e2e/__screenshots__/desktop-chromium/search.png index 8926e25..61163e9 100644 Binary files a/apps/web/e2e/__screenshots__/desktop-chromium/search.png and b/apps/web/e2e/__screenshots__/desktop-chromium/search.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/server-status.png b/apps/web/e2e/__screenshots__/desktop-chromium/server-status.png index c4acab6..d2890c2 100644 Binary files a/apps/web/e2e/__screenshots__/desktop-chromium/server-status.png and b/apps/web/e2e/__screenshots__/desktop-chromium/server-status.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/sign-in.png b/apps/web/e2e/__screenshots__/desktop-chromium/sign-in.png index 9af0f2d..d90cd18 100644 Binary files a/apps/web/e2e/__screenshots__/desktop-chromium/sign-in.png and b/apps/web/e2e/__screenshots__/desktop-chromium/sign-in.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/submit.png b/apps/web/e2e/__screenshots__/desktop-chromium/submit.png index 51d2bb7..e4dd622 100644 Binary files a/apps/web/e2e/__screenshots__/desktop-chromium/submit.png and b/apps/web/e2e/__screenshots__/desktop-chromium/submit.png differ diff --git a/apps/web/e2e/__screenshots__/desktop-chromium/world-profile.png b/apps/web/e2e/__screenshots__/desktop-chromium/world-profile.png index 54725c7..2c96ccb 100644 Binary files a/apps/web/e2e/__screenshots__/desktop-chromium/world-profile.png and b/apps/web/e2e/__screenshots__/desktop-chromium/world-profile.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/account-signed-out.png b/apps/web/e2e/__screenshots__/mobile-chromium/account-signed-out.png index 65c5151..5f47c56 100644 Binary files a/apps/web/e2e/__screenshots__/mobile-chromium/account-signed-out.png and b/apps/web/e2e/__screenshots__/mobile-chromium/account-signed-out.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/appearance-demo.png b/apps/web/e2e/__screenshots__/mobile-chromium/appearance-demo.png new file mode 100644 index 0000000..b549151 Binary files /dev/null and b/apps/web/e2e/__screenshots__/mobile-chromium/appearance-demo.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/community-profile.png b/apps/web/e2e/__screenshots__/mobile-chromium/community-profile.png index 9c9a45b..9d09bf0 100644 Binary files a/apps/web/e2e/__screenshots__/mobile-chromium/community-profile.png and b/apps/web/e2e/__screenshots__/mobile-chromium/community-profile.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/deployment.png b/apps/web/e2e/__screenshots__/mobile-chromium/deployment.png index ced006b..b61656c 100644 Binary files a/apps/web/e2e/__screenshots__/mobile-chromium/deployment.png and b/apps/web/e2e/__screenshots__/mobile-chromium/deployment.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/event-edit-signed-out.png b/apps/web/e2e/__screenshots__/mobile-chromium/event-edit-signed-out.png index 84cc4a9..2ef7b9f 100644 Binary files a/apps/web/e2e/__screenshots__/mobile-chromium/event-edit-signed-out.png and b/apps/web/e2e/__screenshots__/mobile-chromium/event-edit-signed-out.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/event-new-signed-out.png b/apps/web/e2e/__screenshots__/mobile-chromium/event-new-signed-out.png index 6ccd7f9..43befdf 100644 Binary files a/apps/web/e2e/__screenshots__/mobile-chromium/event-new-signed-out.png and b/apps/web/e2e/__screenshots__/mobile-chromium/event-new-signed-out.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/event-profile.png b/apps/web/e2e/__screenshots__/mobile-chromium/event-profile.png index 113a2e4..50715d7 100644 Binary files a/apps/web/e2e/__screenshots__/mobile-chromium/event-profile.png and b/apps/web/e2e/__screenshots__/mobile-chromium/event-profile.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/event-watch-surface.png b/apps/web/e2e/__screenshots__/mobile-chromium/event-watch-surface.png index 4aaee11..ad30b43 100644 Binary files a/apps/web/e2e/__screenshots__/mobile-chromium/event-watch-surface.png and b/apps/web/e2e/__screenshots__/mobile-chromium/event-watch-surface.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/home.png b/apps/web/e2e/__screenshots__/mobile-chromium/home.png index 1a6c8bb..2e8e924 100644 Binary files a/apps/web/e2e/__screenshots__/mobile-chromium/home.png and b/apps/web/e2e/__screenshots__/mobile-chromium/home.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/person-profile.png b/apps/web/e2e/__screenshots__/mobile-chromium/person-profile.png index 1282d92..f6b39c9 100644 Binary files a/apps/web/e2e/__screenshots__/mobile-chromium/person-profile.png and b/apps/web/e2e/__screenshots__/mobile-chromium/person-profile.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/privacy-suppression.png b/apps/web/e2e/__screenshots__/mobile-chromium/privacy-suppression.png index fccb83c..71ba42b 100644 Binary files a/apps/web/e2e/__screenshots__/mobile-chromium/privacy-suppression.png and b/apps/web/e2e/__screenshots__/mobile-chromium/privacy-suppression.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/search.png b/apps/web/e2e/__screenshots__/mobile-chromium/search.png index 0c5ac98..ef3e168 100644 Binary files a/apps/web/e2e/__screenshots__/mobile-chromium/search.png and b/apps/web/e2e/__screenshots__/mobile-chromium/search.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/server-status.png b/apps/web/e2e/__screenshots__/mobile-chromium/server-status.png index 38746ae..0eebdf0 100644 Binary files a/apps/web/e2e/__screenshots__/mobile-chromium/server-status.png and b/apps/web/e2e/__screenshots__/mobile-chromium/server-status.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/sign-in.png b/apps/web/e2e/__screenshots__/mobile-chromium/sign-in.png index 9d7a0d1..e15b55c 100644 Binary files a/apps/web/e2e/__screenshots__/mobile-chromium/sign-in.png and b/apps/web/e2e/__screenshots__/mobile-chromium/sign-in.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/submit.png b/apps/web/e2e/__screenshots__/mobile-chromium/submit.png index 4df9aff..b29ec63 100644 Binary files a/apps/web/e2e/__screenshots__/mobile-chromium/submit.png and b/apps/web/e2e/__screenshots__/mobile-chromium/submit.png differ diff --git a/apps/web/e2e/__screenshots__/mobile-chromium/world-profile.png b/apps/web/e2e/__screenshots__/mobile-chromium/world-profile.png index 2e42be1..6754680 100644 Binary files a/apps/web/e2e/__screenshots__/mobile-chromium/world-profile.png and b/apps/web/e2e/__screenshots__/mobile-chromium/world-profile.png differ diff --git a/apps/web/e2e/public-routes.ts b/apps/web/e2e/public-routes.ts index e5137d3..85219bd 100644 --- a/apps/web/e2e/public-routes.ts +++ b/apps/web/e2e/public-routes.ts @@ -177,6 +177,7 @@ export async function expectSearchPage(page: Page) { await expect(page.getByRole("heading", { name: /Results for aurora/i })).toBeVisible(); await expect(page.getByRole("button", { name: /Search VRDex/i })).toBeVisible(); await expect(page.getByRole("link", { name: /DJ Aurora/i }).first()).toBeVisible(); + await expect(page.locator('[title="Logo"]').first()).toBeVisible(); await expect(page.getByRole("heading", { name: /Events worth checking first/i })).toHaveCount(0); } @@ -215,6 +216,13 @@ export async function expectAccountPage(page: Page) { await expect(page.getByRole("link", { name: "Sign in" }).last()).toBeVisible(); } +export async function expectAppearancePage(page: Page) { + await expect(page.getByRole("heading", { name: /Shape the way your profile image shows up/i })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Profile picture shape and border" })).toBeVisible(); + await expect(page.getByLabel("Avatar roundedness")).toBeVisible(); + await expect(page.getByText("Demo mode is live-only", { exact: false })).toBeVisible(); +} + export async function expectSuppressionPage(page: Page) { await expect(page.getByRole("heading", { name: /Request review of a public listing/i })).toBeVisible(); await expect(page.getByLabel("Request type")).toBeVisible(); @@ -253,6 +261,9 @@ export async function expectPersonProfilePage(page: Page) { await expect(page.getByText(/Creator links/i)).toBeVisible(); await expect(page.getByText("VRChat profile", { exact: true })).toBeVisible(); await expect(page.getByText("DJ Aurora SoundCloud", { exact: true })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Media kit" })).toBeVisible(); + await expect(page.getByRole("link", { name: /Primary logo/i })).toBeVisible(); + await expect(page.getByRole("link", { name: /Download logos zip/i })).toBeVisible(); await expect(page.getByRole("heading", { name: "Worlds" })).toBeVisible(); await expect(page.getByRole("link", { name: /Neon Harbor/i })).toBeVisible(); } @@ -348,6 +359,11 @@ export const capturedRoutes: CapturedRoute[] = [ path: "/account", expectPage: expectAccountPage, }, + { + name: "appearance-demo", + path: "/account/appearance", + expectPage: expectAppearancePage, + }, { name: "search", path: "/search?q=aurora", diff --git a/apps/web/package.json b/apps/web/package.json index 1692f67..4558784 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -26,6 +26,7 @@ "typecheck": "next typegen && node ./scripts/ensure-next-type-stubs.mjs && tsc --noEmit --incremental false" }, "dependencies": { + "@aws-sdk/client-s3": "^3.1068.0", "@convex-dev/auth": "^0.0.92", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/apps/web/src/app/_components/discovery-public-page.tsx b/apps/web/src/app/_components/discovery-public-page.tsx index f3165de..effe924 100644 --- a/apps/web/src/app/_components/discovery-public-page.tsx +++ b/apps/web/src/app/_components/discovery-public-page.tsx @@ -22,6 +22,8 @@ export type PublicSearchResult = { subtitle?: string; summary?: string; imageUrl?: string; + profileImageUrl?: string; + logoImageUrl?: string; startsAt?: number; source?: { sourceType?: string; @@ -93,11 +95,46 @@ function resultMatchesFilter(result: PublicSearchResult, filter: SearchResultFil return result.entityType === filter; } -function resultMeta(result: PublicSearchResult): string { - const subtitle = resultSubtitle(result); - const parts = [entityLabel(result), subtitle].filter(Boolean); +function ResultImage({ result }: { result: PublicSearchResult }) { + const imageStyle = safeImageBackground(result.imageUrl, discoveryThumbOverlay); + + if ( + result.entityType !== "profile" || + !result.logoImageUrl || + !result.profileImageUrl || + result.logoImageUrl === result.profileImageUrl + ) { + return ( + + {!imageStyle ? initialsFor(result.title) : null} + + ); + } - return parts.join(" / "); + const profileImageStyle = safeImageBackground(result.profileImageUrl, discoveryThumbOverlay); + const logoStyle = safeImageBackground(result.logoImageUrl); + + return ( + + + {!profileImageStyle ? initialsFor(result.title) : null} + + + {!logoStyle ? "Logo" : null} + + + ); } function TopNav() { @@ -120,7 +157,6 @@ function TopNav() { } function DiscoveryCard({ result, surface }: { result: PublicSearchResult; surface: string }) { - const imageStyle = safeImageBackground(result.imageUrl, discoveryThumbOverlay); const subtitle = resultSubtitle(result); return ( @@ -135,12 +171,7 @@ function DiscoveryCard({ result, surface }: { result: PublicSearchResult; surfac surface, }} > - - {!imageStyle ? initialsFor(result.title) : null} - + {result.startsAt === undefined ? null : } @@ -154,7 +185,7 @@ function DiscoveryCard({ result, surface }: { result: PublicSearchResult; surfac } function SearchResultCard({ result }: { result: PublicSearchResult }) { - const imageStyle = safeImageBackground(result.imageUrl, discoveryThumbOverlay); + const subtitle = resultSubtitle(result); return ( - - {!imageStyle ? initialsFor(result.title) : null} - - - {resultMeta(result)} - - {result.title} + + + + + {result.title} + + + {entityLabel(result)} + + {subtitle ? {subtitle} : null} {result.startsAt === undefined ? null : } {result.summary ? {result.summary} : null} {result.source ? {result.source.label} : null} diff --git a/apps/web/src/app/_components/profile-public-page.tsx b/apps/web/src/app/_components/profile-public-page.tsx index d94a1da..d1424c9 100644 --- a/apps/web/src/app/_components/profile-public-page.tsx +++ b/apps/web/src/app/_components/profile-public-page.tsx @@ -1,9 +1,11 @@ import Link from "next/link"; +import type { CSSProperties } from "react"; import { EventPreviewCard, type PublicEventPreview } from "./event-public-page"; import { buttonVariants } from "@/components/ui/button"; import { Card, Eyebrow, SectionHeading, SectionTitle } from "@/components/ui/card"; import { BrandLink, PageContainer, PageNav, PageShell } from "@/components/ui/page-shell"; +import { avatarFrameStyle, defaultAvatarAppearance, type AvatarAppearance } from "@/lib/avatar-appearance"; import { cn } from "@/lib/cn"; import { safeImageBackground } from "@/lib/safe-image"; @@ -51,6 +53,30 @@ type PublicProfileGenre = { featured?: boolean; }; +type PublicProfileAsset = { + assetId: string; + label?: string; + caption?: string; + mimeType: string; + byteSize: number; + imageUrl: string; + downloadUrl: string; +}; + +type PublicProfileMediaKit = { + profileImage?: PublicProfileAsset; + banner?: PublicProfileAsset; + primaryLogo?: PublicProfileAsset; + additionalLogos: PublicProfileAsset[]; + logos: PublicProfileAsset[]; + assets: PublicProfileAsset[]; + logoZipUrl?: string; + compactDisplay: "profile_image" | "logo"; + avatarAppearance?: PublicProfileAvatarAppearance; +}; + +type PublicProfileAvatarAppearance = AvatarAppearance; + type PublicProfileBase = { profileType: "person" | "community"; slug: string; @@ -89,6 +115,7 @@ type PublicProfileBase = { }>; upcomingEvents: PublicEventPreview[]; hostedEvents: PublicEventPreview[]; + mediaKit?: PublicProfileMediaKit; }; type PublicPersonProfile = PublicProfileBase & { @@ -137,7 +164,6 @@ function initialsFor(name: string): string { } const profileBannerOverlay = "linear-gradient(135deg, rgba(22, 17, 15, 0.58), rgba(214, 106, 77, 0.2))"; - function safeHttpsUrl(url: string): string | null { try { const parsed = new URL(url); @@ -172,6 +198,44 @@ function roleLabel(role: WorldCreatorRole): string { .join(" "); } +function formatByteSize(value: number): string { + if (value >= 1024 * 1024) { + return `${(value / (1024 * 1024)).toFixed(1)} MB`; + } + + return `${Math.max(1, Math.round(value / 1024))} KB`; +} + +function mimeLabel(value: string): string { + return value.replace(/^image\//, "").replace("svg+xml", "svg").toUpperCase(); +} + +function MediaAssetCard({ asset, label }: { asset: PublicProfileAsset; label: string }) { + const imageStyle = safeImageBackground(asset.imageUrl); + + return ( + + + {!imageStyle ? label.slice(0, 2).toUpperCase() : null} + + + {asset.label ?? label} + {asset.caption ? {asset.caption} : null} + + {mimeLabel(asset.mimeType)} / {formatByteSize(asset.byteSize)} + + + + ); +} + function PillList({ items }: { items: string[] }) { if (items.length === 0) { return

No public entries yet.

; @@ -217,7 +281,16 @@ export function ProfilePublicPage({ profile }: { profile: PublicProfile }) { (item): item is string => Boolean(item), ); const bannerStyle = safeImageBackground(profile.bannerImageUrl, profileBannerOverlay); - const avatarStyle = safeImageBackground(profile.avatarImageUrl); + const avatarImageStyle = safeImageBackground(profile.avatarImageUrl); + const hasAvatarImage = avatarImageStyle !== undefined; + const mediaKit = profile.mediaKit ?? { + additionalLogos: [], + logos: [], + assets: [], + compactDisplay: "profile_image" as const, + }; + const avatarAppearance = mediaKit.avatarAppearance ?? defaultAvatarAppearance; + const avatarStyle: CSSProperties = avatarFrameStyle(avatarImageStyle, avatarAppearance); const sourceSubmittedAt = formatSubmittedAt(profile.source?.submittedAt); const sourceDetails = [ profile.source && profile.source.label !== trust ? profile.source.label : null, @@ -246,12 +319,12 @@ export function ProfilePublicPage({ profile }: { profile: PublicProfile }) {
- {!avatarStyle ? initialsFor(profile.displayName) : null} + {!hasAvatarImage ? initialsFor(profile.displayName) : null}
@@ -370,6 +443,43 @@ export function ProfilePublicPage({ profile }: { profile: PublicProfile }) {
+ +
+ + Media kit + + {mediaKit.logoZipUrl ? ( + + Download logos zip + + ) : null} +
+
+
+

Primary logo

+ {mediaKit.primaryLogo ? ( +
+ +
+ ) : ( +

No public logo yet.

+ )} +
+
+

Additional logos

+
+ {mediaKit.additionalLogos.length === 0 ? ( +

No additional public logos yet.

+ ) : ( + mediaKit.additionalLogos.map((asset, index) => ( + + )) + )} +
+
+
+
+ Worlds diff --git a/apps/web/src/app/account/account-panel.tsx b/apps/web/src/app/account/account-panel.tsx index 1a81a45..268865b 100644 --- a/apps/web/src/app/account/account-panel.tsx +++ b/apps/web/src/app/account/account-panel.tsx @@ -338,9 +338,14 @@ function ConnectedAccountPanel() {
{viewer.user.emailVerified ? "Verified" : "Not verified"}
- +
+ + Customize appearance + + +
diff --git a/apps/web/src/app/account/appearance/appearance-panel.tsx b/apps/web/src/app/account/appearance/appearance-panel.tsx new file mode 100644 index 0000000..b93dffd --- /dev/null +++ b/apps/web/src/app/account/appearance/appearance-panel.tsx @@ -0,0 +1,430 @@ +"use client"; + +import { useMutation, useQuery } from "convex/react"; +import Link from "next/link"; +import type { CSSProperties, ReactNode } from "react"; +import { Component, FormEvent, useDeferredValue, useEffect, useState, useTransition } from "react"; + +import { api } from "@convex-generated-api"; +import { buttonVariants, Button } from "@/components/ui/button"; +import { Card, cardVariants, Eyebrow } from "@/components/ui/card"; +import { Field, FieldText, Input, Select } from "@/components/ui/field"; +import { Notice } from "@/components/ui/notice"; +import { avatarFrameStyle, defaultAvatarAppearance, type AvatarAppearance } from "@/lib/avatar-appearance"; +import { cn } from "@/lib/cn"; +import { safeImageBackground } from "@/lib/safe-image"; +import type { Id } from "../../../../../../convex/_generated/dataModel"; + +const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL; + +type AppearanceProfile = { + profileId: Id<"profiles"> | "demo"; + profileType: "person" | "community"; + slug: string; + displayName: string; + headline?: string; + avatarImageUrl?: string; + avatarAppearance: AvatarAppearance; +}; + +type SaveStatus = + | { kind: "idle" } + | { kind: "saving" } + | { kind: "success" } + | { kind: "error"; message: string }; + +const demoProfiles: AppearanceProfile[] = [ + { + profileId: "demo", + profileType: "person", + slug: "playwright-dj-aurora", + displayName: "DJ Aurora", + headline: "Melodic house sets for late-night VRChat floors.", + avatarImageUrl: "/api/e2e/fixture-assets/fixture-aurora-profile-image", + avatarAppearance: { + borderEnabled: true, + borderColor: "#67e8f9", + borderWidthPx: 4, + borderSoftnessPx: 12, + radiusPercent: 18, + }, + }, +]; + +function initialsFor(name: string): string { + return ( + name + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase()) + .join("") || "VR" + ); +} + +function appearanceErrorMessage(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + const match = message.match( + /Only the profile owner can update profile appearance\.|Profile not found\.|Profile avatar border color must be a six-digit hex color\.|Profile avatar border thickness must be a number\.|Profile avatar border softness must be a number\.|Profile avatar roundedness must be a number\.|A signed-in account is required\./, + ); + + return match?.[0] ?? "Appearance update failed. Check the profile and try again."; +} + +function roundednessLabel(value: number): string { + if (value === 0) { + return "Square"; + } + + if (value >= 50) { + return "Circle"; + } + + if (value >= 32) { + return "Round"; + } + + return "Soft"; +} + +function softnessLabel(value: number): string { + if (value === 0) { + return "Crisp"; + } + + if (value >= 16) { + return "Glow"; + } + + if (value >= 8) { + return "Feathered"; + } + + return "Soft edge"; +} + +function avatarStyle(profile: AppearanceProfile, appearance: AvatarAppearance): CSSProperties { + return avatarFrameStyle(safeImageBackground(profile.avatarImageUrl), appearance); +} + +function AvatarPreview({ appearance, profile }: { appearance: AvatarAppearance; profile: AppearanceProfile }) { + const imageStyle = safeImageBackground(profile.avatarImageUrl); + const style = avatarStyle(profile, appearance); + + return ( + +
+
+
+ {!imageStyle ? initialsFor(profile.displayName) : null} +
+
+

Live preview

+

+ {profile.displayName} +

+ {profile.headline ?

{profile.headline}

: null} +
+
+
+
+
+ {!imageStyle ? initialsFor(profile.displayName) : null} +
+
+

{profile.displayName}

+

Search and compact-card preview

+
+

{roundednessLabel(appearance.radiusPercent)}

+
+
+ ); +} + +function AppearanceEditor({ demo, profiles }: { demo?: boolean; profiles: AppearanceProfile[] }) { + const updateAvatarAppearance = useMutation(api.profileAssets.updateAvatarAppearance); + const [selectedProfileId, setSelectedProfileId] = useState(profiles[0]?.profileId ?? ""); + const selectedProfile = profiles.find((profile) => profile.profileId === selectedProfileId) ?? profiles[0]; + const [draft, setDraft] = useState(selectedProfile?.avatarAppearance ?? defaultAvatarAppearance); + const deferredDraft = useDeferredValue(draft); + const colorPickerValue = /^#[0-9a-fA-F]{6}$/.test(draft.borderColor) ? draft.borderColor : "#000000"; + const [status, setStatus] = useState({ kind: "idle" }); + const [, startTransition] = useTransition(); + + useEffect(() => { + if (!selectedProfileId && profiles[0]) { + setSelectedProfileId(profiles[0].profileId); + } + }, [profiles, selectedProfileId]); + + useEffect(() => { + if (selectedProfile) { + setDraft(selectedProfile.avatarAppearance); + setStatus({ kind: "idle" }); + } + }, [selectedProfile]); + + if (!selectedProfile) { + return null; + } + + async function submitAppearance(event: FormEvent) { + event.preventDefault(); + + if (!selectedProfile || demo || selectedProfile.profileId === "demo") { + return; + } + + setStatus({ kind: "saving" }); + + try { + await updateAvatarAppearance({ + profileId: selectedProfile.profileId, + borderEnabled: draft.borderEnabled, + borderColor: draft.borderColor, + borderWidthPx: draft.borderWidthPx, + borderSoftnessPx: draft.borderSoftnessPx, + radiusPercent: draft.radiusPercent, + }); + startTransition(() => setStatus({ kind: "success" })); + } catch (error) { + startTransition(() => setStatus({ kind: "error", message: appearanceErrorMessage(error) })); + } + } + + return ( +
+
+
+ Avatar frame +

Profile picture shape and border

+

+ Keep the uploaded image reusable. These controls only change how the public avatar frame presents it. +

+
+ + {profiles.length > 1 ? ( + + Profile + + + ) : null} + + + + + Border color +
+ setDraft((current) => ({ ...current, borderColor: event.target.value }))} + /> + setDraft((current) => ({ ...current, borderColor: event.target.value }))} + /> +
+ Use a six-digit hex color. Later this can pull from the profile theme palette. +
+ + + Roundedness +
+ + setDraft((current) => ({ ...current, radiusPercent: Number(event.target.value) })) + } + /> +
+ Square + {draft.radiusPercent}% / {roundednessLabel(draft.radiusPercent)} + Circle +
+
+
+ + + Border thickness +
+ + setDraft((current) => ({ ...current, borderWidthPx: Number(event.target.value) })) + } + /> +
+ Hairline + {draft.borderWidthPx}px + Bold +
+
+
+ + + Border softness +
+ + setDraft((current) => ({ ...current, borderSoftnessPx: Number(event.target.value) })) + } + /> +
+ Crisp + {draft.borderSoftnessPx}px / {softnessLabel(draft.borderSoftnessPx)} + Glow +
+ Softness feathers the border color outward as a subtle gradient glow. +
+
+ + {demo ? ( + Demo mode is live-only. Sign in and claim a profile to save appearance settings. + ) : null} + {status.kind === "saving" ?

Saving appearance...

: null} + {status.kind === "success" ? Appearance saved. : null} + {status.kind === "error" ? {status.message} : null} + +
+ + + View profile + +
+
+ + +
+ ); +} + +function OwnerAppearancePanel() { + const profiles = useQuery(api.profileAssets.listOwnedAppearanceProfiles); + + if (profiles === undefined) { + return

Loading appearance settings...

; + } + + if (profiles === null) { + return ( + +

Sign in to customize a profile

+

Appearance settings belong to claimed profiles.

+ + Sign in + +
+ ); + } + + if (profiles.length === 0) { + return ( + +

No owned profiles yet

+

Claim a person or community profile before changing public appearance.

+ + Go to claims + +
+ ); + } + + return ; +} + +function ConnectedAppearancePanel({ demoMode }: { demoMode: boolean }) { + if (demoMode) { + return ; + } + + return ; +} + +class AppearancePanelErrorBoundary extends Component< + { children: ReactNode }, + { hasError: boolean } +> { + state = { hasError: false }; + + static getDerivedStateFromError() { + return { hasError: true }; + } + + render() { + if (this.state.hasError) { + return ( + + Appearance settings are temporarily unavailable because the backend query failed. Try again after the Convex deployment finishes. + + ); + } + + return this.props.children; + } +} + +export function AppearancePanel({ demoMode = false }: { demoMode?: boolean }) { + if (!convexUrl && !demoMode) { + return ( + + Convex is not configured in this environment, so appearance settings are unavailable. + + ); + } + + return ( + + + + ); +} diff --git a/apps/web/src/app/account/appearance/page.tsx b/apps/web/src/app/account/appearance/page.tsx new file mode 100644 index 0000000..cf010aa --- /dev/null +++ b/apps/web/src/app/account/appearance/page.tsx @@ -0,0 +1,41 @@ +import Link from "next/link"; + +import { AppearancePanel } from "./appearance-panel"; +import { buttonVariants } from "@/components/ui/button"; +import { Card, Eyebrow } from "@/components/ui/card"; +import { BrandLink, PageContainer, PageNav, PageShell } from "@/components/ui/page-shell"; + +export default function AppearancePage() { + const demoMode = process.env.VRDEX_ENABLE_PLAYWRIGHT_FIXTURES === "true"; + + return ( + + + + +
+ + Account + + + Add profile + +
+
+ + + Appearance +

+ Shape the way your profile image shows up. +

+

+ Start with the avatar frame: square to circle, border on or off, and a color that can later plug into the broader profile theme. +

+
+ +
+
+
+
+ ); +} diff --git a/apps/web/src/app/api/e2e/fixture-assets/[assetId]/route.ts b/apps/web/src/app/api/e2e/fixture-assets/[assetId]/route.ts new file mode 100644 index 0000000..dcaec4f --- /dev/null +++ b/apps/web/src/app/api/e2e/fixture-assets/[assetId]/route.ts @@ -0,0 +1,128 @@ +import { NextResponse } from "next/server"; + +export const dynamic = "force-dynamic"; + +type FixtureAssetRouteProps = { + params: Promise<{ + assetId: string; + }>; +}; + +type FixtureAsset = { + title: string; + subtitle: string; + initials: string; + width: number; + height: number; + from: string; + to: string; + accent: string; +}; + +const fixtureAssets: Record = { + "fixture-aurora-profile-image": { + title: "DJ Aurora", + subtitle: "Melodic house", + initials: "DA", + width: 960, + height: 960, + from: "#16111f", + to: "#d66a4d", + accent: "#f5c06f", + }, + "fixture-aurora-primary-logo": { + title: "AURORA", + subtitle: "Primary logo", + initials: "A", + width: 1200, + height: 675, + from: "#0b1020", + to: "#7c3aed", + accent: "#67e8f9", + }, + "fixture-aurora-alt-logo": { + title: "A", + subtitle: "Square mark", + initials: "A", + width: 960, + height: 960, + from: "#2f211b", + to: "#f97316", + accent: "#fde68a", + }, + "fixture-afterglow-event-poster": { + title: "Afterglow Harbor", + subtitle: "Harbor sessions", + initials: "AG", + width: 1200, + height: 675, + from: "#111827", + to: "#0e7490", + accent: "#fb7185", + }, +}; + +function fixtureError(message: string, status = 403) { + return NextResponse.json({ error: message }, { status }); +} + +function fixtureRequestAllowed() { + const productionBlocked = + process.env.VERCEL_ENV === "production" && + process.env.VRDEX_ALLOW_PRODUCTION_E2E_HELPERS !== "true"; + + return !productionBlocked && process.env.VRDEX_ENABLE_PLAYWRIGHT_FIXTURES === "true"; +} + +function escapeSvgText(value: string) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +function renderSvg(asset: FixtureAsset) { + const title = escapeSvgText(asset.title); + const subtitle = escapeSvgText(asset.subtitle); + const initials = escapeSvgText(asset.initials); + const radius = Math.min(asset.width, asset.height) * 0.24; + + return ` + + + + + + + + + + + + + + + ${initials} + ${title} + ${subtitle.toUpperCase()} +`; +} + +export async function GET(_request: Request, { params }: FixtureAssetRouteProps) { + if (!fixtureRequestAllowed()) { + return fixtureError("Fixture assets are not enabled for this request."); + } + + const asset = fixtureAssets[(await params).assetId]; + if (!asset) { + return fixtureError("Unknown fixture asset.", 404); + } + + return new NextResponse(renderSvg(asset), { + headers: { + "Cache-Control": "no-store", + "Content-Type": "image/svg+xml; charset=utf-8", + }, + }); +} diff --git a/apps/web/src/app/api/v0/profile-assets/upload-intents/[intentId]/route.ts b/apps/web/src/app/api/v0/profile-assets/upload-intents/[intentId]/route.ts new file mode 100644 index 0000000..adc2bda --- /dev/null +++ b/apps/web/src/app/api/v0/profile-assets/upload-intents/[intentId]/route.ts @@ -0,0 +1,470 @@ +import { lookup } from "node:dns/promises"; +import type { IncomingHttpHeaders, IncomingMessage } from "node:http"; +import { request as httpsRequest } from "node:https"; +import { isIP } from "node:net"; + +import { NextRequest, NextResponse } from "next/server"; +import type { GenericId } from "convex/values"; + +import { api } from "@convex-generated-api"; +import { convexHttpClient } from "@/lib/server/convex-http"; +import { isProfileAssetStorageConfigured, putProfileAssetObject } from "@/lib/server/profile-asset-storage"; + +export const dynamic = "force-dynamic"; + +type RouteContext = { + params: Promise<{ + intentId: string; + }>; +}; + +type UploadBody = { + body: Uint8Array; + mimeType: string; +}; + +const PROFILE_ASSET_UPLOAD_MAX_BYTES = 12 * 1024 * 1024; +const FILE_UPLOAD_REQUEST_MAX_BYTES = PROFILE_ASSET_UPLOAD_MAX_BYTES + 64 * 1024; +const PROFILE_ASSET_MIME_TYPES = new Set(["image/png", "image/svg+xml", "image/jpeg", "image/webp"]); +const SOURCE_URL_MAX_REDIRECTS = 5; + +function errorResponse(message: string, status: number) { + return NextResponse.json({ error: message }, { status }); +} + +function normalizedContentType(value: string | null): string { + return (value ?? "application/octet-stream").split(";")[0]!.trim().toLowerCase(); +} + +function mimeTypeForFile(file: File): string { + const contentType = normalizedContentType(file.type); + + if (contentType !== "application/octet-stream") { + return contentType; + } + + const lowerName = file.name.toLowerCase(); + + if (lowerName.endsWith(".svg")) { + return "image/svg+xml"; + } + + if (lowerName.endsWith(".jpg") || lowerName.endsWith(".jpeg")) { + return "image/jpeg"; + } + + if (lowerName.endsWith(".webp")) { + return "image/webp"; + } + + if (lowerName.endsWith(".png")) { + return "image/png"; + } + + return contentType; +} + +function assertAllowedMimeType(mimeType: string) { + if (!PROFILE_ASSET_MIME_TYPES.has(mimeType)) { + throw new Error("Profile media assets must be PNG, SVG, JPEG, or WebP images."); + } +} + +function assertAllowedByteSize(byteSize: number) { + if (!Number.isSafeInteger(byteSize) || byteSize <= 0) { + throw new Error("Profile media assets must include a positive byte size."); + } + + if (byteSize > PROFILE_ASSET_UPLOAD_MAX_BYTES) { + throw new Error("Profile media assets must be 12 MB or smaller."); + } +} + +function validateUploadBody(upload: UploadBody) { + assertAllowedMimeType(upload.mimeType); + assertAllowedByteSize(upload.body.byteLength); +} + +function requestContentLength(request: NextRequest): number | null { + const header = request.headers.get("content-length"); + + if (header === null) { + return null; + } + + const value = Number(header); + + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error("Upload request included an invalid Content-Length header."); + } + + return value; +} + +async function requestBodyWithLimit(request: NextRequest): Promise { + const contentLength = requestContentLength(request); + + if (contentLength !== null && contentLength > FILE_UPLOAD_REQUEST_MAX_BYTES) { + throw new Error("Profile media upload requests are too large."); + } + + if (request.body === null) { + throw new Error("Upload requests must include a file field."); + } + + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let byteSize = 0; + + for (;;) { + const { done, value } = await reader.read(); + + if (done) { + break; + } + + byteSize += value.byteLength; + + if (byteSize > FILE_UPLOAD_REQUEST_MAX_BYTES) { + await reader.cancel(); + throw new Error("Profile media upload requests are too large."); + } + + chunks.push(value); + } + + const body = new Uint8Array(byteSize); + let offset = 0; + + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + + return body; +} + +async function bodyFromFileRequest(request: NextRequest): Promise { + const requestBody = await requestBodyWithLimit(request); + const headers = new Headers(request.headers); + const body = requestBody.buffer.slice( + requestBody.byteOffset, + requestBody.byteOffset + requestBody.byteLength, + ) as ArrayBuffer; + headers.delete("content-length"); + + const formData = await new Request(request.url, { + body, + headers, + method: request.method, + }).formData(); + const file = formData.get("file"); + + if (!(file instanceof File)) { + throw new Error("Upload requests must include a file field."); + } + + const mimeType = mimeTypeForFile(file); + assertAllowedMimeType(mimeType); + assertAllowedByteSize(file.size); + + return { + body: new Uint8Array(await file.arrayBuffer()), + mimeType, + }; +} + +function firstHeaderValue(value: IncomingHttpHeaders[string]): string | null { + if (Array.isArray(value)) { + return value[0] ?? null; + } + + return value ?? null; +} + +function redirectLocation(statusCode: number | undefined, location: IncomingHttpHeaders[string], sourceUrl: URL): URL | null { + if (![301, 302, 303, 307, 308].includes(statusCode ?? 0)) { + return null; + } + + const nextLocation = firstHeaderValue(location); + if (nextLocation === null) { + throw new Error("Source URL redirected without a Location header."); + } + + return new URL(nextLocation, sourceUrl); +} + +function ipv4Parts(address: string): number[] | null { + const parts = address.split(".").map((part) => Number(part)); + + if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) { + return null; + } + + return parts; +} + +function isPrivateIpv4(address: string): boolean { + const parts = ipv4Parts(address); + + if (parts === null) { + return true; + } + + const [a, b, c] = parts; + + return ( + a === 0 || + a === 10 || + a === 127 || + a >= 224 || + (a === 100 && b >= 64 && b <= 127) || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 0) || + (a === 192 && b === 168) || + (a === 198 && (b === 18 || b === 19)) || + (a === 198 && b === 51 && c === 100) || + (a === 203 && b === 0 && c === 113) + ); +} + +function hexPart(address: string, index: number): number { + return Number.parseInt(address.split(":")[index] || "0", 16); +} + +function isPrivateIpv6(address: string): boolean { + const normalized = address.toLowerCase().replace(/^\[|\]$/g, "").split("%")[0]!; + const mappedPrefix = "::ffff:"; + + if (normalized.startsWith(mappedPrefix)) { + return isPrivateIpv4(normalized.slice(mappedPrefix.length)); + } + + if (normalized === "::" || normalized === "::1") { + return true; + } + + const first = hexPart(normalized, 0); + const second = hexPart(normalized, 1); + const third = hexPart(normalized, 2); + + return ( + first === 0 || + first === 0x100 || + first === 0x2002 || + (first >= 0xfc00 && first <= 0xfdff) || + (first >= 0xfe80 && first <= 0xfebf) || + (first >= 0xfec0 && first <= 0xfeff) || + (first >= 0xff00 && first <= 0xffff) || + (first === 0x64 && second === 0xff9b && third === 0x1) || + (first === 0x2001 && second === 0xdb8) + ); +} + +function isPublicIpAddress(address: string): boolean { + const normalized = address.replace(/^\[|\]$/g, ""); + const version = isIP(normalized); + + if (version === 4) { + return !isPrivateIpv4(normalized); + } + + if (version === 6) { + return !isPrivateIpv6(normalized); + } + + return false; +} + +async function resolvePublicHttpsSourceUrl(sourceUrl: URL): Promise { + if (sourceUrl.protocol !== "https:") { + throw new Error("Profile media asset imports must use HTTPS URLs."); + } + + if (sourceUrl.username || sourceUrl.password) { + throw new Error("Profile media asset imports must not include URL credentials."); + } + + if (sourceUrl.port && sourceUrl.port !== "443") { + throw new Error("Profile media asset imports must use the default HTTPS port."); + } + + const hostname = sourceUrl.hostname.toLowerCase().replace(/^\[|\]$/g, ""); + + if (hostname === "localhost" || hostname.endsWith(".localhost")) { + throw new Error("Profile media asset imports must use public HTTPS URLs."); + } + + if (isIP(hostname)) { + if (!isPublicIpAddress(hostname)) { + throw new Error("Profile media asset imports must use public HTTPS URLs."); + } + return hostname; + } + + const addresses = await lookup(hostname, { all: true, verbatim: true }); + + if (addresses.length === 0 || addresses.some((address) => !isPublicIpAddress(address.address))) { + throw new Error("Profile media asset imports must use public HTTPS URLs."); + } + + return addresses[0]!.address; +} + +function contentLengthFromHeader(contentLength: string | null): number | null { + if (contentLength === null) { + return null; + } + + const value = Number(contentLength); + + if (!Number.isSafeInteger(value)) { + throw new Error("Source URL returned an invalid Content-Length header."); + } + + return value; +} + +function requestPinnedSourceUrl(sourceUrl: URL, address: string): Promise { + return new Promise((resolve, reject) => { + const request = httpsRequest( + { + host: address, + method: "GET", + path: `${sourceUrl.pathname}${sourceUrl.search}`, + port: 443, + servername: sourceUrl.hostname.replace(/^\[|\]$/g, ""), + headers: { + Host: sourceUrl.host, + "User-Agent": "VRDex profile media importer", + }, + }, + resolve, + ); + + request.setTimeout(15_000, () => request.destroy(new Error("Source URL request timed out."))); + request.on("error", reject); + request.end(); + }); +} + +async function responseBodyWithLimit(response: IncomingMessage): Promise { + const chunks: Uint8Array[] = []; + let byteSize = 0; + + for await (const chunk of response) { + const value = typeof chunk === "string" ? Buffer.from(chunk) : chunk; + + byteSize += value.byteLength; + + if (byteSize > PROFILE_ASSET_UPLOAD_MAX_BYTES) { + response.destroy(); + throw new Error("Profile media assets must be 12 MB or smaller."); + } + + chunks.push(value); + } + + assertAllowedByteSize(byteSize); + + const body = new Uint8Array(byteSize); + let offset = 0; + + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + + return body; +} + +async function bodyFromSourceUrl(sourceUrl: string): Promise { + let currentUrl = new URL(sourceUrl); + + for (let redirects = 0; redirects <= SOURCE_URL_MAX_REDIRECTS; redirects += 1) { + const address = await resolvePublicHttpsSourceUrl(currentUrl); + const response = await requestPinnedSourceUrl(currentUrl, address); + const redirectedUrl = redirectLocation(response.statusCode, response.headers.location, currentUrl); + + if (redirectedUrl !== null) { + response.resume(); + currentUrl = redirectedUrl; + continue; + } + + if ((response.statusCode ?? 0) < 200 || (response.statusCode ?? 0) >= 300) { + response.resume(); + throw new Error(`Source URL returned HTTP ${response.statusCode ?? 0}.`); + } + + const mimeType = normalizedContentType(firstHeaderValue(response.headers["content-type"])); + assertAllowedMimeType(mimeType); + const contentLength = contentLengthFromHeader(firstHeaderValue(response.headers["content-length"])); + if (contentLength !== null) { + assertAllowedByteSize(contentLength); + } + + return { + body: await responseBodyWithLimit(response), + mimeType, + }; + } + + throw new Error("Source URL redirected too many times."); +} + +export async function POST(request: NextRequest, context: RouteContext) { + if (!isProfileAssetStorageConfigured()) { + return errorResponse("Profile asset storage is not configured.", 501); + } + + const { intentId } = await context.params; + const uploadToken = request.headers.get("x-vrdex-upload-token")?.trim(); + + if (!uploadToken) { + return errorResponse("Upload token is required.", 403); + } + + const convex = convexHttpClient(); + const intent = await convex.query(api.profileAssets.validateUploadIntentForStorage, { + intentId: intentId as GenericId<"profileAssetUploadIntents">, + uploadToken, + }); + + if (intent === null) { + return errorResponse("Upload intent was not found or expired.", 404); + } + + try { + const upload = intent.sourceUrl + ? await bodyFromSourceUrl(intent.sourceUrl) + : await bodyFromFileRequest(request); + + validateUploadBody(upload); + + await putProfileAssetObject({ + storageKey: intent.storageKey, + body: upload.body, + contentType: upload.mimeType, + }); + await convex.mutation(api.profileAssets.markUploadIntentUploaded, { + intentId: intent.intentId, + uploadToken, + mimeType: upload.mimeType, + byteSize: upload.body.byteLength, + }); + + return NextResponse.json({ + intentId: intent.intentId, + storageKey: intent.storageKey, + mimeType: upload.mimeType, + byteSize: upload.body.byteLength, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Profile media upload failed."; + + return errorResponse(message, 400); + } +} diff --git a/apps/web/src/app/api/v0/profiles/[slug]/assets/[assetId]/file/route.ts b/apps/web/src/app/api/v0/profiles/[slug]/assets/[assetId]/file/route.ts new file mode 100644 index 0000000..27bb564 --- /dev/null +++ b/apps/web/src/app/api/v0/profiles/[slug]/assets/[assetId]/file/route.ts @@ -0,0 +1,67 @@ +import { api } from "@convex-generated-api"; +import { convexHttpClient } from "@/lib/server/convex-http"; +import { getProfileAssetObject, isProfileAssetStorageConfigured } from "@/lib/server/profile-asset-storage"; + +export const dynamic = "force-dynamic"; + +type RouteContext = { + params: Promise<{ + slug: string; + assetId: string; + }>; +}; + +function extensionForMimeType(mimeType: string): string { + if (mimeType === "image/svg+xml") { + return "svg"; + } + + return mimeType.split("/")[1] ?? "bin"; +} + +function safeFileName(value: string): string { + return value.trim().replace(/[^a-zA-Z0-9._ -]+/g, "-").replace(/^-+|-+$/g, "") || "profile-asset"; +} + +export async function GET(request: Request, context: RouteContext) { + if (!isProfileAssetStorageConfigured()) { + return Response.json({ error: "Profile asset storage is not configured." }, { status: 501 }); + } + + const { slug, assetId } = await context.params; + const asset = await convexHttpClient().query(api.profileAssets.getPublicAssetForStorage, { + slug, + assetId, + }); + + if (asset === null) { + return Response.json({ error: "Asset not found." }, { status: 404 }); + } + + const object = await getProfileAssetObject(asset.storageKey); + + if (object === null) { + return Response.json({ error: "Stored asset not found." }, { status: 404 }); + } + + const url = new URL(request.url); + const download = url.searchParams.get("download") === "1"; + const baseName = safeFileName(asset.originalFileName ?? asset.label ?? `${asset.displayName} logo`); + const hasExtension = /\.[a-z0-9]+$/i.test(baseName); + const fileName = hasExtension ? baseName : `${baseName}.${extensionForMimeType(asset.mimeType)}`; + const body = object.body.buffer.slice( + object.body.byteOffset, + object.body.byteOffset + object.body.byteLength, + ) as ArrayBuffer; + + return new Response(body, { + headers: { + "cache-control": "private, no-store", + "content-disposition": `${download ? "attachment" : "inline"}; filename="${fileName}"`, + "content-length": String(object.contentLength ?? object.body.byteLength), + "content-security-policy": "sandbox; script-src 'none'; object-src 'none'", + "content-type": object.contentType, + "x-content-type-options": "nosniff", + }, + }); +} diff --git a/apps/web/src/app/api/v0/profiles/[slug]/assets/route.ts b/apps/web/src/app/api/v0/profiles/[slug]/assets/route.ts new file mode 100644 index 0000000..8d29d3d --- /dev/null +++ b/apps/web/src/app/api/v0/profiles/[slug]/assets/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server"; + +import { api } from "@convex-generated-api"; +import { convexHttpClient } from "@/lib/server/convex-http"; + +export const dynamic = "force-dynamic"; + +type RouteContext = { + params: Promise<{ + slug: string; + }>; +}; + +export async function GET(_request: Request, context: RouteContext) { + const { slug } = await context.params; + const result = await convexHttpClient().query(api.profileAssets.listPublicBySlug, { slug }); + + if (result === null) { + return NextResponse.json({ error: "Profile not found." }, { status: 404 }); + } + + return NextResponse.json({ + profileType: result.profileType, + slug: result.slug, + displayName: result.displayName, + assets: result.mediaKit.assets, + mediaKit: result.mediaKit, + }); +} diff --git a/apps/web/src/app/api/v0/profiles/[slug]/logos.zip/route.ts b/apps/web/src/app/api/v0/profiles/[slug]/logos.zip/route.ts new file mode 100644 index 0000000..7d4822c --- /dev/null +++ b/apps/web/src/app/api/v0/profiles/[slug]/logos.zip/route.ts @@ -0,0 +1,88 @@ +import { api } from "@convex-generated-api"; +import { convexHttpClient } from "@/lib/server/convex-http"; +import { getProfileAssetObject, isProfileAssetStorageConfigured } from "@/lib/server/profile-asset-storage"; +import { createStoredZip } from "@/lib/server/zip"; + +export const dynamic = "force-dynamic"; + +type RouteContext = { + params: Promise<{ + slug: string; + }>; +}; + +function extensionForMimeType(mimeType: string): string { + if (mimeType === "image/svg+xml") { + return "svg"; + } + + return mimeType.split("/")[1] ?? "bin"; +} + +function safeFilePart(value: string): string { + return value.trim().replace(/[^a-zA-Z0-9._ -]+/g, "-").replace(/^-+|-+$/g, "") || "logo"; +} + +export async function GET(_request: Request, context: RouteContext) { + if (!isProfileAssetStorageConfigured()) { + return Response.json({ error: "Profile asset storage is not configured." }, { status: 501 }); + } + + const { slug } = await context.params; + const convex = convexHttpClient(); + const profile = await convex.query(api.profileAssets.listPublicBySlug, { slug }); + + if (profile === null) { + return Response.json({ error: "Profile not found." }, { status: 404 }); + } + + if (profile.mediaKit.logos.length === 0) { + return Response.json({ error: "No public logos found." }, { status: 404 }); + } + + const entries = ( + await Promise.all( + profile.mediaKit.logos.map(async (logo, index) => { + const asset = await convex.query(api.profileAssets.getPublicAssetForStorage, { + slug, + assetId: logo.assetId, + }); + + if (asset === null) { + return null; + } + + const object = await getProfileAssetObject(asset.storageKey); + if (object === null) { + return null; + } + + const extension = extensionForMimeType(asset.mimeType); + const name = safeFilePart(asset.label ?? (index === 0 ? "primary-logo" : `logo-${index + 1}`)); + + return { + name: `${String(index + 1).padStart(2, "0")}-${name}.${extension}`, + body: object.body, + }; + }), + ) + ).filter((entry): entry is { name: string; body: Uint8Array } => entry !== null); + + if (entries.length === 0) { + return Response.json({ error: "Stored logos were not found." }, { status: 404 }); + } + + const zip = createStoredZip(entries); + const fileName = `${safeFilePart(profile.displayName)}-logos.zip`; + const body = zip.buffer.slice(zip.byteOffset, zip.byteOffset + zip.byteLength) as ArrayBuffer; + + return new Response(body, { + headers: { + "cache-control": "public, max-age=300", + "content-disposition": `attachment; filename="${fileName}"`, + "content-length": String(zip.byteLength), + "content-type": "application/zip", + "x-content-type-options": "nosniff", + }, + }); +} diff --git a/apps/web/src/app/api/v0/profiles/[slug]/logos/route.ts b/apps/web/src/app/api/v0/profiles/[slug]/logos/route.ts new file mode 100644 index 0000000..311996c --- /dev/null +++ b/apps/web/src/app/api/v0/profiles/[slug]/logos/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; + +import { api } from "@convex-generated-api"; +import { convexHttpClient } from "@/lib/server/convex-http"; + +export const dynamic = "force-dynamic"; + +type RouteContext = { + params: Promise<{ + slug: string; + }>; +}; + +export async function GET(_request: Request, context: RouteContext) { + const { slug } = await context.params; + const result = await convexHttpClient().query(api.profileAssets.listPublicBySlug, { slug }); + + if (result === null) { + return NextResponse.json({ error: "Profile not found." }, { status: 404 }); + } + + return NextResponse.json({ + profileType: result.profileType, + slug: result.slug, + displayName: result.displayName, + primaryLogo: result.mediaKit.primaryLogo, + additionalLogos: result.mediaKit.additionalLogos, + logos: result.mediaKit.logos, + logoZipUrl: result.mediaKit.logoZipUrl, + }); +} diff --git a/apps/web/src/app/api/v0/profiles/[slug]/route.ts b/apps/web/src/app/api/v0/profiles/[slug]/route.ts new file mode 100644 index 0000000..af9396c --- /dev/null +++ b/apps/web/src/app/api/v0/profiles/[slug]/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server"; + +import { api } from "@convex-generated-api"; +import { convexHttpClient } from "@/lib/server/convex-http"; + +export const dynamic = "force-dynamic"; + +type RouteContext = { + params: Promise<{ + slug: string; + }>; +}; + +export async function GET(_request: Request, context: RouteContext) { + const { slug } = await context.params; + const profile = await convexHttpClient().query(api.profiles.getPublicBySlug, { slug }); + + if (profile === null) { + return NextResponse.json({ error: "Profile not found." }, { status: 404 }); + } + + return NextResponse.json(profile); +} diff --git a/apps/web/src/app/submit/profile-submission-form.tsx b/apps/web/src/app/submit/profile-submission-form.tsx index 919ae94..808ba6a 100644 --- a/apps/web/src/app/submit/profile-submission-form.tsx +++ b/apps/web/src/app/submit/profile-submission-form.tsx @@ -6,9 +6,10 @@ import { useConvexAuth, useMutation } from "convex/react"; import { api } from "@convex-generated-api"; import { buttonVariants, Button } from "@/components/ui/button"; import { Card, Eyebrow } from "@/components/ui/card"; -import { Field, Input, Select } from "@/components/ui/field"; +import { Field, FieldText, Input, Select } from "@/components/ui/field"; import { Notice } from "@/components/ui/notice"; import { cn } from "@/lib/cn"; +import type { Id } from "../../../../../convex/_generated/dataModel"; const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL; @@ -28,12 +29,34 @@ type ProfileSubmissionResult = { slug: string; }; +type ProfileAssetPlacement = "profile_image" | "banner" | "primary_logo" | "additional_logo"; + +type ProfileAssetUploadPayload = { + intentId: Id<"profileAssetUploadIntents">; + uploadToken: string; + label?: string; + caption?: string; + placements: ProfileAssetPlacement[]; + position?: number; +}; + +type CreateUploadIntent = (payload: { + originalFileName?: string; + sourceUrl?: string; + mimeType: string; + byteSize?: number; +}) => Promise<{ + intentId: Id<"profileAssetUploadIntents">; + uploadToken: string; +}>; + type ProfileSubmissionPayload = | { profileType: "person"; displayName: string; aliases: string[]; tags: string[]; + assets?: ProfileAssetUploadPayload[]; person: { roleTags: string[] }; } | { @@ -41,6 +64,7 @@ type ProfileSubmissionPayload = displayName: string; aliases: string[]; tags: string[]; + assets?: ProfileAssetUploadPayload[]; community: { subtype: string; categoryTags: string[] }; }; @@ -84,12 +108,176 @@ function stringField(value: FormDataEntryValue | null): string { return typeof value === "string" ? value : ""; } -function payloadFromFormData(formData: FormData): ProfileSubmissionPayload { +function fileField(formData: FormData, name: string): File | undefined { + const value = formData.get(name); + + return value instanceof File && value.size > 0 ? value : undefined; +} + +function fileListField(formData: FormData, name: string): File[] { + return formData.getAll(name).filter((value): value is File => value instanceof File && value.size > 0); +} + +function optionalStringField(value: FormDataEntryValue | null): string | undefined { + const text = stringField(value).trim(); + + return text ? text : undefined; +} + +function mimeTypeForFile(file: File): string { + if (file.type) { + return file.type; + } + + const lowerName = file.name.toLowerCase(); + + if (lowerName.endsWith(".svg")) { + return "image/svg+xml"; + } + + if (lowerName.endsWith(".jpg") || lowerName.endsWith(".jpeg")) { + return "image/jpeg"; + } + + if (lowerName.endsWith(".webp")) { + return "image/webp"; + } + + return "image/png"; +} + +function mimeTypeForUrl(url: string): string { + const lowerUrl = url.toLowerCase().split("?")[0] ?? ""; + + if (lowerUrl.endsWith(".svg")) { + return "image/svg+xml"; + } + + if (lowerUrl.endsWith(".jpg") || lowerUrl.endsWith(".jpeg")) { + return "image/jpeg"; + } + + if (lowerUrl.endsWith(".webp")) { + return "image/webp"; + } + + return "image/png"; +} + +async function uploadAssetFile( + createUploadIntent: CreateUploadIntent, + file: File, +): Promise> { + const intent = await createUploadIntent({ + originalFileName: file.name, + mimeType: mimeTypeForFile(file), + byteSize: file.size, + }); + const formData = new FormData(); + formData.set("file", file); + const response = await fetch(`/api/v0/profile-assets/upload-intents/${intent.intentId}`, { + method: "POST", + headers: { "x-vrdex-upload-token": intent.uploadToken }, + body: formData, + }); + + if (!response.ok) { + const errorBody = (await response.json().catch(() => null)) as { error?: string } | null; + throw new Error(errorBody?.error ?? "Profile media upload failed."); + } + + return intent; +} + +async function importAssetUrl( + createUploadIntent: CreateUploadIntent, + sourceUrl: string, +): Promise> { + const intent = await createUploadIntent({ + sourceUrl, + mimeType: mimeTypeForUrl(sourceUrl), + }); + const response = await fetch(`/api/v0/profile-assets/upload-intents/${intent.intentId}`, { + method: "POST", + headers: { "x-vrdex-upload-token": intent.uploadToken }, + }); + + if (!response.ok) { + const errorBody = (await response.json().catch(() => null)) as { error?: string } | null; + throw new Error(errorBody?.error ?? "Profile media import failed."); + } + + return intent; +} + +async function uploadAssetsFromFormData( + formData: FormData, + createUploadIntent?: CreateUploadIntent, +): Promise { + const profileImage = fileField(formData, "profileImage"); + const primaryLogo = fileField(formData, "primaryLogo"); + const primaryLogoSourceUrl = optionalStringField(formData.get("primaryLogoSourceUrl")); + const additionalLogos = fileListField(formData, "additionalLogos"); + const useProfileImageAsLogo = formData.get("useProfileImageAsPrimaryLogo") === "on"; + const uploads: ProfileAssetUploadPayload[] = []; + + if (!profileImage && !primaryLogo && !primaryLogoSourceUrl && additionalLogos.length === 0) { + return uploads; + } + + if (!createUploadIntent) { + throw new Error("Profile media uploads require the live authenticated submission flow."); + } + + if (profileImage) { + const intent = await uploadAssetFile(createUploadIntent, profileImage); + uploads.push({ + ...intent, + label: optionalStringField(formData.get("profileImageLabel")) ?? "Profile image", + placements: useProfileImageAsLogo && !primaryLogo && !primaryLogoSourceUrl + ? ["profile_image", "primary_logo"] + : ["profile_image"], + }); + } + + if (primaryLogo) { + const intent = await uploadAssetFile(createUploadIntent, primaryLogo); + uploads.push({ + ...intent, + label: optionalStringField(formData.get("primaryLogoLabel")) ?? "Primary logo", + caption: optionalStringField(formData.get("primaryLogoCaption")), + placements: ["primary_logo"], + }); + } else if (primaryLogoSourceUrl) { + const intent = await importAssetUrl(createUploadIntent, primaryLogoSourceUrl); + uploads.push({ + ...intent, + label: optionalStringField(formData.get("primaryLogoLabel")) ?? "Primary logo", + caption: optionalStringField(formData.get("primaryLogoCaption")), + placements: ["primary_logo"], + }); + } + + for (const [index, file] of additionalLogos.entries()) { + const intent = await uploadAssetFile(createUploadIntent, file); + uploads.push({ + ...intent, + label: `Logo ${index + 2}`, + placements: ["additional_logo"], + position: index + 1, + }); + } + + return uploads; +} + +function payloadFromFormData(formData: FormData, assets?: ProfileAssetUploadPayload[]): ProfileSubmissionPayload { const selectedType = stringField(formData.get("profileType")) as ProfileType; const sharedPayload = { displayName: stringField(formData.get("displayName")), aliases: splitList(formData.get("aliases")), tags: splitList(formData.get("tags")), + ...(assets && assets.length > 0 ? { assets } : {}), }; if (selectedType === "community") { @@ -145,9 +333,11 @@ function SignInRequiredSubmissionPanel() { function SubmissionFormFields({ submitProfile, + createUploadIntent, helperText, }: { submitProfile: (payload: ProfileSubmissionPayload) => Promise; + createUploadIntent?: CreateUploadIntent; helperText?: string; }) { const [profileType, setProfileType] = useState("person"); @@ -163,7 +353,8 @@ function SubmissionFormFields({ setStatus({ kind: "submitting" }); try { - const result = await submitProfile(payloadFromFormData(formData)); + const assets = await uploadAssetsFromFormData(formData, createUploadIntent); + const result = await submitProfile(payloadFromFormData(formData, assets)); form.reset(); setProfileType("person"); @@ -229,9 +420,53 @@ function SubmissionFormFields({ {helperText ?? - "Community submissions intentionally skip custom slugs, freeform bios, about text, image URLs, private contact details, and claim signals. VRDex generates the slug and marks the profile as unclaimed until an owner claim flow exists."} + "Community submissions intentionally skip custom slugs, freeform bios, private contact details, and claim signals. Media-kit files are stored in VRDex-managed storage and marked with community-submitted provenance."} + + Media kit +
+ + Profile image + + Optional avatar/profile picture. PNG, JPG, WebP, or SVG. + + + Profile image label + + +
+ +
+ + Primary logo upload + + PNG or SVG recommended for event runners. + + + Primary logo HTTPS URL + + VRDex downloads the file into managed storage instead of hotlinking it. + + + Primary logo label + + + + Primary logo caption + + +
+ + Additional logos + + Optional extra public logo files available for download. + +
+