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
26 changes: 19 additions & 7 deletions src/routes/dashboard/manage/events.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>) => void;
type RosterTab = "qr" | "roster";

Expand Down Expand Up @@ -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,
Expand All @@ -476,19 +477,30 @@ function ManageEventsPage() {
};
await (creating
? axios.post<FullEvent>(`${API_BASE_URL}/events/create`, {
...body,
...details,
id: event.id,
type: event.type,
})
: axios.put<FullEvent>(`${API_BASE_URL}/events/${event.id}`, body));
: axios.put<FullEvent>(`${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<ThumbnailUpload>(
`${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`);
}
Expand Down
69 changes: 55 additions & 14 deletions src/routes/dashboard/manage/projects.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -99,6 +100,7 @@ type ManageStatus = "all" | "active" | "archived";
type DetailTab = "details" | "team";
type InviteAction = "accept" | "decline" | "revoke";
type RowHandler = (event: MouseEvent<HTMLElement>) => void;
type ThumbnailUpload = { url: string } | { hash: string; url: string };

interface ProjectPageParams {
active?: boolean;
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<Pick<FullProject, "id">>(`${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<Pick<FullProject, "id">>(`${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<ThumbnailUpload>(
`${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 }) =>
Expand Down Expand Up @@ -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();
},
Expand All @@ -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.");
},
});
Expand All @@ -705,6 +740,7 @@ function ManageProjectsPage() {
creating: true,
id: `new-${String(Date.now())}`,
founded_at: new Date().toISOString(),
hadThumbnail: false,
});
}, [form]);
const closeConfirm = useCallback(() => {
Expand Down Expand Up @@ -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],
);
Expand Down
33 changes: 17 additions & 16 deletions src/routes/dashboard/manage/projects_.$projectId.gallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -248,39 +247,41 @@ function ProjectGalleryPage() {
(old = []) => [...old, record],
);

const { data: uploadResponse } = await axios.post<UploadResponse>(
`${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<UploadResponse>(
`${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."),
Expand Down
45 changes: 23 additions & 22 deletions src/routes/dashboard/projects.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 120 90'>` +
`<defs><linearGradient id='g' x1='0' y1='0' x2='1' y2='1'>` +
`<stop offset='0' stop-color='${color}'/>` +
`<stop offset='1' stop-color='${color}' stop-opacity='0.35'/></linearGradient></defs>` +
`<rect width='120' height='90' fill='url(#g)'/>` +
`<circle cx='${String(cx)}' cy='${String(cy)}' r='${String(r)}' fill='#ffffff' opacity='0.2'/>` +
`<circle cx='${String(120 - cx)}' cy='${String(90 - cy)}' r='${String(r * 0.7)}' fill='#000000' opacity='0.12'/>` +
`</svg>`;
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
}

function initialsOf(name: string) {
return (
name
Expand All @@ -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 =
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 120 90'>` +
`<defs><linearGradient id='g' x1='0' y1='0' x2='1' y2='1'>` +
`<stop offset='0' stop-color='${color}'/>` +
`<stop offset='1' stop-color='${color}' stop-opacity='0.35'/></linearGradient></defs>` +
`<rect width='120' height='90' fill='url(#g)'/>` +
`<circle cx='${String(cx)}' cy='${String(cy)}' r='${String(r)}' fill='#ffffff' opacity='0.2'/>` +
`<circle cx='${String(120 - cx)}' cy='${String(90 - cy)}' r='${String(r * 0.7)}' fill='#000000' opacity='0.12'/>` +
`</svg>`;
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
}

function patchMembership(
queryClient: ReturnType<typeof useQueryClient>,
project: FullProject,
Expand Down Expand Up @@ -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],
);

Expand Down Expand Up @@ -1033,8 +1032,10 @@ function ProjectsPage() {
>
<div className="relative h-24">
<img
src={mediaArtwork(item.hash, openColor)}
src={mediaSrc(item)}
alt=""
loading="lazy"
decoding="async"
className="size-full object-cover transition group-hover/media:scale-105"
/>
{item.kind === "video" && (
Expand Down Expand Up @@ -1095,7 +1096,7 @@ function ProjectsPage() {
<MediaLightbox
items={openMedia}
index={lightboxIndex}
srcFor={lightboxSrc}
srcFor={mediaSrc}
onIndexChange={setLightboxIndex}
onClose={handleLightboxClose}
/>
Expand Down
Loading