Compare commits
2 Commits
4680fbb378
...
ca5bce7134
| Author | SHA1 | Date |
|---|---|---|
|
|
ca5bce7134 | |
|
|
48f0ff4c0f |
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { useMemo, useRef } from "react";
|
||||||
|
import { Camera, Images } from "lucide-react";
|
||||||
|
import { isMobileUa } from "../lib/photoWallShare";
|
||||||
|
|
||||||
|
type PhotoSourcePickerProps = {
|
||||||
|
disabled?: boolean;
|
||||||
|
picking?: boolean;
|
||||||
|
hasFile?: boolean;
|
||||||
|
onPick: (file: File | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const btnClass =
|
||||||
|
"inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg border border-zinc-300 bg-zinc-100 px-4 text-sm font-semibold text-gray-600 disabled:opacity-55";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mobile: gallery + camera (capture forces camera-only on iOS/Android).
|
||||||
|
* Desktop: one “Upload Photo” control that uses the gallery/file input.
|
||||||
|
*/
|
||||||
|
export function PhotoSourcePicker({ disabled, picking, hasFile, onPick }: PhotoSourcePickerProps) {
|
||||||
|
const galleryRef = useRef<HTMLInputElement>(null);
|
||||||
|
const cameraRef = useRef<HTMLInputElement>(null);
|
||||||
|
const isMobile = useMemo(() => isMobileUa(), []);
|
||||||
|
|
||||||
|
const emitFile = (input: HTMLInputElement | null, selected: File | null) => {
|
||||||
|
onPick(selected);
|
||||||
|
if (input) input.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const galleryLabel = picking
|
||||||
|
? "Processing…"
|
||||||
|
: isMobile
|
||||||
|
? "Gallery"
|
||||||
|
: hasFile
|
||||||
|
? "Change photo"
|
||||||
|
: "Upload Photo";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={isMobile ? "grid grid-cols-2 gap-2" : undefined}>
|
||||||
|
<input
|
||||||
|
ref={galleryRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => emitFile(galleryRef.current, e.target.files?.[0] || null)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={cameraRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
capture="user"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => emitFile(cameraRef.current, e.target.files?.[0] || null)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => galleryRef.current?.click()}
|
||||||
|
className={btnClass}
|
||||||
|
>
|
||||||
|
<Images className="size-5 shrink-0" aria-hidden />
|
||||||
|
{galleryLabel}
|
||||||
|
</button>
|
||||||
|
{isMobile ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => cameraRef.current?.click()}
|
||||||
|
className={btnClass}
|
||||||
|
>
|
||||||
|
<Camera className="size-5 shrink-0" aria-hidden />
|
||||||
|
{picking ? "Processing…" : "Camera"}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
import { Calendar, MapPin } from "lucide-react";
|
||||||
|
import type { PhotoWallEventHeaderDisplay, PhotoWallPublicEvent } from "../lib/api";
|
||||||
|
import { formatEventScheduleRange } from "../lib/sessionDisplay";
|
||||||
|
import { EventBanner } from "./EventBanner";
|
||||||
|
import { EventBuzzLogo } from "./EventBuzzLogo";
|
||||||
|
|
||||||
|
const EVENT_TYPE_LABEL: Record<string, string> = {
|
||||||
|
conference: "Conference",
|
||||||
|
workshop: "Workshop",
|
||||||
|
summit: "Summit",
|
||||||
|
cme: "CME",
|
||||||
|
webinar: "Webinar",
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveEventHeader(display?: PhotoWallEventHeaderDisplay | null) {
|
||||||
|
return {
|
||||||
|
showLogo: display?.showLogo !== false,
|
||||||
|
showBanner: display?.showBanner === true,
|
||||||
|
showTitle: display?.showTitle !== false,
|
||||||
|
showDateRange: display?.showDateRange !== false,
|
||||||
|
showEventType: display?.showEventType !== false,
|
||||||
|
showLocationType: display?.showLocationType !== false,
|
||||||
|
showVenue: display?.showVenue !== false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEventType(t?: string): string {
|
||||||
|
if (!t) return "";
|
||||||
|
return EVENT_TYPE_LABEL[t] || t.replace(/^\w/, (c) => c.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLocationKind(locationType?: string): string {
|
||||||
|
if (locationType === "virtual") return "Virtual";
|
||||||
|
if (locationType === "hybrid") return "Hybrid";
|
||||||
|
if (locationType === "physical") return "In person";
|
||||||
|
return locationType?.trim() || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PhotoWallPresentChrome({
|
||||||
|
event,
|
||||||
|
eventHeader,
|
||||||
|
eventNameFallback = "",
|
||||||
|
}: {
|
||||||
|
event?: PhotoWallPublicEvent | null;
|
||||||
|
eventHeader?: PhotoWallEventHeaderDisplay | null;
|
||||||
|
eventNameFallback?: string;
|
||||||
|
}) {
|
||||||
|
const flags = resolveEventHeader(eventHeader);
|
||||||
|
const eventName = event?.name?.trim() || eventNameFallback || "Event";
|
||||||
|
const bannerSrc = event?.imageUrl?.trim() || "";
|
||||||
|
const logoSrc = event?.logoUrl?.trim() || "";
|
||||||
|
const schedule = event?.startDate
|
||||||
|
? formatEventScheduleRange(
|
||||||
|
typeof event.startDate === "string" ? event.startDate : String(event.startDate),
|
||||||
|
event.endDate
|
||||||
|
? typeof event.endDate === "string"
|
||||||
|
? event.endDate
|
||||||
|
: String(event.endDate)
|
||||||
|
: String(event.startDate),
|
||||||
|
)
|
||||||
|
: "";
|
||||||
|
const eventTypeLabel = formatEventType(event?.eventType);
|
||||||
|
const locationKind = formatLocationKind(event?.locationType);
|
||||||
|
const eventLocation = event?.location?.trim() || "";
|
||||||
|
const showDate = flags.showDateRange && Boolean(schedule);
|
||||||
|
const showType = flags.showEventType && Boolean(eventTypeLabel);
|
||||||
|
const showLocationKind = flags.showLocationType && Boolean(locationKind);
|
||||||
|
const showVenue = flags.showVenue && Boolean(eventLocation);
|
||||||
|
const showTitle = flags.showTitle && Boolean(eventName);
|
||||||
|
const showEventInfo = showTitle || showDate || showType || showLocationKind || showVenue;
|
||||||
|
const showHeader = flags.showLogo || showEventInfo;
|
||||||
|
|
||||||
|
if (!showHeader && !flags.showBanner) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showHeader ? (
|
||||||
|
<header className="flex flex-col items-start justify-start min-h-[64px] w-full sm:flex-row sm:items-center sm:justify-between gap-6 bg-[var(--theme-header-color)] text-[var(--theme-card-text-color)] px-4 py-3 shadow-xs sm:px-8">
|
||||||
|
<div className="flex min-w-0 shrink-0 items-center">
|
||||||
|
{flags.showLogo ? (
|
||||||
|
logoSrc ? (
|
||||||
|
<img
|
||||||
|
src={logoSrc}
|
||||||
|
alt={eventName}
|
||||||
|
className="max-h-10 max-w-[min(220px,40vw)] object-contain object-left"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EventBuzzLogo variant="color" className="h-8 max-h-8 w-auto" />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="block w-0" aria-hidden />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showEventInfo ? (
|
||||||
|
<div className="min-w-0 sm:max-w-[min(640px,58vw)] flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex flex-col items-start gap-1">
|
||||||
|
{showType ? (
|
||||||
|
<div className="mb-1 flex items-center justify-end text-xs">
|
||||||
|
<span className="rounded bg-[var(--theme-button-color)]/20 px-2 py-0.5">{eventTypeLabel}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{showTitle ? (
|
||||||
|
<h1 className="truncate text-base font-semibold leading-tight sm:text-lg">{eventName}</h1>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-start gap-1">
|
||||||
|
{showDate ? (
|
||||||
|
<div
|
||||||
|
className={`flex items-center justify-end gap-2 text-xs font-semibold text-[var(--theme-card-text-color)]/90 ${showTitle ? "mt-1" : ""}`}
|
||||||
|
>
|
||||||
|
<Calendar className="size-4 shrink-0 text-[var(--theme-button-color)]" strokeWidth={2} />
|
||||||
|
<span>{schedule}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{showLocationKind || showVenue ? (
|
||||||
|
<div className="mt-1 flex flex-wrap flex-col items-start gap-x-3 gap-y-1 text-xs font-semibold text-[var(--theme-card-text-color)]/85">
|
||||||
|
{showLocationKind ? <span className="font-semibold uppercase">{locationKind}</span> : null}
|
||||||
|
{showVenue ? (
|
||||||
|
<span className="inline-flex max-w-full items-start gap-1.5 text-left">
|
||||||
|
<MapPin className="mt-0.5 size-4 shrink-0 text-[var(--theme-button-color)]" strokeWidth={2} />
|
||||||
|
<span className="">{eventLocation}</span>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</header>
|
||||||
|
) : null}
|
||||||
|
{flags.showBanner ? (
|
||||||
|
<EventBanner imageUrl={bannerSrc} alt={eventName} className="w-full shadow-sm mb-3" />
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -12,13 +12,13 @@ export function PresentFooterNav({
|
||||||
nextDisabled: boolean;
|
nextDisabled: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<footer className="fixed inset-x-0 bottom-0 z-40 border-t border-[#e5e5ea] bg-white/95 shadow-[0_-4px_12px_rgba(0,0,0,0.06)] backdrop-blur-md">
|
<footer className="fixed inset-x-0 bottom-0 z-40 border-t-0 border-[#e5e5ea bg-white/ card-bg shadow-[0_-4px_12px_rgba(0,0,0,0.06) shadow-md backdrop-blur-m">
|
||||||
<div className="mx-auto flex h-16 max-w-5xl items-center justify-between gap-4 px-4 sm:px-6">
|
<div className="mx-auto flex h-16 max-w-5xl items-center justify-between gap-4 px-4 sm:px-6">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={prevDisabled}
|
disabled={prevDisabled}
|
||||||
onClick={onPrev}
|
onClick={onPrev}
|
||||||
className="min-w-[88px] rounded-lg border border-[#e5e5ea] bg-white px-4 py-2 text-sm font-semibold text-[#24282f] transition hover:bg-[#f2f2f7] disabled:cursor-not-allowed disabled:opacity-40"
|
className="min-w-[88px] rounded-lg border-0 border-[#e5e5ea bg-whit bg-zinc-100 px-4 py-2 text-sm font-semibold text-[#24282f text-gray-800 transition hover:bg-[#f2f2f7] disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
>
|
>
|
||||||
◀ Prev
|
◀ Prev
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -29,7 +29,7 @@ export function PresentFooterNav({
|
||||||
type="button"
|
type="button"
|
||||||
disabled={nextDisabled}
|
disabled={nextDisabled}
|
||||||
onClick={onNext}
|
onClick={onNext}
|
||||||
className="min-w-[88px] rounded-lg border border-[#e5e5ea] bg-white px-4 py-2 text-sm font-semibold text-[#24282f] transition hover:bg-[#f2f2f7] disabled:cursor-not-allowed disabled:opacity-40"
|
className="min-w-[88px] rounded-lg border-0 border-[#e5e5ea bg-whit bg-zinc-100 px-4 py-2 text-sm font-semibold text-[#24282f text-gray-800 transition hover:bg-[#f2f2f7] disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
>
|
>
|
||||||
Next ▶
|
Next ▶
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -26,25 +26,25 @@ export function PresentLeaderboard({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-lg border border-[#e5e5ea] bg-white shadow-sm">
|
<div className="overflow-hidden rounded-lg border-0 border-[#e5e5ea bg-whit card-bg shadow-md">
|
||||||
<div className="grid grid-cols-[48px_minmax(0,1fr)_88px_72px] gap-2 border-b border-[#e5e5ea] bg-[#f2f2f7] px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-[rgba(36,40,47,0.55)] sm:grid-cols-[56px_minmax(0,1fr)_110px_88px] sm:px-4">
|
<div className="grid grid-cols-[48px_minmax(0,1fr)_88px_72px] gap-2 border-b border-[#e5e5ea] bg-[#f2f2f7 bg-[var(--theme-card-color)]/50 px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-[rgba(36,40,47,0.55) text-[var(--theme-card-text-color)]/70 sm:grid-cols-[56px_minmax(0,1fr)_110px_88px] sm:px-4">
|
||||||
<span>Rank</span>
|
<span>Rank</span>
|
||||||
<span>Name</span>
|
<span>Name</span>
|
||||||
<span className="text-right">Correct</span>
|
<span className="text-right">Correct</span>
|
||||||
<span className="text-right">Time</span>
|
<span className="text-right">Time</span>
|
||||||
</div>
|
</div>
|
||||||
<ul className="divide-y divide-[#e5e5ea]">
|
<ul className="divide-y divide-[#e5e5ea divide-[var(--theme-card-text-color)]/30">
|
||||||
{entries.map((row) => (
|
{entries.map((row) => (
|
||||||
<li
|
<li
|
||||||
key={`${row.participantId || row.displayName}-${row.rank}`}
|
key={`${row.participantId || row.displayName}-${row.rank}`}
|
||||||
className="grid grid-cols-[48px_minmax(0,1fr)_88px_72px] items-center gap-2 px-3 py-2.5 sm:grid-cols-[56px_minmax(0,1fr)_110px_88px] sm:px-4"
|
className="grid grid-cols-[48px_minmax(0,1fr)_88px_72px] items-center gap-2 px-3 py-2.5 sm:grid-cols-[56px_minmax(0,1fr)_110px_88px] sm:px-4"
|
||||||
>
|
>
|
||||||
<span className="text-sm font-bold tabular-nums text-[rgba(36,40,47,0.45)]">#{row.rank}</span>
|
<span className="text-sm font-bold tabular-nums text-[rgba(36,40,47,0.45) text-[var(--theme-card-text-color)]/70">#{row.rank}</span>
|
||||||
<span className="truncate text-sm font-semibold text-[#24282f] sm:text-base">{row.displayName}</span>
|
<span className="truncate text-sm font-semibold text-[#24282f sm:text-base">{row.displayName}</span>
|
||||||
<span className="text-right text-sm font-semibold tabular-nums text-[#24282f]">
|
<span className="text-right text-sm font-semibold tabular-nums text-[#24282f ">
|
||||||
{row.correctCount}/{row.totalQuestions}
|
{row.correctCount}/{row.totalQuestions}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-right text-sm font-semibold tabular-nums text-[#1e72ef]">
|
<span className="text-right text-sm font-semibold tabular-nums text-[#1e72ef text-[var(--theme-button-color)]">
|
||||||
{formatTimeTaken(row.timeTakenSec)}
|
{formatTimeTaken(row.timeTakenSec)}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
|
|
|
||||||
|
|
@ -34,14 +34,14 @@ export function PresentRespondersList({
|
||||||
const reveal = Boolean(isQuiz && showCorrectness);
|
const reveal = Boolean(isQuiz && showCorrectness);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-lg border border-[#e5e5ea] bg-white shadow-sm">
|
<div className="overflow-hidden rounded-lg border-0 border-[#e5e5ea bg-whit card-bg shadow-md">
|
||||||
<div className="grid grid-cols-[48px_minmax(0,1fr)_96px_72px] gap-2 border-b border-[#e5e5ea] bg-[#f2f2f7] px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-[rgba(36,40,47,0.55)] sm:grid-cols-[56px_minmax(0,1fr)_110px_88px] sm:px-4">
|
<div className="grid grid-cols-[48px_minmax(0,1fr)_96px_72px] gap-2 border-b border-[#e5e5ea] bg-[#f2f2f7 bg-[var(--theme-card-color)]/50 px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-[rgba(36,40,47,0.55) text-[var(--theme-card-text-color)]/70 sm:grid-cols-[56px_minmax(0,1fr)_110px_88px] sm:px-4">
|
||||||
<span>Rank</span>
|
<span>Rank</span>
|
||||||
<span>Name</span>
|
<span>Name</span>
|
||||||
<span className="text-right">Result</span>
|
<span className="text-right">Result</span>
|
||||||
<span className="text-right">Time</span>
|
<span className="text-right">Time</span>
|
||||||
</div>
|
</div>
|
||||||
<ul className="divide-y divide-[#e5e5ea]">
|
<ul className="divide-y divide-[#e5e5ea divide-[var(--theme-card-text-color)]/30">
|
||||||
{responders.map((r, i) => {
|
{responders.map((r, i) => {
|
||||||
const label = resultLabel(r.isCorrect, reveal);
|
const label = resultLabel(r.isCorrect, reveal);
|
||||||
return (
|
return (
|
||||||
|
|
@ -49,20 +49,20 @@ export function PresentRespondersList({
|
||||||
key={`${r.participantId}-${r.answeredAt}-${i}`}
|
key={`${r.participantId}-${r.answeredAt}-${i}`}
|
||||||
className="grid grid-cols-[48px_minmax(0,1fr)_96px_72px] items-center gap-2 px-3 py-2.5 sm:grid-cols-[56px_minmax(0,1fr)_110px_88px] sm:px-4"
|
className="grid grid-cols-[48px_minmax(0,1fr)_96px_72px] items-center gap-2 px-3 py-2.5 sm:grid-cols-[56px_minmax(0,1fr)_110px_88px] sm:px-4"
|
||||||
>
|
>
|
||||||
<span className="text-sm font-bold tabular-nums text-[rgba(36,40,47,0.45)]">#{i + 1}</span>
|
<span className="text-sm font-bold tabular-nums text-[rgba(36,40,47,0.45) text-[var(--theme-card-text-color)]/70">#{i + 1}</span>
|
||||||
<span className="truncate text-sm font-semibold text-[#24282f] sm:text-base">{r.displayName}</span>
|
<span className="truncate text-sm font-semibold text-[#24282f sm:text-base">{r.displayName}</span>
|
||||||
<span
|
<span
|
||||||
className={`text-right text-sm font-semibold ${
|
className={`text-right text-sm font-semibold ${
|
||||||
label === "Correct"
|
label === "Correct"
|
||||||
? "text-emerald-700"
|
? "text-emerald-700"
|
||||||
: label === "Incorrect"
|
: label === "Incorrect"
|
||||||
? "text-red-600"
|
? "text-red-600"
|
||||||
: "text-[rgba(36,40,47,0.45)]"
|
: "text-[rgba(36,40,47,0.45) text-[var(--theme-card-text-color)]/70"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-right text-sm font-semibold tabular-nums text-[#1e72ef]">
|
<span className="text-right text-sm font-semibold tabular-nums text-[#1e72ef text-[var(--theme-button-color)]">
|
||||||
{formatTimeTaken(r.responseTimeSec)}
|
{formatTimeTaken(r.responseTimeSec)}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ export function PresentSessionMenu({
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-controls={menuId}
|
aria-controls={menuId}
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
className="flex h-11 w-11 items-center justify-center rounded-lg border border-[#e5e5ea] bg-white text-[#24282f] shadow-sm transition hover:bg-[#f2f2f7]"
|
className="flex h-11 w-11 items-center justify-center rounded-lg border-0 border-[#e5e5ea bg-whit text-[#24282f card-bg shadow-sm transition hover:bg-[#f2f2f7]"
|
||||||
>
|
>
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
<path
|
<path
|
||||||
|
|
@ -96,11 +96,11 @@ export function PresentSessionMenu({
|
||||||
className="absolute left-0 top-full z-50 mt-2 max-h-[70vh] w-[min(22rem,calc(100vw-2rem))] overflow-y-auto rounded-xl border border-[#e5e5ea] bg-white py-2 shadow-lg"
|
className="absolute left-0 top-full z-50 mt-2 max-h-[70vh] w-[min(22rem,calc(100vw-2rem))] overflow-y-auto rounded-xl border border-[#e5e5ea] bg-white py-2 shadow-lg"
|
||||||
>
|
>
|
||||||
{groups.length === 0 ? (
|
{groups.length === 0 ? (
|
||||||
<p className="px-4 py-3 text-sm text-[rgba(36,40,47,0.55)]">No activities</p>
|
<p className="px-4 py-3 text-sm text-[rgba(36,40,47,0.55) text-[var(--theme-card-text-color)]/70">No activities</p>
|
||||||
) : (
|
) : (
|
||||||
groups.map((group) => (
|
groups.map((group) => (
|
||||||
<div key={group.label} className="py-1">
|
<div key={group.label} className="py-1">
|
||||||
<p className="px-4 py-1.5 text-xs font-bold uppercase tracking-wide text-[rgba(36,40,47,0.45)]">
|
<p className="px-4 py-1.5 text-xs font-bold uppercase tracking-wide text-[rgba(36,40,47,0.45) text-[var(--theme-card-text-color)]/70">
|
||||||
{group.label}
|
{group.label}
|
||||||
</p>
|
</p>
|
||||||
{group.items.map((item) => {
|
{group.items.map((item) => {
|
||||||
|
|
@ -118,7 +118,7 @@ export function PresentSessionMenu({
|
||||||
isCurrent ? "bg-[#eef5ff]" : ""
|
isCurrent ? "bg-[#eef5ff]" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="min-w-0 flex-1 truncate font-semibold text-[#24282f]">
|
<span className="min-w-0 flex-1 truncate font-semibold text-[#24282f text-[var(--theme-card-text-color)]">
|
||||||
{item.title || "Untitled"}
|
{item.title || "Untitled"}
|
||||||
</span>
|
</span>
|
||||||
{item.isOnAir ? (
|
{item.isOnAir ? (
|
||||||
|
|
@ -127,7 +127,7 @@ export function PresentSessionMenu({
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{isCurrent ? (
|
{isCurrent ? (
|
||||||
<span className="flex-shrink-0 text-xs font-semibold text-[#1e72ef]">Current</span>
|
<span className="flex-shrink-0 text-xs font-semibold text-[#1e72ef text-[var(--theme-button-color)]">Current</span>
|
||||||
) : null}
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -40,31 +40,31 @@ export function PresentStatsBars({
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{options.map((opt) => {
|
{options.map((opt) => {
|
||||||
const highlighted = revealCorrect && opt.isCorrect === true;
|
const highlighted = revealCorrect && opt.isCorrect === true;
|
||||||
const barColor = highlighted ? "bg-emerald-500" : "bg-[#1e72ef]";
|
const barColor = highlighted ? "bg-emerald-500" : "bg-[#1e72ef bg-[var(--theme-button-color)]";
|
||||||
const borderColor = highlighted ? "border-emerald-400 ring-2 ring-emerald-200" : "border-[#e5e5ea]";
|
const borderColor = highlighted ? "border-emerald-400 ring-2 ring-emerald-200" : "border-[#e5e5ea]";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={opt.optionId} className={`rounded-lg border bg-white p-4 shadow-sm ${borderColor}`}>
|
<div key={opt.optionId} className={`rounded-lg border bg-whit card-bg p-4 shadow-sm ${borderColor}`}>
|
||||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||||
<span className="min-w-0 flex-1 text-base font-medium leading-snug text-[#24282f]">
|
<span className="min-w-0 flex-1 text-base font-medium leading-snug text-[#24282f">
|
||||||
{opt.text || "—"}
|
{opt.text || "—"}
|
||||||
{highlighted ? (
|
{highlighted ? (
|
||||||
<span className="ml-2 inline-flex rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-emerald-800">
|
<span className="ml-2 inline-flex rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-bold uppercase tracking-wide text-emerald-800">
|
||||||
Correct answer
|
Correct answer
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
<span className="text-sm font-semibold tabular-nums text-[#1e72ef]">{opt.percent}%</span>
|
<span className="text-sm font-semibold tabular-nums text-[#1e72ef text-[var(--theme-button-color)]">{opt.percent}%</span>
|
||||||
<div className="flex items-center gap-1.5 rounded-full bg-[#f2f2f7] px-2.5 py-0.5">
|
<div className="flex items-center gap-1.5 rounded-full bg-[#f2f2f7 bg-zinc-100 px-2.5 py-0.5">
|
||||||
<span className="text-base font-bold tabular-nums leading-none text-[#24282f]">{opt.count}</span>
|
<span className="text-base font-bold tabular-nums leading-none text-[#24282f">{opt.count}</span>
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-wide text-[rgba(36,40,47,0.55)]">
|
<span className="text-[10px] font-semibold uppercase tracking-wide text-[rgba(36,40,47,0.55) text-[var(--theme-card-text-color)]/70">
|
||||||
{opt.count === 1 ? "response" : "responses"}
|
{opt.count === 1 ? "response" : "responses"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-3 overflow-hidden rounded-full bg-[#e5e5ea]">
|
<div className="h-3 overflow-hidden rounded-full bg-[#e5e5ea bg-zinc-400/30">
|
||||||
<div
|
<div
|
||||||
className={`h-full rounded-full transition-[width] duration-500 ease-out ${barColor}`}
|
className={`h-full rounded-full transition-[width] duration-500 ease-out ${barColor}`}
|
||||||
style={{ width: `${Math.min(100, Math.max(opt.count > 0 ? 2 : 0, opt.percent))}%` }}
|
style={{ width: `${Math.min(100, Math.max(opt.count > 0 ? 2 : 0, opt.percent))}%` }}
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ body {
|
||||||
|
|
||||||
/* Auth / modal shells (Figma EventBuzz_Event_Mobile_V1_Register) */
|
/* Auth / modal shells (Figma EventBuzz_Event_Mobile_V1_Register) */
|
||||||
.eb-gradient-auth {
|
.eb-gradient-auth {
|
||||||
background-color: var(--theme-bg-color, transparent);
|
/* background-color: var(--theme-bg-color, transparent);
|
||||||
background-image: var(
|
background-image: var(
|
||||||
--theme-bg-image,
|
--theme-bg-image,
|
||||||
linear-gradient(
|
linear-gradient(
|
||||||
|
|
@ -71,7 +71,7 @@ body {
|
||||||
);
|
);
|
||||||
background-size: var(--theme-bg-size, cover);
|
background-size: var(--theme-bg-size, cover);
|
||||||
background-repeat: var(--theme-bg-repeat, no-repeat);
|
background-repeat: var(--theme-bg-repeat, no-repeat);
|
||||||
background-position: var(--theme-bg-position, center center);
|
background-position: var(--theme-bg-position, center center); */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Rewards / Leaderboard “Total Points” card */
|
/* Rewards / Leaderboard “Total Points” card */
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,12 @@ import {
|
||||||
Download,
|
Download,
|
||||||
Loader2,
|
Loader2,
|
||||||
Share2,
|
Share2,
|
||||||
|
ArrowLeft,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { HeaderMobile } from "../components/HeaderMobile";
|
import { HeaderMobile } from "../components/HeaderMobile";
|
||||||
import { MobileShell } from "../components/MobileShell";
|
import { MobileShell } from "../components/MobileShell";
|
||||||
|
import { PhotoSourcePicker } from "../components/PhotoSourcePicker";
|
||||||
import { PhotoWallCropModal } from "../components/PhotoWallCropModal";
|
import { PhotoWallCropModal } from "../components/PhotoWallCropModal";
|
||||||
import { PublicEventChrome } from "../components/PublicEventChrome";
|
import { PublicEventChrome } from "../components/PublicEventChrome";
|
||||||
import { SharePlatformIcon } from "../components/SharePlatformIcon";
|
import { SharePlatformIcon } from "../components/SharePlatformIcon";
|
||||||
|
|
@ -102,7 +104,6 @@ const GENERATING_PIPELINE_MESSAGES = [
|
||||||
|
|
||||||
export function AiAvatarPage() {
|
export function AiAvatarPage() {
|
||||||
const { uploadToken } = useParams<{ uploadToken: string }>();
|
const { uploadToken } = useParams<{ uploadToken: string }>();
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
|
||||||
const resultImgRef = useRef<HTMLImageElement>(null);
|
const resultImgRef = useRef<HTMLImageElement>(null);
|
||||||
const [meta, setMeta] = useState<AiAvatarMeta | null>(null);
|
const [meta, setMeta] = useState<AiAvatarMeta | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
@ -191,7 +192,6 @@ export function AiAvatarPage() {
|
||||||
if (prev) URL.revokeObjectURL(prev);
|
if (prev) URL.revokeObjectURL(prev);
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
if (fileRef.current) fileRef.current.value = "";
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const applySelectedFile = useCallback((next: File) => {
|
const applySelectedFile = useCallback((next: File) => {
|
||||||
|
|
@ -212,10 +212,7 @@ export function AiAvatarPage() {
|
||||||
}, [selectedAvatar]);
|
}, [selectedAvatar]);
|
||||||
|
|
||||||
const handleFileChange = async (selected: File | null) => {
|
const handleFileChange = async (selected: File | null) => {
|
||||||
if (!selected) {
|
if (!selected) return;
|
||||||
clearSelectedPhoto();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!selected.type.startsWith("image/")) {
|
if (!selected.type.startsWith("image/")) {
|
||||||
setErr("Please choose an image file");
|
setErr("Please choose an image file");
|
||||||
return;
|
return;
|
||||||
|
|
@ -238,7 +235,6 @@ export function AiAvatarPage() {
|
||||||
setErr(e instanceof Error ? e.message : "Could not process image");
|
setErr(e instanceof Error ? e.message : "Could not process image");
|
||||||
} finally {
|
} finally {
|
||||||
setPicking(false);
|
setPicking(false);
|
||||||
if (fileRef.current) fileRef.current.value = "";
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -477,10 +473,7 @@ export function AiAvatarPage() {
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{showShare ? (
|
{showShare ? (
|
||||||
<div className="mt-4">
|
<div className="mt-2">
|
||||||
<p className="mb-2 text-center text-xs font-semibold uppercase tracking-wide text-[var(--theme-card-text-color)]/60">
|
|
||||||
Share
|
|
||||||
</p>
|
|
||||||
{useNativeShare ? (
|
{useNativeShare ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -574,36 +567,29 @@ export function AiAvatarPage() {
|
||||||
clearSelectedPhoto();
|
clearSelectedPhoto();
|
||||||
setErr(null);
|
setErr(null);
|
||||||
}}
|
}}
|
||||||
className="text-sm font-semibold text-[var(--theme-card-text-color)]"
|
className="text-xs font-semibold inline-flex items-center gap-1 text-[var(--theme-card-text-color)]/80"
|
||||||
>
|
>
|
||||||
← Change avatar
|
<span className="flex items-center justify-center w-7 h-7 bg-[var(--theme-card-text-color)]/10 rounded-full">
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
</span>
|
||||||
|
Change avatar
|
||||||
</button>
|
</button>
|
||||||
<h2 className="mb-1 text-center text-[22px] font-semibold text-[var(--theme-card-text-color)]">Upload selfie</h2>
|
<h2 className="mb-1 text-center text-[22px] font-semibold text-[var(--theme-card-text-color)]">Upload selfie</h2>
|
||||||
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">
|
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">
|
||||||
Avatar: <span className="font-semibold">{selectedAvatar.name}</span>
|
Avatar: <span className="font-semibold">{selectedAvatar.name}</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<input
|
|
||||||
ref={fileRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
capture="user"
|
|
||||||
className="hidden"
|
|
||||||
onChange={(e) => void handleFileChange(e.target.files?.[0] || null)}
|
|
||||||
/>
|
|
||||||
{previewUrl ? (
|
{previewUrl ? (
|
||||||
<div className="overflow-hidden rounded-lg">
|
<div className="overflow-hidden rounded-lg">
|
||||||
<img src={previewUrl} alt="Your selfie" className="mx-auto max-h-[240px] w-full object-contain" />
|
<img src={previewUrl} alt="Your selfie" className="mx-auto max-h-[240px] w-full object-contain" />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
<PhotoSourcePicker
|
||||||
type="button"
|
|
||||||
disabled={picking}
|
disabled={picking}
|
||||||
onClick={() => fileRef.current?.click()}
|
picking={picking}
|
||||||
className="inline-flex h-11 w-full items-center justify-center rounded-lg border border-zinc-300 bg-zinc-100 px-4 text-sm font-semibold text-gray-600 disabled:opacity-55"
|
hasFile={Boolean(file)}
|
||||||
>
|
onPick={(selected) => void handleFileChange(selected)}
|
||||||
{picking ? "Processing…" : file ? "Change selfie" : "Choose selfie"}
|
/>
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!file || submitting}
|
disabled={!file || submitting}
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ export function LeaderboardPage() {
|
||||||
return (
|
return (
|
||||||
<MobileShell className="relative">
|
<MobileShell className="relative">
|
||||||
<HeaderMobile logoUrl={ctx?.event?.logoUrl} eventId={eventId || null} />
|
<HeaderMobile logoUrl={ctx?.event?.logoUrl} eventId={eventId || null} />
|
||||||
<div className="eb-gradient-auth flex flex-1 flex-col items-center px-6 pb-10 pt-2">
|
<div className="eb-gradient-auth flex flex-1 flex-col items-center px-4 pb-10 pt-2">
|
||||||
<div className="mt-6 w-full max-w-[354px] rounded-xl card-bg shadow-md">
|
<div className="mt-6 w-full max-w-[354px] rounded-xl card-bg shadow-md">
|
||||||
|
|
||||||
<div className="flex gap-2 justify-between w-full p-4">
|
<div className="flex gap-2 justify-between w-full p-4">
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { HeaderMobile } from "../components/HeaderMobile";
|
import { HeaderMobile } from "../components/HeaderMobile";
|
||||||
import { MobileShell } from "../components/MobileShell";
|
import { MobileShell } from "../components/MobileShell";
|
||||||
|
import { PhotoSourcePicker } from "../components/PhotoSourcePicker";
|
||||||
import { PublicEventChrome } from "../components/PublicEventChrome";
|
import { PublicEventChrome } from "../components/PublicEventChrome";
|
||||||
import { SharePlatformIcon } from "../components/SharePlatformIcon";
|
import { SharePlatformIcon } from "../components/SharePlatformIcon";
|
||||||
import {
|
import {
|
||||||
|
|
@ -218,7 +219,6 @@ function MatchCard({
|
||||||
|
|
||||||
export function PhotoSearchPage() {
|
export function PhotoSearchPage() {
|
||||||
const { searchToken } = useParams<{ searchToken: string }>();
|
const { searchToken } = useParams<{ searchToken: string }>();
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
|
||||||
const [meta, setMeta] = useState<PhotoSearchMeta | null>(null);
|
const [meta, setMeta] = useState<PhotoSearchMeta | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
@ -271,14 +271,10 @@ export function PhotoSearchPage() {
|
||||||
if (prev) URL.revokeObjectURL(prev);
|
if (prev) URL.revokeObjectURL(prev);
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
if (fileRef.current) fileRef.current.value = "";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPick = (selected: File | null) => {
|
const onPick = (selected: File | null) => {
|
||||||
if (!selected) {
|
if (!selected) return;
|
||||||
clearFile();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!selected.type.startsWith("image/")) {
|
if (!selected.type.startsWith("image/")) {
|
||||||
setErr("Please choose an image file");
|
setErr("Please choose an image file");
|
||||||
return;
|
return;
|
||||||
|
|
@ -389,26 +385,15 @@ export function PhotoSearchPage() {
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<input
|
|
||||||
ref={fileRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
capture="user"
|
|
||||||
className="hidden"
|
|
||||||
onChange={(e) => onPick(e.target.files?.[0] || null)}
|
|
||||||
/>
|
|
||||||
{previewUrl && !searched ? (
|
{previewUrl && !searched ? (
|
||||||
<div className="mb-3 overflow-hidden rounded-lg">
|
<div className="mb-3 overflow-hidden rounded-lg">
|
||||||
<img src={previewUrl} alt="Your selfie" className="mx-auto max-h-[240px] w-full object-contain" />
|
<img src={previewUrl} alt="Your selfie" className="mx-auto max-h-[240px] w-full object-contain" />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
<PhotoSourcePicker
|
||||||
type="button"
|
hasFile={Boolean(file)}
|
||||||
onClick={() => fileRef.current?.click()}
|
onPick={(selected) => onPick(selected)}
|
||||||
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg border border-zinc-300 bg-zinc-100 px-4 text-sm font-semibold text-gray-600"
|
/>
|
||||||
>
|
|
||||||
{file ? "Change your photo" : "Upload your photo"}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{file && !searched ? (
|
{file && !searched ? (
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { getPhotoWallPresent, type PhotoWallPresentData } from "../lib/api";
|
import { getPhotoWallPresent, type PhotoWallPresentData } from "../lib/api";
|
||||||
import { PhotoWallEventHeader } from "../components/PhotoWallEventHeader";
|
import { PhotoWallPresentChrome } from "../components/PhotoWallPresentChrome";
|
||||||
|
|
||||||
const POLL_MS = 4000;
|
const POLL_MS = 4000;
|
||||||
|
|
||||||
|
|
@ -60,7 +60,7 @@ export function PhotoWallPresentPage() {
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return (
|
return (
|
||||||
<div className="eb-gradient-auth flex min-h-dvh flex-col items-center justify-center gap-2 px-6 text-center text-white">
|
<div className="eb-gradient-aut card-bg p-4 flex min-h-dvh flex-col items-center justify-center gap-2 px-6 text-center text-white">
|
||||||
<p className="text-lg font-semibold">Photo wall unavailable</p>
|
<p className="text-lg font-semibold">Photo wall unavailable</p>
|
||||||
<p className="text-sm text-white/80">{err}</p>
|
<p className="text-sm text-white/80">{err}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -89,22 +89,11 @@ export function PhotoWallPresentPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="eb-gradient-auth min-h-dvh text-white">
|
<div className="eb-gradient-auth min-h-dvh text-white">
|
||||||
{data.event ? (
|
<PhotoWallPresentChrome
|
||||||
<div className="px-4 pb-3 pt-4">
|
|
||||||
<div className="card-bg mx-auto mb-3 max-w-[400px] rounded-lg p-4 shadow-sm [&:not(:has(header))]:hidden">
|
|
||||||
<PhotoWallEventHeader
|
|
||||||
event={data.event}
|
event={data.event}
|
||||||
display={data.eventHeader || null}
|
eventHeader={data.eventHeader}
|
||||||
eyebrow={data.eventHeader?.showTitle === false ? null : "Photo Wall"}
|
eventNameFallback={data.eventName}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="card-bg mx-auto mb-3 max-w-[400px] rounded-lg px-4 pb-3 pt-4 text-center shadow-sm">
|
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--theme-card-text-color)]/70">Photo Wall</p>
|
|
||||||
<h1 className="text-xl font-semibold text-[var(--theme-card-text-color)] sm:text-2xl">{data.eventName}</h1>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{data.photos.length === 0 ? (
|
{data.photos.length === 0 ? (
|
||||||
<div className="flex min-h-[60dvh] items-center justify-center px-6 text-center text-white/80">
|
<div className="flex min-h-[60dvh] items-center justify-center px-6 text-center text-white/80">
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { HeaderMobile } from "../components/HeaderMobile";
|
import { HeaderMobile } from "../components/HeaderMobile";
|
||||||
import { MobileShell } from "../components/MobileShell";
|
import { MobileShell } from "../components/MobileShell";
|
||||||
|
import { PhotoSourcePicker } from "../components/PhotoSourcePicker";
|
||||||
import { PhotoWallCropModal } from "../components/PhotoWallCropModal";
|
import { PhotoWallCropModal } from "../components/PhotoWallCropModal";
|
||||||
import { PublicEventChrome } from "../components/PublicEventChrome";
|
import { PublicEventChrome } from "../components/PublicEventChrome";
|
||||||
import { SharePlatformIcon } from "../components/SharePlatformIcon";
|
import { SharePlatformIcon } from "../components/SharePlatformIcon";
|
||||||
|
|
@ -97,7 +98,6 @@ const SHARE_LABELS: Array<{ key: keyof PhotoWallSharePlatforms; label: string }>
|
||||||
|
|
||||||
export function PhotoWallUploadPage() {
|
export function PhotoWallUploadPage() {
|
||||||
const { uploadToken } = useParams<{ uploadToken: string }>();
|
const { uploadToken } = useParams<{ uploadToken: string }>();
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
|
||||||
const resultImgRef = useRef<HTMLImageElement>(null);
|
const resultImgRef = useRef<HTMLImageElement>(null);
|
||||||
const [meta, setMeta] = useState<PhotoWallUploadMeta | null>(null);
|
const [meta, setMeta] = useState<PhotoWallUploadMeta | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
@ -164,7 +164,6 @@ export function PhotoWallUploadPage() {
|
||||||
if (prev) URL.revokeObjectURL(prev);
|
if (prev) URL.revokeObjectURL(prev);
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
if (fileRef.current) fileRef.current.value = "";
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -209,10 +208,7 @@ export function PhotoWallUploadPage() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleFileChange = async (selected: File | null) => {
|
const handleFileChange = async (selected: File | null) => {
|
||||||
if (!selected) {
|
if (!selected) return;
|
||||||
clearSelectedPhoto();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!selected.type.startsWith("image/")) {
|
if (!selected.type.startsWith("image/")) {
|
||||||
setErr("Please choose an image file");
|
setErr("Please choose an image file");
|
||||||
return;
|
return;
|
||||||
|
|
@ -238,7 +234,6 @@ export function PhotoWallUploadPage() {
|
||||||
setErr(e instanceof Error ? e.message : "Could not process image");
|
setErr(e instanceof Error ? e.message : "Could not process image");
|
||||||
} finally {
|
} finally {
|
||||||
setPicking(false);
|
setPicking(false);
|
||||||
if (fileRef.current) fileRef.current.value = "";
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -560,29 +555,17 @@ export function PhotoWallUploadPage() {
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={(e) => void onSubmit(e)} className="space-y-4">
|
<form onSubmit={(e) => void onSubmit(e)} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<input
|
|
||||||
ref={fileRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
capture="user"
|
|
||||||
className="hidden"
|
|
||||||
onChange={(e) => void handleFileChange(e.target.files?.[0] || null)}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={picking}
|
|
||||||
onClick={() => fileRef.current?.click()}
|
|
||||||
className="flex w-full flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-[var(--theme-card-text-color)]/30 bg-[var(--theme-card-color)]/80 px-4 py-4 text-sm text-[var(--theme-card-text-color)] hover:border-[var(--theme-button-color)]/70"
|
|
||||||
>
|
|
||||||
{previewUrl ? (
|
{previewUrl ? (
|
||||||
<img src={previewUrl} alt="Preview" className="max-h-48 rounded-md object-contain" />
|
<div className="mb-3 overflow-hidden rounded-lg">
|
||||||
) : (
|
<img src={previewUrl} alt="Preview" className="mx-auto max-h-48 rounded-md object-contain" />
|
||||||
<>
|
</div>
|
||||||
<ImagePlus className="size-8 text-[var(--theme-button-color)]" />
|
) : null}
|
||||||
<span>{picking ? "Preparing…" : "Tap to take or choose a photo"}</span>
|
<PhotoSourcePicker
|
||||||
</>
|
disabled={picking}
|
||||||
)}
|
picking={picking}
|
||||||
</button>
|
hasFile={Boolean(file)}
|
||||||
|
onPick={(selected) => void handleFileChange(selected)}
|
||||||
|
/>
|
||||||
{file ? (
|
{file ? (
|
||||||
<p className="mt-2 truncate text-center text-xs text-[var(--theme-card-text-color)]/80">{file.name}</p>
|
<p className="mt-2 truncate text-center text-xs text-[var(--theme-card-text-color)]/80">{file.name}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -444,9 +444,9 @@ export function QuizPollPresentPage() {
|
||||||
const t = token?.trim() || "";
|
const t = token?.trim() || "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-dvh flex-col bg-[#f2f2f7] pb-20 text-[#24282f]">
|
<div className="flex min-h-dvh flex-col bg-[#f2f2f7 pb-20 text-[#24282f text-[var(--theme-card-text-color)]">
|
||||||
<main className="mx-auto flex w-full max-w-4xl flex-1 flex-col gap-3 px-4 py-3 sm:px-6">
|
<main className="mx-auto flex w-full max-w-4xl flex-1 flex-col gap-3 px-4 py-3 sm:px-6">
|
||||||
<div className="rounded-xl border border-[#e5e5ea] bg-white px-5 py-3 shadow-sm">
|
<div className="rounded-xl border-0 border-[#e5e5ea bg-whit card-bg px-5 py-3 shadow-md relative z-10">
|
||||||
<div className="mb-2 flex flex-wrap items-start justify-between gap-3">
|
<div className="mb-2 flex flex-wrap items-start justify-between gap-3">
|
||||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
<PresentSessionMenu
|
<PresentSessionMenu
|
||||||
|
|
@ -470,7 +470,7 @@ export function QuizPollPresentPage() {
|
||||||
Live · Question {liveQuestionIndex + 1}
|
Live · Question {liveQuestionIndex + 1}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="rounded-full bg-[#f2f2f7] px-3 py-1 text-xs font-bold uppercase tracking-wide text-[rgba(36,40,47,0.55)]">
|
<span className="rounded-full bg-[#f2f2f7 px-3 py-1 text-xs font-bold uppercase tracking-wide text-[rgba(36,40,47,0.55) bg-zinc-100 text-[var(--theme-card-text-color)]/70">
|
||||||
Offline · No question live
|
Offline · No question live
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
|
@ -479,7 +479,7 @@ export function QuizPollPresentPage() {
|
||||||
Live
|
Live
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="rounded-full bg-[#f2f2f7] px-3 py-1 text-xs font-bold uppercase tracking-wide text-[rgba(36,40,47,0.55)]">
|
<span className="rounded-full bg-[#f2f2f7 px-3 py-1 text-xs font-bold uppercase tracking-wide text-[rgba(36,40,47,0.55) bg-zinc-100 text-[var(--theme-card-text-color)]/70">
|
||||||
Offline
|
Offline
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -534,21 +534,21 @@ export function QuizPollPresentPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm font-medium uppercase tracking-wide text-[#1e72ef]">
|
<p className="text-xs font-semibold uppercase tracking-wide text-[#1e72ef text-[var(--theme-button-color)]">
|
||||||
Question {questionSafe + 1} of {questionCount}
|
Question {questionSafe + 1} of {questionCount}
|
||||||
</p>
|
</p>
|
||||||
<h1 className="mt-1 text-base font-semibold leading-snug text-[#24282f] sm:text-lg">{currentQuestion.question}</h1>
|
<h1 className="mt-1 text-base font-semibold leading-snug text-[#24282f sm:text-lg">{currentQuestion.question}</h1>
|
||||||
{statsLoading && !stats ? (
|
{statsLoading && !stats ? (
|
||||||
<p className="mt-2 text-xs text-[rgba(36,40,47,0.45)]">Updating…</p>
|
<p className="mt-2 text-xs text-[rgba(36,40,47,0.45) text-[var(--theme-card-text-color)]/60">Updating…</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 rounded-lg border border-[#e5e5ea] bg-white p-1 shadow-sm">
|
<div className="flex gap-2 rounded-lg border-0 border-[#e5e5ea bg-white card-bg p-1 shadow-md">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTab("responses")}
|
onClick={() => setTab("responses")}
|
||||||
className={`flex-1 rounded-md px-3 py-2 text-sm font-semibold transition ${
|
className={`flex-1 rounded-md px-3 py-2 text-sm font-semibold transition ${
|
||||||
tab === "responses" ? "bg-[#1e72ef] text-white" : "text-[rgba(36,40,47,0.65)] hover:text-[#24282f]"
|
tab === "responses" ? "bg-[#1e72ef theme-btn-bg text-white" : "text-[rgba(36,40,47,0.65) hover:text-[#24282f text-[var(--theme-card-text-color)]/80 hover:text-[var(--theme-card-text-color)]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Responses{stats ? ` (${stats.totalResponses})` : ""}
|
Responses{stats ? ` (${stats.totalResponses})` : ""}
|
||||||
|
|
@ -557,7 +557,7 @@ export function QuizPollPresentPage() {
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTab("participants")}
|
onClick={() => setTab("participants")}
|
||||||
className={`flex-1 rounded-md px-3 py-2 text-sm font-semibold transition ${
|
className={`flex-1 rounded-md px-3 py-2 text-sm font-semibold transition ${
|
||||||
tab === "participants" ? "bg-[#1e72ef] text-white" : "text-[rgba(36,40,47,0.65)] hover:text-[#24282f]"
|
tab === "participants" ? "bg-[#1e72ef theme-btn-bg text-white" : "text-[rgba(36,40,47,0.65) hover:text-[#24282f text-[var(--theme-card-text-color)]/80 hover:text-[var(--theme-card-text-color)]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Participants
|
Participants
|
||||||
|
|
@ -567,7 +567,7 @@ export function QuizPollPresentPage() {
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTab("leaderboard")}
|
onClick={() => setTab("leaderboard")}
|
||||||
className={`flex-1 rounded-md px-3 py-2 text-sm font-semibold transition ${
|
className={`flex-1 rounded-md px-3 py-2 text-sm font-semibold transition ${
|
||||||
tab === "leaderboard" ? "bg-[#1e72ef] text-white" : "text-[rgba(36,40,47,0.65)] hover:text-[#24282f]"
|
tab === "leaderboard" ? "bg-[#1e72ef theme-btn-bg text-white" : "text-[rgba(36,40,47,0.65) hover:text-[#24282f text-[var(--theme-card-text-color)]/80 hover:text-[var(--theme-card-text-color)]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Leaderboard{leaderboard.length > 0 ? ` (${leaderboard.length})` : ""}
|
Leaderboard{leaderboard.length > 0 ? ` (${leaderboard.length})` : ""}
|
||||||
|
|
|
||||||
|
|
@ -722,7 +722,7 @@ export function QuizPollTakePage() {
|
||||||
<div className="flex shrink-0 flex-col">
|
<div className="flex shrink-0 flex-col">
|
||||||
<HeaderMobile logoUrl={headerLogo} eventId={eventId} />
|
<HeaderMobile logoUrl={headerLogo} eventId={eventId} />
|
||||||
</div>
|
</div>
|
||||||
<main className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pb-8 pt-6">
|
<main className="min-h-0 flex-1 overflow-y-auto px-4 pb-8 pt-6">
|
||||||
<div className="mx-auto w-full max-w-[370px]">
|
<div className="mx-auto w-full max-w-[370px]">
|
||||||
<div className="rounded-lg card-bg p-5 text-center shadow-md">
|
<div className="rounded-lg card-bg p-5 text-center shadow-md">
|
||||||
<p className="font-inter text-lg font-semibold text-[var(--theme-card-text-color)]]">Thank you</p>
|
<p className="font-inter text-lg font-semibold text-[var(--theme-card-text-color)]]">Thank you</p>
|
||||||
|
|
@ -929,7 +929,7 @@ export function QuizPollTakePage() {
|
||||||
</div>
|
</div>
|
||||||
<main
|
<main
|
||||||
ref={mainScrollRef}
|
ref={mainScrollRef}
|
||||||
className="relative z-0 min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pt-6"
|
className="relative z-0 min-h-0 flex-1 overflow-y-auto px-4 pt-6"
|
||||||
>
|
>
|
||||||
<div className="mx-auto w-full max-w-[370px] pb-4">
|
<div className="mx-auto w-full max-w-[370px] pb-4">
|
||||||
<div className="flex flex-col gap-1 card-bg p-4 rounded-lg shadow-sm">
|
<div className="flex flex-col gap-1 card-bg p-4 rounded-lg shadow-sm">
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ export function RewardsPage() {
|
||||||
return (
|
return (
|
||||||
<MobileShell className="relative">
|
<MobileShell className="relative">
|
||||||
<HeaderMobile logoUrl={ctx?.event?.logoUrl} eventId={eventId || null} />
|
<HeaderMobile logoUrl={ctx?.event?.logoUrl} eventId={eventId || null} />
|
||||||
<div className="eb-gradient-auth flex flex-1 flex-col items-center px-6 pb-10 pt-2">
|
<div className="eb-gradient-auth flex flex-1 flex-col items-center px-4 pb-10 pt-2">
|
||||||
{/* <div className="flex flex-col w-full max-w-[354px] items-center card-bg p-4 rounded-md">
|
{/* <div className="flex flex-col w-full max-w-[354px] items-center card-bg p-4 rounded-md">
|
||||||
<Trophy className="size-[68px] text-amber-300" strokeWidth={1.25} />
|
<Trophy className="size-[68px] text-amber-300" strokeWidth={1.25} />
|
||||||
<h1 className="mt-1 font-inter text-xl font-semibold text-white">Your Rewards</h1>
|
<h1 className="mt-1 font-inter text-xl font-semibold text-white">Your Rewards</h1>
|
||||||
|
|
@ -98,12 +98,6 @@ export function RewardsPage() {
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-center px-4">
|
<div className="flex justify-center px-4">
|
||||||
|
|
||||||
{/* <Link
|
|
||||||
to={eventId ? `/leaderboard?event_id=${encodeURIComponent(eventId)}` : "/leaderboard"}
|
|
||||||
className="w-full rounded-ful rounded-lg border border-[rgba(214,200,200,0.6)] bg-[var(--theme-button-color)] transition-colors duration-300 ease-in-out hover:bg-[var(--theme-button-color)]/90 py-3 text-center text-xs font-semibold uppercase tracking-wide text-white shadow-[0_4px_4px_rgba(0,0,0,0.15)]"
|
|
||||||
>
|
|
||||||
Leaderboard
|
|
||||||
</Link> */}
|
|
||||||
<Link
|
<Link
|
||||||
to={eventId ? `/leaderboard?event_id=${encodeURIComponent(eventId)}` : "/leaderboard"}
|
to={eventId ? `/leaderboard?event_id=${encodeURIComponent(eventId)}` : "/leaderboard"}
|
||||||
className="w-full rounded-ful rounded-lg border border-[var(--theme-button-color)]/25 bg-[var(--theme-button-color)]/20 text-[var(--theme-button-color)] py-3 text-center text-sm font-semibold uppercase tracking-wide shadow-sm"
|
className="w-full rounded-ful rounded-lg border border-[var(--theme-button-color)]/25 bg-[var(--theme-button-color)]/20 text-[var(--theme-button-color)] py-3 text-center text-sm font-semibold uppercase tracking-wide shadow-sm"
|
||||||
|
|
@ -133,7 +127,7 @@ export function RewardsPage() {
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={row._id}
|
key={row._id}
|
||||||
className="flex items-center gap-4 border-b border-[var(--theme-card-text-color)]/5 pb-3 last:border-0"
|
className="flex items-center gap-2 border-b border-[var(--theme-card-text-color)]/5 pb-3 last:border-0"
|
||||||
>
|
>
|
||||||
<span className={`shrink-0 px-1 py-1 text-sm font-semibold ${cls}`}>
|
<span className={`shrink-0 px-1 py-1 text-sm font-semibold ${cls}`}>
|
||||||
{signed}
|
{signed}
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,12 @@ import {
|
||||||
import {
|
import {
|
||||||
Download,
|
Download,
|
||||||
Share2,
|
Share2,
|
||||||
|
ArrowLeft,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { HeaderMobile } from "../components/HeaderMobile";
|
import { HeaderMobile } from "../components/HeaderMobile";
|
||||||
import { MobileShell } from "../components/MobileShell";
|
import { MobileShell } from "../components/MobileShell";
|
||||||
|
import { PhotoSourcePicker } from "../components/PhotoSourcePicker";
|
||||||
import { PhotoWallCropModal } from "../components/PhotoWallCropModal";
|
import { PhotoWallCropModal } from "../components/PhotoWallCropModal";
|
||||||
import { PublicEventChrome } from "../components/PublicEventChrome";
|
import { PublicEventChrome } from "../components/PublicEventChrome";
|
||||||
import { SharePlatformIcon } from "../components/SharePlatformIcon";
|
import { SharePlatformIcon } from "../components/SharePlatformIcon";
|
||||||
|
|
@ -87,7 +89,6 @@ const SHARE_LABELS: Array<{ key: keyof PhotoWallSharePlatforms; label: string }>
|
||||||
|
|
||||||
export function SocialSharePage() {
|
export function SocialSharePage() {
|
||||||
const { uploadToken } = useParams<{ uploadToken: string }>();
|
const { uploadToken } = useParams<{ uploadToken: string }>();
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
|
||||||
const resultImgRef = useRef<HTMLImageElement>(null);
|
const resultImgRef = useRef<HTMLImageElement>(null);
|
||||||
const [meta, setMeta] = useState<SocialShareMeta | null>(null);
|
const [meta, setMeta] = useState<SocialShareMeta | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
@ -174,7 +175,6 @@ export function SocialSharePage() {
|
||||||
if (prev) URL.revokeObjectURL(prev);
|
if (prev) URL.revokeObjectURL(prev);
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
if (fileRef.current) fileRef.current.value = "";
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const applySelectedFile = useCallback((next: File) => {
|
const applySelectedFile = useCallback((next: File) => {
|
||||||
|
|
@ -201,10 +201,7 @@ export function SocialSharePage() {
|
||||||
}, [selectedFrame, meta?.requireUploadCrop, meta?.maxPhotoWidth, meta?.maxPhotoHeight]);
|
}, [selectedFrame, meta?.requireUploadCrop, meta?.maxPhotoWidth, meta?.maxPhotoHeight]);
|
||||||
|
|
||||||
const handleFileChange = async (selected: File | null) => {
|
const handleFileChange = async (selected: File | null) => {
|
||||||
if (!selected) {
|
if (!selected) return;
|
||||||
clearSelectedPhoto();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!selected.type.startsWith("image/")) {
|
if (!selected.type.startsWith("image/")) {
|
||||||
setErr("Please choose an image file");
|
setErr("Please choose an image file");
|
||||||
return;
|
return;
|
||||||
|
|
@ -229,7 +226,6 @@ export function SocialSharePage() {
|
||||||
setErr(e instanceof Error ? e.message : "Could not process image");
|
setErr(e instanceof Error ? e.message : "Could not process image");
|
||||||
} finally {
|
} finally {
|
||||||
setPicking(false);
|
setPicking(false);
|
||||||
if (fileRef.current) fileRef.current.value = "";
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -459,10 +455,7 @@ export function SocialSharePage() {
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{showShare ? (
|
{showShare ? (
|
||||||
<div className="mt-4">
|
<div className="mt-2">
|
||||||
<p className="mb-2 text-center text-xs font-semibold uppercase tracking-wide text-[var(--theme-card-text-color)]/60">
|
|
||||||
Share
|
|
||||||
</p>
|
|
||||||
{useNativeShare ? (
|
{useNativeShare ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -556,36 +549,28 @@ export function SocialSharePage() {
|
||||||
clearSelectedPhoto();
|
clearSelectedPhoto();
|
||||||
setErr(null);
|
setErr(null);
|
||||||
}}
|
}}
|
||||||
className="text-sm font-semibold theme-btn-lin text-[var(--theme-card-text-color)]"
|
className="text-xs font-semibold inline-flex items-center gap-1 text-[var(--theme-card-text-color)]/80"
|
||||||
>
|
>
|
||||||
← Change frame
|
<span className="flex items-center justify-center w-7 h-7 bg-[var(--theme-card-text-color)]/10 rounded-full">
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
</span>Change frame
|
||||||
</button>
|
</button>
|
||||||
<h2 className="mb-2 text-center text-[22px] font-semibold text-[var(--theme-card-text-color)]">Upload photo</h2>
|
<h2 className="mb-2 text-center text-[22px] font-semibold text-[var(--theme-card-text-color)]">Upload photo</h2>
|
||||||
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">
|
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">
|
||||||
Selected frame: <span className="font-semibold">{selectedFrame.name}</span>
|
Selected frame: <span className="font-semibold">{selectedFrame.name}</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<input
|
|
||||||
ref={fileRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
capture="user"
|
|
||||||
className="hidden"
|
|
||||||
onChange={(e) => void handleFileChange(e.target.files?.[0] || null)}
|
|
||||||
/>
|
|
||||||
{previewUrl ? (
|
{previewUrl ? (
|
||||||
<div className="overflow-hidden rounded-lg">
|
<div className="overflow-hidden rounded-lg">
|
||||||
<img src={previewUrl} alt="Your photo" className="mx-auto max-h-[240px] w-full object-contain" />
|
<img src={previewUrl} alt="Your photo" className="mx-auto max-h-[240px] w-full object-contain" />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
<PhotoSourcePicker
|
||||||
type="button"
|
|
||||||
disabled={picking}
|
disabled={picking}
|
||||||
onClick={() => fileRef.current?.click()}
|
picking={picking}
|
||||||
className="inline-flex h-11 w-full items-center justify-center rounded-lg border border-zinc-300 bg-zinc-100 px-4 text-sm font-semibold text-gray-600 disabled:opacity-55"
|
hasFile={Boolean(file)}
|
||||||
>
|
onPick={(selected) => void handleFileChange(selected)}
|
||||||
{picking ? "Processing…" : file ? "Change photo" : "Choose photo"}
|
/>
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!file || submitting}
|
disabled={!file || submitting}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue