252 lines
7.5 KiB
TypeScript
252 lines
7.5 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
useCallback,
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
type ChangeEvent,
|
|
} from "react";
|
|
import Upload from "../../../../components/controls/Upload";
|
|
import { useMessages, useTranslation } from "../../../../contexts/MessagesContext";
|
|
import { useCreateFlow } from "../../context/CreateFlowContext";
|
|
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
|
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
|
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
|
|
import { fetchAuthSession } from "../../../../../lib/create/api";
|
|
import {
|
|
createFlowUploadFailureMessageKey,
|
|
uploadCreateFlowFile,
|
|
} from "../../../../../lib/create/uploadToServer";
|
|
import {
|
|
COMMUNITY_AVATAR_ACCEPT,
|
|
messageKeyForCreateFlowUploadReason,
|
|
validateCreateFlowUploadFile,
|
|
} from "../../../../../lib/create/createFlowUploadValidation";
|
|
import {
|
|
clearPendingCommunityAvatarFile,
|
|
readPendingCommunityAvatarFile,
|
|
storePendingCommunityAvatarFile,
|
|
} from "../../../../../lib/create/pendingCommunityAvatarUpload";
|
|
|
|
function hasCommunityAvatarUrl(url: string | undefined): boolean {
|
|
return typeof url === "string" && url.trim().length > 0;
|
|
}
|
|
|
|
/** Create Community — Figma Flow — Upload. */
|
|
export function CommunityUploadScreen() {
|
|
const m = useMessages();
|
|
const u = m.create.community.communityUpload;
|
|
const { markCreateFlowInteraction, state, updateState } = useCreateFlow();
|
|
const tUpload = useTranslation("create.upload");
|
|
|
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
const [signedIn, setSignedIn] = useState<boolean | null>(null);
|
|
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null);
|
|
const [localFileName, setLocalFileName] = useState<string | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
void fetchAuthSession().then(({ user }) => {
|
|
if (!cancelled) setSignedIn(Boolean(user));
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
useEffect(
|
|
() => () => {
|
|
if (localPreviewUrl) URL.revokeObjectURL(localPreviewUrl);
|
|
},
|
|
[localPreviewUrl],
|
|
);
|
|
|
|
const serverAvatarUrl = hasCommunityAvatarUrl(state.communityAvatarUrl)
|
|
? state.communityAvatarUrl!.trim()
|
|
: null;
|
|
const serverAvatarUrlRef = useRef(serverAvatarUrl);
|
|
serverAvatarUrlRef.current = serverAvatarUrl;
|
|
|
|
useEffect(() => {
|
|
if (serverAvatarUrl) {
|
|
setLocalPreviewUrl((prev) => {
|
|
if (prev) URL.revokeObjectURL(prev);
|
|
return null;
|
|
});
|
|
}
|
|
}, [serverAvatarUrl]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
void (async () => {
|
|
try {
|
|
const file = await readPendingCommunityAvatarFile();
|
|
if (cancelled || !file) return;
|
|
const validated = await validateCreateFlowUploadFile(
|
|
file,
|
|
"communityAvatar",
|
|
);
|
|
if (validated.ok === false) {
|
|
await clearPendingCommunityAvatarFile();
|
|
return;
|
|
}
|
|
if (cancelled) return;
|
|
if (serverAvatarUrlRef.current) return;
|
|
const objectUrl = URL.createObjectURL(file);
|
|
if (cancelled) {
|
|
URL.revokeObjectURL(objectUrl);
|
|
return;
|
|
}
|
|
setLocalPreviewUrl((prev) => {
|
|
if (prev) URL.revokeObjectURL(prev);
|
|
return objectUrl;
|
|
});
|
|
setLocalFileName(file.name);
|
|
if (!state.communityAvatarFileName) {
|
|
updateState({ communityAvatarFileName: file.name });
|
|
}
|
|
} catch {
|
|
// Missing IndexedDB / quota: leave the picker empty.
|
|
}
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [state.communityAvatarFileName, updateState]);
|
|
|
|
const resolveUploadError = useCallback(
|
|
(err: unknown) => {
|
|
const key = createFlowUploadFailureMessageKey(err);
|
|
if (key === "errors.tooLarge") return u.fileTooLarge;
|
|
return tUpload(key);
|
|
},
|
|
[tUpload, u.fileTooLarge],
|
|
);
|
|
|
|
const handleFileChange = useCallback(
|
|
async (e: ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
e.target.value = "";
|
|
if (!file) return;
|
|
markCreateFlowInteraction();
|
|
setErrorMessage(null);
|
|
|
|
if (signedIn === true) {
|
|
setBusy(true);
|
|
try {
|
|
const { url } = await uploadCreateFlowFile(file, "communityAvatar");
|
|
setLocalPreviewUrl((prev) => {
|
|
if (prev) URL.revokeObjectURL(prev);
|
|
return null;
|
|
});
|
|
setLocalFileName(file.name);
|
|
updateState({
|
|
communityAvatarUrl: url,
|
|
communityAvatarFileName: file.name,
|
|
});
|
|
} catch (err) {
|
|
setErrorMessage(resolveUploadError(err));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const validated = await validateCreateFlowUploadFile(
|
|
file,
|
|
"communityAvatar",
|
|
);
|
|
if (validated.ok === false) {
|
|
setErrorMessage(
|
|
validated.reason === "tooLarge"
|
|
? u.fileTooLarge
|
|
: tUpload(messageKeyForCreateFlowUploadReason(validated.reason)),
|
|
);
|
|
return;
|
|
}
|
|
try {
|
|
await storePendingCommunityAvatarFile(file);
|
|
setLocalPreviewUrl((prev) => {
|
|
if (prev) URL.revokeObjectURL(prev);
|
|
return URL.createObjectURL(file);
|
|
});
|
|
setLocalFileName(file.name);
|
|
updateState({ communityAvatarFileName: file.name });
|
|
} catch {
|
|
setErrorMessage(tUpload("errors.generic"));
|
|
}
|
|
},
|
|
[
|
|
markCreateFlowInteraction,
|
|
resolveUploadError,
|
|
signedIn,
|
|
tUpload,
|
|
u.fileTooLarge,
|
|
updateState,
|
|
],
|
|
);
|
|
|
|
const handlePickFile = useCallback(() => {
|
|
if (!busy) fileInputRef.current?.click();
|
|
}, [busy]);
|
|
|
|
const displaySrc = serverAvatarUrl ?? localPreviewUrl;
|
|
const hasPreview = typeof displaySrc === "string" && displaySrc.length > 0;
|
|
const fileName =
|
|
localFileName ??
|
|
(typeof state.communityAvatarFileName === "string" &&
|
|
state.communityAvatarFileName.trim().length > 0
|
|
? state.communityAvatarFileName.trim()
|
|
: undefined);
|
|
|
|
return (
|
|
<CreateFlowStepShell
|
|
variant="centeredNarrow"
|
|
contentTopBelowMd="space-1400"
|
|
>
|
|
<div
|
|
className={`flex flex-col items-center gap-[18px] ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
|
|
>
|
|
<div className="w-full">
|
|
<CreateFlowHeaderLockup
|
|
title={u.title}
|
|
description={u.description}
|
|
justification="center"
|
|
/>
|
|
</div>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
className="sr-only"
|
|
tabIndex={-1}
|
|
accept={COMMUNITY_AVATAR_ACCEPT}
|
|
aria-label={hasPreview ? u.replaceButton : u.hintText}
|
|
onChange={handleFileChange}
|
|
/>
|
|
<Upload
|
|
hasLabel={false}
|
|
active={!busy}
|
|
state={
|
|
errorMessage ? "error" : busy ? "disabled" : "default"
|
|
}
|
|
hintText={
|
|
errorMessage
|
|
? errorMessage
|
|
: busy
|
|
? u.uploadingLabel
|
|
: u.hintText
|
|
}
|
|
buttonLabel={hasPreview ? u.replaceButton : undefined}
|
|
previewSrc={hasPreview ? displaySrc ?? undefined : undefined}
|
|
previewAlt={u.previewAlt}
|
|
fileName={hasPreview ? fileName : undefined}
|
|
onClick={handlePickFile}
|
|
/>
|
|
</div>
|
|
</CreateFlowStepShell>
|
|
);
|
|
}
|