Files
community-rule/app/(app)/create/screens/upload/CommunityUploadScreen.tsx
T

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>
);
}