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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";

import { ALLOWED_LOGO_TYPES, MAX_LOGO_SOURCE_BYTES } from "../constants";
import type { Sponsor, SponsorPayload } from "../types";

const TIER_OPTIONS = [
Expand All @@ -31,8 +32,6 @@ const TIER_OPTIONS = [
"Bronze",
"Standard",
];
const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"];
const MAX_SIZE_BYTES = 1 * 1024 * 1024; // 1MB

interface SponsorFormDialogProps {
open: boolean;
Expand Down Expand Up @@ -64,19 +63,17 @@ function SponsorForm({
? `data:${sponsor.logo_content_type};base64,${sponsor.logo_data}`
: "",
);
const [isDraggingLogo, setIsDraggingLogo] = useState(false);
const logoInputRef = useRef<HTMLInputElement>(null);

const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;

if (!ALLOWED_TYPES.includes(file.type)) {
const acceptLogoFile = (file: File) => {
if (!ALLOWED_LOGO_TYPES.includes(file.type)) {
toast.error("Unsupported file type. Use PNG, JPEG, WebP, or GIF.");
return;
}

if (file.size > MAX_SIZE_BYTES) {
toast.error("File too large. Maximum size is 1MB.");
if (file.size > MAX_LOGO_SOURCE_BYTES) {
toast.error("File too large. Maximum size is 10MB.");
return;
}

Expand All @@ -86,6 +83,31 @@ function SponsorForm({
reader.readAsDataURL(file);
};

const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) acceptLogoFile(file);
};

const handleLogoDragOver = (e: React.DragEvent<HTMLButtonElement>) => {
if (!e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
setIsDraggingLogo(true);
};

const handleLogoDragLeave = (e: React.DragEvent<HTMLButtonElement>) => {
// dragleave also fires when moving onto a child; only reset on real exit.
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
setIsDraggingLogo(false);
};

const handleLogoDrop = (e: React.DragEvent<HTMLButtonElement>) => {
e.preventDefault();
setIsDraggingLogo(false);
const file = e.dataTransfer.files[0];
if (file) acceptLogoFile(file);
};

const clearLogo = () => {
setLogoFile(null);
setLogoPreview(
Expand Down Expand Up @@ -116,52 +138,64 @@ function SponsorForm({
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label>Logo</Label>
<div className="flex items-center gap-3">
{logoPreview ? (
<img
src={logoPreview}
alt="Logo preview"
className="size-12 rounded object-contain border"
/>
) : (
<div className="size-12 rounded border border-dashed flex items-center justify-center text-muted-foreground">
<ImagePlus className="size-5" />
</div>
)}
<div className="flex items-center gap-2">
<input
ref={logoInputRef}
type="file"
accept={ALLOWED_TYPES.join(",")}
className="hidden"
onChange={handleLogoChange}
/>
<input
ref={logoInputRef}
type="file"
accept={ALLOWED_LOGO_TYPES.join(",")}
className="hidden"
onChange={handleLogoChange}
/>
<div className="relative">
<button
type="button"
onClick={() => logoInputRef.current?.click()}
onDragOver={handleLogoDragOver}
onDragLeave={handleLogoDragLeave}
onDrop={handleLogoDrop}
className={`flex h-32 w-full cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed px-4 text-center transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
isDraggingLogo
? "border-primary bg-primary/5"
: "border-muted-foreground/25 hover:border-muted-foreground/50 hover:bg-muted/50"
}`}
>
{logoPreview ? (
<>
<img
src={logoPreview}
alt="Logo preview"
className="h-16 max-w-full object-contain"
/>
<span className="text-xs text-muted-foreground">
Drop or click to replace
</span>
</>
) : (
<>
<ImagePlus className="size-4 text-muted-foreground" />
<span className="text-sm font-medium">
{isDraggingLogo
? "Drop to upload"
: "Drop logo here or click to browse"}
</span>
<span className="text-xs text-muted-foreground">
PNG, JPEG, WebP, or GIF · max 10MB
</span>
</>
)}
</button>
{logoFile && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => logoInputRef.current?.click()}
className="cursor-pointer"
variant="ghost"
size="icon-sm"
onClick={clearLogo}
aria-label="Remove selected logo"
className="absolute right-1.5 top-1.5 cursor-pointer text-muted-foreground hover:text-foreground"
>
<ImagePlus className="mr-1 size-3" />
{logoPreview ? "Replace" : "Choose file"}
<X className="size-4" />
</Button>
{logoFile && (
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={clearLogo}
className="cursor-pointer text-muted-foreground hover:text-foreground"
>
<X className="size-4" />
</Button>
)}
</div>
)}
</div>
<p className="text-xs text-muted-foreground">
PNG, JPEG, WebP, or GIF (max 1MB)
</p>
</div>
<div className="space-y-2">
<Label htmlFor="sponsor-name">Name</Label>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
} from "@/components/ui/tooltip";

import { fetchSponsors } from "../api";
import { ALLOWED_LOGO_TYPES, MAX_LOGO_SOURCE_BYTES } from "../constants";
import type { Sponsor, SponsorPayload } from "../types";
import { SponsorFormDialog } from "./SponsorFormDialog";

Expand All @@ -61,9 +62,6 @@ const tierColors: Record<string, string> = {
Standard: "bg-blue-100 text-blue-800",
};

const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"];
const MAX_SIZE_BYTES = 1 * 1024 * 1024; // 1MB

interface SponsorsTableProps {
sponsors: Sponsor[];
saving: boolean;
Expand Down Expand Up @@ -203,13 +201,13 @@ export function SponsorsTable({
const sponsorId = logoTargetIdRef.current;
if (!file || !sponsorId) return;

if (!ALLOWED_TYPES.includes(file.type)) {
if (!ALLOWED_LOGO_TYPES.includes(file.type)) {
toast.error("Unsupported file type. Use PNG, JPEG, WebP, or GIF.");
return;
}

if (file.size > MAX_SIZE_BYTES) {
toast.error("File too large. Maximum size is 1MB.");
if (file.size > MAX_LOGO_SOURCE_BYTES) {
toast.error("File too large. Maximum size is 10MB.");
return;
}

Expand Down Expand Up @@ -264,7 +262,7 @@ export function SponsorsTable({
<input
ref={logoInputRef}
type="file"
accept={ALLOWED_TYPES.join(",")}
accept={ALLOWED_LOGO_TYPES.join(",")}
className="hidden"
onChange={handleLogoFileChange}
/>
Expand Down
14 changes: 14 additions & 0 deletions client/portal/src/pages/admin/sponsors/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export const ALLOWED_LOGO_TYPES = [
"image/png",
"image/jpeg",
"image/webp",
"image/gif",
];

// Upper bound on the file an admin may pick. Logos are downscaled and
// re-encoded client-side before upload, so this only guards against
// accidentally selecting something enormous.
export const MAX_LOGO_SOURCE_BYTES = 10 * 1024 * 1024;

// Matches maxLogoBytes on the backend (decoded size after compression).
export const MAX_LOGO_BYTES = 1 * 1024 * 1024;
25 changes: 14 additions & 11 deletions client/portal/src/pages/admin/sponsors/store.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { toast } from "sonner";
import { create } from "zustand";

import { prepareLogoForUpload } from "@/shared/lib/logo-image";

import {
createSponsor as apiCreateSponsor,
deleteSponsor as apiDeleteSponsor,
fetchSponsors,
updateSponsor as apiUpdateSponsor,
uploadSponsorLogo,
} from "./api";
import { MAX_LOGO_BYTES } from "./constants";
import type { Sponsor, SponsorPayload } from "./types";

export interface SponsorsState {
Expand Down Expand Up @@ -90,18 +94,17 @@ export const useSponsorsStore = create<SponsorsState>((set) => ({
},

uploadLogo: async (sponsorId: string, file: File) => {
const base64 = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
const base64Data = result.split(",")[1];
resolve(base64Data);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
const logo = await prepareLogoForUpload(file);
if (logo.byteLength > MAX_LOGO_BYTES) {
toast.error("Logo is still too large after compression.");
return null;
}

const res = await uploadSponsorLogo(sponsorId, base64, file.type);
const res = await uploadSponsorLogo(
sponsorId,
logo.base64,
logo.contentType,
);
if (res.status === 200 && res.data) {
set((state) => ({
sponsors: state.sponsors.map((s) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";

import { ALLOWED_LOGO_TYPES, MAX_LOGO_BYTES } from "../constants";
import { ALLOWED_LOGO_TYPES, MAX_LOGO_SOURCE_BYTES } from "../constants";
import type { Track, TrackPayload, TrackPrize } from "../types";

interface TrackFormDialogProps {
Expand Down Expand Up @@ -62,8 +62,8 @@ function TrackForm({
return;
}

if (file.size > MAX_LOGO_BYTES) {
toast.error("File too large. Maximum size is 750KB.");
if (file.size > MAX_LOGO_SOURCE_BYTES) {
toast.error("File too large. Maximum size is 10MB.");
return;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import {
import { cn } from "@/shared/lib/utils";

import { fetchTracks } from "../api";
import { ALLOWED_LOGO_TYPES, MAX_LOGO_BYTES } from "../constants";
import { ALLOWED_LOGO_TYPES, MAX_LOGO_SOURCE_BYTES } from "../constants";
import type { Track, TrackPayload } from "../types";
import { TrackFormDialog } from "./TrackFormDialog";

Expand Down Expand Up @@ -171,8 +171,8 @@ export function TracksTable({
return;
}

if (file.size > MAX_LOGO_BYTES) {
toast.error("File too large. Maximum size is 750KB.");
if (file.size > MAX_LOGO_SOURCE_BYTES) {
toast.error("File too large. Maximum size is 10MB.");
return;
}

Expand Down
10 changes: 8 additions & 2 deletions client/portal/src/pages/admin/tracks/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ export const ALLOWED_LOGO_TYPES = [
"image/gif",
];

// Matches maxTrackLogoBytes on the backend. readJSON caps the request body at
// 1MB and base64 inflates by ~4/3, so a 1MB decoded limit is unreachable.
// Upper bound on the file an admin may pick. Logos are downscaled and
// re-encoded client-side before upload, so this only guards against
// accidentally selecting something enormous.
export const MAX_LOGO_SOURCE_BYTES = 10 * 1024 * 1024;

// Matches maxTrackLogoBytes on the backend (decoded size after compression).
// readJSON caps the request body at 1MB and base64 inflates by ~4/3, so a 1MB
// decoded limit is unreachable.
export const MAX_LOGO_BYTES = 750 * 1024;
21 changes: 10 additions & 11 deletions client/portal/src/pages/admin/tracks/store.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { toast } from "sonner";
import { create } from "zustand";

import { errorAlert } from "@/shared/lib/api";
import { prepareLogoForUpload } from "@/shared/lib/logo-image";

import {
createTrack as apiCreateTrack,
Expand All @@ -10,6 +12,7 @@ import {
updateTrack as apiUpdateTrack,
uploadTrackLogo,
} from "./api";
import { MAX_LOGO_BYTES } from "./constants";
import type { Track, TrackPayload } from "./types";

function sortByOrder(tracks: Track[]): Track[] {
Expand Down Expand Up @@ -108,17 +111,13 @@ export const useTracksStore = create<TracksState>((set) => ({
},

uploadLogo: async (trackId: string, file: File) => {
const base64 = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
resolve(result.split(",")[1]);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});

const res = await uploadTrackLogo(trackId, base64, file.type);
const logo = await prepareLogoForUpload(file);
if (logo.byteLength > MAX_LOGO_BYTES) {
toast.error("Logo is still too large after compression.");
return null;
}

const res = await uploadTrackLogo(trackId, logo.base64, logo.contentType);
if (res.status === 200 && res.data) {
const updated = res.data;
set((state) => ({
Expand Down
Loading
Loading