diff --git a/src/routes/dashboard/manage/events.tsx b/src/routes/dashboard/manage/events.tsx index 4594a70f..9c9ee0d4 100644 --- a/src/routes/dashboard/manage/events.tsx +++ b/src/routes/dashboard/manage/events.tsx @@ -95,6 +95,7 @@ export const Route = createFileRoute("/dashboard/manage/events")({ /// Types and Interfaces +type ThumbnailUpload = { url: string } | { hash: string; url: string }; type RowHandler = (event: MouseEvent) => void; type RosterTab = "qr" | "roster"; @@ -466,7 +467,7 @@ function ManageEventsPage() { ); const { mutate: saveEvent } = useMutation({ mutationFn: async ({ creating, event, thumbnailFile, removeThumbnail }: SaveVars) => { - const body = { + const details = { name: event.name, description: event.description, location: event.location, @@ -476,19 +477,30 @@ function ManageEventsPage() { }; await (creating ? axios.post(`${API_BASE_URL}/events/create`, { - ...body, + ...details, id: event.id, type: event.type, }) - : axios.put(`${API_BASE_URL}/events/${event.id}`, body)); + : axios.put(`${API_BASE_URL}/events/${event.id}`, details)); const eventId = event.id; if (thumbnailFile) { - const bytes = new Uint8Array(await thumbnailFile.arrayBuffer()); - await axios.post(`${API_BASE_URL}/events/${eventId}/thumbnail`, { - hash: await blake3(bytes), + const body = { + hash: await blake3(new Uint8Array(await thumbnailFile.arrayBuffer())), content_type: thumbnailFile.type, - }); + size: thumbnailFile.size, + }; + const { data: upload } = await axios.post( + `${API_BASE_URL}/events/${eventId}/thumbnail/upload`, + body, + ); + + if (!("hash" in upload)) + await axios.put(upload.url, thumbnailFile, { + headers: { "Content-Type": thumbnailFile.type }, + withCredentials: false, + }); + await axios.post(`${API_BASE_URL}/events/${eventId}/thumbnail/commit`, body); } else if (removeThumbnail) { await axios.delete(`${API_BASE_URL}/events/${eventId}/thumbnail`); } diff --git a/src/routes/dashboard/manage/projects.tsx b/src/routes/dashboard/manage/projects.tsx index 6f9a05a2..91e99022 100644 --- a/src/routes/dashboard/manage/projects.tsx +++ b/src/routes/dashboard/manage/projects.tsx @@ -9,6 +9,7 @@ import { import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { type ColumnDef, type RowData, type TableMeta } from "@tanstack/react-table"; import axios from "axios"; +import { blake3 } from "hash-wasm"; import { Activity, Archive, @@ -99,6 +100,7 @@ type ManageStatus = "all" | "active" | "archived"; type DetailTab = "details" | "team"; type InviteAction = "accept" | "decline" | "revoke"; type RowHandler = (event: MouseEvent) => void; +type ThumbnailUpload = { url: string } | { hash: string; url: string }; interface ProjectPageParams { active?: boolean; @@ -130,10 +132,13 @@ interface InviteVars { interface SaveVars { creating: boolean; project: FullProject; + removeThumbnail: boolean; + thumbnailFile?: File; } interface Editor { creating: boolean; founded_at: string; + hadThumbnail: boolean; id: string; } @@ -409,7 +414,9 @@ const projectFormSchema = z.object({ active: z.boolean(), join_policy: z.enum(JOIN_POLICIES as [JoinPolicy, ...JoinPolicy[]]), tags: z.array(z.string()), - thumbnail: z.object({ hash: z.string(), url: z.string() }).nullish(), + thumbnail: z + .object({ hash: z.string(), url: z.string(), file: z.instanceof(File).optional() }) + .nullish(), }); /// Tanstack Query options @@ -545,22 +552,42 @@ function ManageProjectsPage() { [queryClient], ); const { mutate: saveProject } = useMutation({ - mutationFn: async ({ creating, project }: SaveVars) => { - await (creating - ? axios.post(`${API_BASE_URL}/projects/create`, { - name: project.name, - description: project.description, - link: project.link, + mutationFn: async ({ creating, project, thumbnailFile, removeThumbnail }: SaveVars) => { + const details = { + name: project.name, + description: project.description, + link: project.link, + }; + const { data: saved } = await (creating + ? axios.post>(`${API_BASE_URL}/projects/create`, { + ...details, type: project.type, tags: project.tags, active: project.active, founded_at: project.founded_at, }) - : axios.put(`${API_BASE_URL}/projects/${project.id}`, { - name: project.name, - description: project.description, - link: project.link, - })); + : axios.put>(`${API_BASE_URL}/projects/${project.id}`, details)); + + if (!thumbnailFile) { + if (removeThumbnail) await axios.delete(`${API_BASE_URL}/projects/${saved.id}/thumbnail`); + return; + } + + const body = { + hash: await blake3(new Uint8Array(await thumbnailFile.arrayBuffer())), + content_type: thumbnailFile.type, + size: thumbnailFile.size, + }; + const { data: upload } = await axios.post( + `${API_BASE_URL}/projects/${saved.id}/thumbnail/upload`, + body, + ); + if (!("hash" in upload)) + await axios.put(upload.url, thumbnailFile, { + headers: { "Content-Type": thumbnailFile.type }, + withCredentials: false, + }); + await axios.post(`${API_BASE_URL}/projects/${saved.id}/thumbnail/commit`, body); }, onError: () => toast.error("Couldn't save the project. Please try again."), onSuccess: (_data, { creating }) => @@ -666,14 +693,18 @@ function ManageProjectsPage() { onSubmit: ({ value }) => { if (!editor) return; const existing = projects?.find((item) => item.id === editor.id); + const thumbnail = value.thumbnail; saveProject({ creating: editor.creating, project: { ...value, + thumbnail: thumbnail ? { hash: thumbnail.hash, url: thumbnail.url } : undefined, id: editor.id, founded_at: editor.founded_at, members: existing?.members ?? (me ? [{ id: me.id, name: me.name }] : EMPTY_MEMBERS), }, + thumbnailFile: thumbnail?.file, + removeThumbnail: !editor.creating && !thumbnail && editor.hadThumbnail, }); closeEditor(); }, @@ -685,7 +716,11 @@ function ManageProjectsPage() { multiple: false, onDrop: (accepted, rejections) => { if (accepted.length > 0) - form.setFieldValue("thumbnail", { hash: "local", url: URL.createObjectURL(accepted[0]) }); + form.setFieldValue("thumbnail", { + hash: "local", + url: URL.createObjectURL(accepted[0]), + file: accepted[0], + }); else if (rejections.length > 0) toast.error("That file must be an image under 32 MB."); }, }); @@ -705,6 +740,7 @@ function ManageProjectsPage() { creating: true, id: `new-${String(Date.now())}`, founded_at: new Date().toISOString(), + hadThumbnail: false, }); }, [form]); const closeConfirm = useCallback(() => { @@ -773,7 +809,12 @@ function ManageProjectsPage() { setTagText((project.tags ?? []).join(", ")); setTab(nextTab as DetailTab); setAddingMember(false); - setEditor({ creating: false, id: project.id, founded_at: project.founded_at }); + setEditor({ + creating: false, + id: project.id, + founded_at: project.founded_at, + hadThumbnail: !!project.thumbnail, + }); }, [findProject, form], ); diff --git a/src/routes/dashboard/manage/projects_.$projectId.gallery.tsx b/src/routes/dashboard/manage/projects_.$projectId.gallery.tsx index 6df7f1f1..c2b10d65 100644 --- a/src/routes/dashboard/manage/projects_.$projectId.gallery.tsx +++ b/src/routes/dashboard/manage/projects_.$projectId.gallery.tsx @@ -105,9 +105,8 @@ const TEAL_BUTTON_CLASS = "bg-brand-teal font-bold text-primary hover:bg-brand-t /// Helper functions -// This will be removed once we start using real images function mediaSrc(item: MediaRecord, color: string) { - return item.url.startsWith("blob:") ? item.url : mediaArtwork(item.hash, color); + return item.url || mediaArtwork(item.hash, color); } /// Route components @@ -248,39 +247,41 @@ function ProjectGalleryPage() { (old = []) => [...old, record], ); - const { data: uploadResponse } = await axios.post( - `${API_BASE_URL}/projects/${projectId}/media/upload`, - { hash: record.hash, content_type: record.content_type, size: record.size }, - ); - if ("kind" in uploadResponse) continue; - - const commit: CommitRequest = { + const body: CommitRequest = { hash: record.hash, content_type: record.content_type, size: record.size, }; - if ("upload_id" in uploadResponse) { - commit.upload_id = uploadResponse.upload_id; - commit.chunks = []; + const { data: upload } = await axios.post( + `${API_BASE_URL}/projects/${projectId}/media/upload`, + body, + ); + + if ("kind" in upload) continue; + + if ("upload_id" in upload) { + body.upload_id = upload.upload_id; + body.chunks = []; let offset = 0; - for (const { index, url, size } of uploadResponse.chunks) { + for (const { index, url, size } of upload.chunks) { // These are presigned S3 urls, so forcing auth breaks it const part = await axios.put(url, file.slice(offset, offset + size), { withCredentials: false, }); - commit.chunks.push({ + body.chunks.push({ number: index, etag: String(part.headers.etag).replaceAll('"', ""), }); offset += size; } } else { - await axios.put(uploadResponse.url, file, { + await axios.put(upload.url, file, { headers: { "Content-Type": record.content_type }, withCredentials: false, }); } - await axios.post(`${API_BASE_URL}/projects/${projectId}/media/commit`, commit); + + await axios.post(`${API_BASE_URL}/projects/${projectId}/media/commit`, body); } }, onError: () => toast.error("Couldn't upload the media. Please try again."), diff --git a/src/routes/dashboard/projects.tsx b/src/routes/dashboard/projects.tsx index 43a7c1b2..a9693d48 100644 --- a/src/routes/dashboard/projects.tsx +++ b/src/routes/dashboard/projects.tsx @@ -304,6 +304,23 @@ export const projectMediaQueryOptions = (projectId: string) => /// Helper functions +export function mediaArtwork(hash: string, color: string) { + const seed = Number.parseInt(hash.slice(-6), 16) || 0; + const cx = 15 + (seed % 55); + const cy = 12 + (Math.floor(seed / 55) % 45); + const r = 18 + (seed % 26); + const svg = + `` + + `` + + `` + + `` + + `` + + `` + + `` + + ``; + return `data:image/svg+xml,${encodeURIComponent(svg)}`; +} + function initialsOf(name: string) { return ( name @@ -324,24 +341,6 @@ function fmtMonthYear(iso: string) { return MONTH_YEAR_FMT.format(new Date(iso)); } -// Will get replaced with the proper images -export function mediaArtwork(hash: string, color: string) { - const seed = Number.parseInt(hash.slice(-6), 16) || 0; - const cx = 15 + (seed % 55); - const cy = 12 + (Math.floor(seed / 55) % 45); - const r = 18 + (seed % 26); - const svg = - `` + - `` + - `` + - `` + - `` + - `` + - `` + - ``; - return `data:image/svg+xml,${encodeURIComponent(svg)}`; -} - function patchMembership( queryClient: ReturnType, project: FullProject, @@ -548,8 +547,8 @@ function ProjectsPage() { const openProject = openId ? projects?.find((project) => project.id === openId) : undefined; const openColor = openProject ? SIG_META[openProject.type].color : "#93a3b6"; - const lightboxSrc = useCallback( - (item: MediaRecord) => mediaArtwork(item.hash, openColor), + const mediaSrc = useCallback( + (item: MediaRecord) => item.url || mediaArtwork(item.hash, openColor), [openColor], ); @@ -1033,8 +1032,10 @@ function ProjectsPage() { >
{item.kind === "video" && ( @@ -1095,7 +1096,7 @@ function ProjectsPage() { diff --git a/src/routes/events.tsx b/src/routes/events.tsx index cf90c668..7ef89439 100644 --- a/src/routes/events.tsx +++ b/src/routes/events.tsx @@ -7,7 +7,6 @@ import { MapPin } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; import { toast } from "sonner"; -import aboutUsThumb from "@/assets/images/about-us.png"; import { PublicEventsCalendar } from "@/components/app/events-calendar"; import { Button } from "@/components/ui/button"; import { Popover, PopoverTrigger } from "@/components/ui/popover"; @@ -32,6 +31,11 @@ type EventType = | "sig_swe" | "social"; +interface EventThumbnail { + hash: string; + url: string; +} + interface ApiEvent { id: string; name: string; @@ -41,6 +45,7 @@ interface ApiEvent { location: string; type: EventType; timezone: string; + thumbnail?: EventThumbnail | null; creator_id: string; } @@ -349,12 +354,25 @@ function Events() { > {selectedEvent ? ( <> - + {selectedEvent.thumbnail ? ( + + ) : ( +
+ {selectedEventType.label} +
+ )}
+ {project.thumbnail ? ( + + ) : undefined} +