Add button and custom modal flow implemented
This commit is contained in:
@@ -73,6 +73,7 @@ import { useAuthModal } from "../../contexts/AuthModalContext";
|
||||
import { useMessages, useTranslation } from "../../contexts/MessagesContext";
|
||||
import { PostLoginDraftTransfer } from "./PostLoginDraftTransfer";
|
||||
import { SignedInDraftHydration } from "./SignedInDraftHydration";
|
||||
import { CreateFlowPendingAvatarFlush } from "./components/CreateFlowPendingAvatarFlush";
|
||||
import Alert from "../../components/modals/Alert";
|
||||
import Share from "../../components/modals/Share";
|
||||
import {
|
||||
@@ -561,6 +562,12 @@ function CreateFlowLayoutContent({
|
||||
<Suspense fallback={null}>
|
||||
<PostLoginDraftTransfer sessionUser={sessionUser} />
|
||||
</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
<CreateFlowPendingAvatarFlush
|
||||
sessionUser={sessionUser}
|
||||
sessionResolved={sessionResolved}
|
||||
/>
|
||||
</Suspense>
|
||||
<Share
|
||||
isOpen={shareModalOpen}
|
||||
onClose={() => setShareModalOpen(false)}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useCreateFlow } from "../context/CreateFlowContext";
|
||||
import { uploadCreateFlowFile } from "../../../../lib/create/uploadToServer";
|
||||
import {
|
||||
clearPendingCommunityAvatarFile,
|
||||
readPendingCommunityAvatarFile,
|
||||
} from "../../../../lib/create/pendingCommunityAvatarUpload";
|
||||
|
||||
/**
|
||||
* After sign-in, uploads a community avatar staged in IndexedDB (anonymous pick)
|
||||
* and writes `communityAvatarUrl` on success.
|
||||
*/
|
||||
export function CreateFlowPendingAvatarFlush({
|
||||
sessionUser,
|
||||
sessionResolved,
|
||||
}: {
|
||||
sessionUser: { id: string; email: string } | null | undefined;
|
||||
sessionResolved: boolean;
|
||||
}) {
|
||||
const { updateState } = useCreateFlow();
|
||||
/** One successful flush per signed-in user id (survives React StrictMode remounts). */
|
||||
const lastFlushedUserIdRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionResolved || !sessionUser) return;
|
||||
if (lastFlushedUserIdRef.current === sessionUser.id) return;
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
const file = await readPendingCommunityAvatarFile();
|
||||
if (cancelled || !file) return;
|
||||
try {
|
||||
const { url } = await uploadCreateFlowFile(file, "communityAvatar");
|
||||
if (cancelled) return;
|
||||
await clearPendingCommunityAvatarFile();
|
||||
updateState({ communityAvatarUrl: url });
|
||||
lastFlushedUserIdRef.current = sessionUser.id;
|
||||
} catch {
|
||||
// Leave pending blob in place so the user can retry after fixing auth / UPLOAD_ROOT.
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [sessionResolved, sessionUser, updateState]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -10,8 +10,8 @@
|
||||
* {@link ApplicableScopeField} chip rows, {@link IncrementerBlock}.
|
||||
*/
|
||||
|
||||
import { memo, useCallback, useRef } from "react";
|
||||
import { useMessages } from "../../../contexts/MessagesContext";
|
||||
import { memo, useCallback, useRef, useState } from "react";
|
||||
import { useMessages, useTranslation } from "../../../contexts/MessagesContext";
|
||||
import Chip from "../../../components/controls/Chip";
|
||||
import IncrementerBlock from "../../../components/controls/IncrementerBlock";
|
||||
import InlineTextButton from "../../../components/buttons/InlineTextButton";
|
||||
@@ -20,6 +20,7 @@ import ApplicableScopeField from "./ApplicableScopeField";
|
||||
import InputLabel from "../../../components/type/InputLabel";
|
||||
import type { CustomMethodCardFieldBlock } from "../../../../lib/create/customMethodCardFieldBlocks";
|
||||
import ModalTextAreaField from "./ModalTextAreaField";
|
||||
import { uploadCreateFlowFile } from "../../../../lib/create/uploadToServer";
|
||||
|
||||
const TEXT_VALUE_MAX = 8000;
|
||||
|
||||
@@ -55,7 +56,12 @@ function CustomMethodCardUploadBlockRow({
|
||||
noFileChosen: string;
|
||||
}) {
|
||||
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const tUpload = useTranslation("create.upload");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const displayName = block.fileName?.trim() ? block.fileName : noFileChosen;
|
||||
const hasAsset = Boolean(block.assetUrl?.trim());
|
||||
const previewAlt = block.fileName?.trim() || block.blockTitle || noFileChosen;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -65,41 +71,81 @@ function CustomMethodCardUploadBlockRow({
|
||||
size="s"
|
||||
palette="default"
|
||||
/>
|
||||
<p className="font-[family-name:var(--font-body)] text-[length:var(--font-size-body-m)] text-[var(--color-content-default-secondary)]">
|
||||
{displayName}
|
||||
</p>
|
||||
{!hasAsset ? (
|
||||
<p className="font-[family-name:var(--font-body)] text-[length:var(--font-size-body-m)] text-[var(--color-content-default-secondary)]">
|
||||
{displayName}
|
||||
</p>
|
||||
) : null}
|
||||
{hasAsset ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- same-origin upload URL
|
||||
<img
|
||||
src={block.assetUrl!.trim()}
|
||||
alt={previewAlt}
|
||||
className="max-h-[160px] max-w-full rounded-[var(--measures-radius-200,8px)] object-contain"
|
||||
/>
|
||||
) : null}
|
||||
<input
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
className="sr-only"
|
||||
tabIndex={-1}
|
||||
accept="image/jpeg,image/png,image/webp,image/gif,application/pdf"
|
||||
aria-label={uploadFileInputAriaLabel}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
const name = file?.name?.trim();
|
||||
patch(
|
||||
mapBlockById(blocks, block.id, (b) =>
|
||||
b.kind === "upload"
|
||||
? {
|
||||
...b,
|
||||
...(name ? { fileName: name } : {}),
|
||||
}
|
||||
: b,
|
||||
),
|
||||
);
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
setErrorMessage(null);
|
||||
setBusy(true);
|
||||
void (async () => {
|
||||
try {
|
||||
const { url } = await uploadCreateFlowFile(
|
||||
file,
|
||||
"customMethodAttachment",
|
||||
);
|
||||
const name = file.name?.trim();
|
||||
patch(
|
||||
mapBlockById(blocks, block.id, (b) =>
|
||||
b.kind === "upload"
|
||||
? {
|
||||
...b,
|
||||
...(name ? { fileName: name } : {}),
|
||||
assetUrl: url,
|
||||
}
|
||||
: b,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
setErrorMessage(tUpload("errors.generic"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
/>
|
||||
<Upload
|
||||
hintText={uploadHint}
|
||||
onClick={() => uploadInputRef.current?.click()}
|
||||
active={!busy}
|
||||
hintText={busy ? tUpload("uploading") : uploadHint}
|
||||
onClick={() => {
|
||||
if (!busy) uploadInputRef.current?.click();
|
||||
}}
|
||||
/>
|
||||
{block.fileName?.trim() ? (
|
||||
{errorMessage ? (
|
||||
<p
|
||||
className="font-[family-name:var(--font-body)] text-[length:var(--font-size-body-s)] text-[var(--color-content-default-secondary)]"
|
||||
role="alert"
|
||||
>
|
||||
{errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
{block.fileName?.trim() || block.assetUrl?.trim() ? (
|
||||
<InlineTextButton
|
||||
onClick={() =>
|
||||
patch(
|
||||
mapBlockById(blocks, block.id, (b) =>
|
||||
b.kind === "upload" ? { ...b, fileName: undefined } : b,
|
||||
b.kind === "upload"
|
||||
? { ...b, fileName: undefined, assetUrl: undefined }
|
||||
: b,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -220,15 +266,30 @@ function CustomMethodCardFieldBlocksSummaryComponent({
|
||||
return (
|
||||
<div key={block.id}>
|
||||
{readOnly ? (
|
||||
<ModalTextAreaField
|
||||
label={block.blockTitle}
|
||||
rows={2}
|
||||
value={
|
||||
block.fileName?.trim() ? block.fileName : noFileChosen
|
||||
}
|
||||
onChange={() => {}}
|
||||
disabled
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<InputLabel
|
||||
label={block.blockTitle}
|
||||
helpIcon
|
||||
size="s"
|
||||
palette="default"
|
||||
/>
|
||||
{block.assetUrl?.trim() ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={block.assetUrl.trim()}
|
||||
alt={
|
||||
block.fileName?.trim() ||
|
||||
block.blockTitle ||
|
||||
noFileChosen
|
||||
}
|
||||
className="max-h-[160px] max-w-full rounded-[var(--measures-radius-200,8px)] object-contain"
|
||||
/>
|
||||
) : (
|
||||
<p className="font-[family-name:var(--font-body)] text-[length:var(--font-size-body-m)] text-[var(--color-content-default-secondary)]">
|
||||
{noFileChosen}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<CustomMethodCardUploadBlockRow
|
||||
block={block}
|
||||
|
||||
+76
-8
@@ -1,7 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMessages, useTranslation } from "../../../../contexts/MessagesContext";
|
||||
import {
|
||||
useMessages,
|
||||
useTranslation,
|
||||
} from "../../../../contexts/MessagesContext";
|
||||
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
|
||||
import { CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS } from "../../../../../lib/create/customMethodCardWizardConstants";
|
||||
import type { AddCustomFieldType } from "../../../../components/controls/AddCustomField/AddCustomField.types";
|
||||
@@ -13,9 +16,10 @@ import type { CustomMethodCardWizardProps } from "./CustomMethodCardWizard.types
|
||||
* `20066:14748`, `20094:48551`, `20066:14361`).
|
||||
*/
|
||||
const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
|
||||
({ isOpen, onClose, onFinalize }) => {
|
||||
({ isOpen, onClose, onFinalize, onPersistCustomUploadFile }) => {
|
||||
const m = useMessages();
|
||||
const t = useTranslation("common");
|
||||
const tUpload = useTranslation("create.upload");
|
||||
const w = m.create.customRule.customMethodCardWizard;
|
||||
|
||||
const copy = useMemo(
|
||||
@@ -23,10 +27,23 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
|
||||
step1: w.steps["1"],
|
||||
step2: w.steps["2"],
|
||||
step3: w.steps["3"],
|
||||
step3BlocksList: w.step3BlocksList,
|
||||
fieldTypeLabels: {
|
||||
text: w.addCustomField.fieldTypes.text,
|
||||
badges: w.addCustomField.fieldTypes.badges,
|
||||
upload: w.addCustomField.fieldTypes.upload,
|
||||
proportion: w.addCustomField.fieldTypes.proportion,
|
||||
},
|
||||
footerFinalize: w.footer.finalize,
|
||||
fieldModals: w.fieldModals,
|
||||
}),
|
||||
[w.fieldModals, w.footer.finalize, w.steps],
|
||||
[
|
||||
w.addCustomField.fieldTypes,
|
||||
w.fieldModals,
|
||||
w.footer.finalize,
|
||||
w.step3BlocksList,
|
||||
w.steps,
|
||||
],
|
||||
);
|
||||
|
||||
const fieldBodiesCopy = useMemo(
|
||||
@@ -58,6 +75,13 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
|
||||
const [uploadFileName, setUploadFileName] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [uploadAssetUrl, setUploadAssetUrl] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [uploadFieldBusy, setUploadFieldBusy] = useState(false);
|
||||
const [uploadFieldError, setUploadFieldError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [proportionBlockTitle, setProportionBlockTitle] = useState("");
|
||||
const [proportionDefault, setProportionDefault] = useState(50);
|
||||
|
||||
@@ -70,6 +94,9 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
|
||||
setBadgeOptions([]);
|
||||
setUploadBlockTitle("");
|
||||
setUploadFileName(undefined);
|
||||
setUploadAssetUrl(undefined);
|
||||
setUploadFieldBusy(false);
|
||||
setUploadFieldError(null);
|
||||
setProportionBlockTitle("");
|
||||
setProportionDefault(50);
|
||||
if (fileInputRef.current) {
|
||||
@@ -135,10 +162,14 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
|
||||
}
|
||||
if (fieldTypeModal === "upload") {
|
||||
const t0 = uploadBlockTitle.trim();
|
||||
return (
|
||||
const titleOk =
|
||||
t0.length > 0 &&
|
||||
t0.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS
|
||||
);
|
||||
t0.length <= CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS;
|
||||
if (!titleOk) return false;
|
||||
if (onPersistCustomUploadFile) {
|
||||
return Boolean(uploadAssetUrl?.trim());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const t0 = proportionBlockTitle.trim();
|
||||
return (
|
||||
@@ -154,6 +185,8 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
|
||||
proportionDefault,
|
||||
textBlockTitle,
|
||||
uploadBlockTitle,
|
||||
uploadAssetUrl,
|
||||
onPersistCustomUploadFile,
|
||||
]);
|
||||
|
||||
const headerTitle =
|
||||
@@ -213,13 +246,35 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
|
||||
}, [resetFieldTypeDrafts]);
|
||||
|
||||
const handleFileChosen = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
setUploadFileName(file?.name);
|
||||
setUploadAssetUrl(undefined);
|
||||
setUploadFieldError(null);
|
||||
if (!file || !onPersistCustomUploadFile) return;
|
||||
setUploadFieldBusy(true);
|
||||
try {
|
||||
const { url } = await onPersistCustomUploadFile(file);
|
||||
setUploadAssetUrl(url);
|
||||
} catch {
|
||||
setUploadFieldError(tUpload("errors.generic"));
|
||||
} finally {
|
||||
setUploadFieldBusy(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
[onPersistCustomUploadFile, tUpload],
|
||||
);
|
||||
|
||||
const handleClearPendingUpload = useCallback(() => {
|
||||
setUploadFileName(undefined);
|
||||
setUploadAssetUrl(undefined);
|
||||
setUploadFieldError(null);
|
||||
setUploadFieldBusy(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleBadgeAddOption = useCallback((label: string) => {
|
||||
setBadgeOptions((prev) =>
|
||||
prev.includes(label) ? prev : [...prev, label],
|
||||
@@ -253,6 +308,9 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
|
||||
id,
|
||||
blockTitle: uploadBlockTitle.trim(),
|
||||
fileName: uploadFileName,
|
||||
...(uploadAssetUrl?.trim()
|
||||
? { assetUrl: uploadAssetUrl.trim() }
|
||||
: {}),
|
||||
};
|
||||
break;
|
||||
default:
|
||||
@@ -276,6 +334,7 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
|
||||
textPlaceholderBody,
|
||||
uploadBlockTitle,
|
||||
uploadFileName,
|
||||
uploadAssetUrl,
|
||||
]);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
@@ -337,6 +396,13 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
|
||||
onUploadBlockTitleChange: setUploadBlockTitle,
|
||||
fileInputRef,
|
||||
onFileChosen: handleFileChosen,
|
||||
onClearPendingUpload: handleClearPendingUpload,
|
||||
uploadAssetPreviewUrl: uploadAssetUrl,
|
||||
uploadPersisting:
|
||||
Boolean(fieldTypeModal === "upload" && uploadFieldBusy),
|
||||
uploadBusyHint: tUpload("uploading"),
|
||||
uploadErrorMessage:
|
||||
fieldTypeModal === "upload" ? uploadFieldError : null,
|
||||
proportionBlockTitle,
|
||||
proportionDefault,
|
||||
onProportionBlockTitleChange: setProportionBlockTitle,
|
||||
@@ -348,6 +414,8 @@ const CustomMethodCardWizardContainer = memo<CustomMethodCardWizardProps>(
|
||||
onBack={handleBack}
|
||||
onNext={handleNext}
|
||||
stepper={!fieldTypeModal}
|
||||
draftFieldBlocks={draftFieldBlocks}
|
||||
onDraftFieldBlocksReorder={setDraftFieldBlocks}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -21,6 +21,9 @@ export interface CustomMethodCardWizardFieldBodiesCopy {
|
||||
blockTitlePlaceholder: string;
|
||||
uploadFileInputAriaLabel: string;
|
||||
uploadHint: string;
|
||||
uploadPreviewImageAlt: string;
|
||||
clearPendingUploadAriaLabel: string;
|
||||
clearPendingUploadTooltip: string;
|
||||
};
|
||||
proportion: {
|
||||
blockTitleLabel: string;
|
||||
@@ -35,6 +38,11 @@ export interface CustomMethodCardWizardCopy {
|
||||
step1: { title: string; description: string; fieldPlaceholder: string };
|
||||
step2: { title: string; description: string; fieldPlaceholder: string };
|
||||
step3: { title: string; description: string };
|
||||
step3BlocksList: {
|
||||
listLabel: string;
|
||||
dragHandleAriaLabel: string;
|
||||
};
|
||||
fieldTypeLabels: Record<AddCustomFieldType, string>;
|
||||
footerFinalize: string;
|
||||
fieldModals: {
|
||||
addField: string;
|
||||
@@ -67,6 +75,11 @@ export interface CustomMethodCardWizardProps {
|
||||
description: string;
|
||||
fieldBlocks: CustomMethodCardFieldBlock[];
|
||||
}) => void;
|
||||
/**
|
||||
* Persists custom-method upload files to `POST /api/uploads` (purpose
|
||||
* `customMethodAttachment`). When omitted, upload field only stores `fileName`.
|
||||
*/
|
||||
onPersistCustomUploadFile?: (file: File) => Promise<{ url: string }>;
|
||||
}
|
||||
|
||||
export interface CustomMethodCardWizardFieldBodiesViewProps {
|
||||
@@ -84,6 +97,15 @@ export interface CustomMethodCardWizardFieldBodiesViewProps {
|
||||
onUploadBlockTitleChange: (_v: string) => void;
|
||||
fileInputRef: RefObject<HTMLInputElement | null>;
|
||||
onFileChosen: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
/** Clears chosen file, preview URL, and related errors so the user can pick again. */
|
||||
onClearPendingUpload: () => void;
|
||||
/** When set after a successful upload, shows an inline image preview in the modal. */
|
||||
uploadAssetPreviewUrl?: string | null;
|
||||
/** Shown under the upload control while saving to the server. */
|
||||
uploadPersisting?: boolean;
|
||||
/** Replaces upload hint text while `uploadPersisting` is true. */
|
||||
uploadBusyHint?: string;
|
||||
uploadErrorMessage?: string | null;
|
||||
proportionBlockTitle: string;
|
||||
proportionDefault: number;
|
||||
onProportionBlockTitleChange: (_v: string) => void;
|
||||
@@ -111,6 +133,8 @@ export interface CustomMethodCardWizardViewProps {
|
||||
CustomMethodCardWizardFieldBodiesViewProps,
|
||||
"fieldType" | "copy"
|
||||
>;
|
||||
draftFieldBlocks: CustomMethodCardFieldBlock[];
|
||||
onDraftFieldBlocksReorder: (_next: CustomMethodCardFieldBlock[]) => void;
|
||||
nextDisabled: boolean;
|
||||
nextLabel: string;
|
||||
showBackButton: boolean;
|
||||
|
||||
@@ -6,6 +6,7 @@ import InputWithCounter from "../../../../components/controls/InputWithCounter";
|
||||
import TextArea from "../../../../components/controls/TextArea";
|
||||
import AddCustomField from "../../../../components/controls/AddCustomField";
|
||||
import { CustomMethodCardWizardFieldBodiesView } from "./CustomMethodCardWizardFieldBodies.view";
|
||||
import { CustomMethodCardWizardBlocksListView } from "./CustomMethodCardWizardBlocksList.view";
|
||||
import type { CustomMethodCardWizardViewProps } from "./CustomMethodCardWizard.types";
|
||||
|
||||
function CustomMethodCardWizardViewComponent({
|
||||
@@ -32,6 +33,8 @@ function CustomMethodCardWizardViewComponent({
|
||||
onBack,
|
||||
onNext,
|
||||
stepper,
|
||||
draftFieldBlocks,
|
||||
onDraftFieldBlocksReorder,
|
||||
}: CustomMethodCardWizardViewProps) {
|
||||
return (
|
||||
<Create
|
||||
@@ -79,11 +82,22 @@ function CustomMethodCardWizardViewComponent({
|
||||
/>
|
||||
) : null}
|
||||
{!fieldTypeModal && wizardStep === 3 ? (
|
||||
<AddCustomField
|
||||
active={addFieldExpanded}
|
||||
onPressAdd={onPressAddCustomField}
|
||||
onSelectFieldType={onSelectFieldType}
|
||||
/>
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
{draftFieldBlocks.length > 0 ? (
|
||||
<CustomMethodCardWizardBlocksListView
|
||||
blocks={draftFieldBlocks}
|
||||
fieldTypeLabels={copy.fieldTypeLabels}
|
||||
dragHandleAriaLabel={copy.step3BlocksList.dragHandleAriaLabel}
|
||||
listLabel={copy.step3BlocksList.listLabel}
|
||||
onBlocksReorder={onDraftFieldBlocksReorder}
|
||||
/>
|
||||
) : null}
|
||||
<AddCustomField
|
||||
active={addFieldExpanded}
|
||||
onPressAdd={onPressAddCustomField}
|
||||
onSelectFieldType={onSelectFieldType}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</Create>
|
||||
);
|
||||
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useCallback, useState, type DragEvent } from "react";
|
||||
import Icon from "../../../../components/asset/icon";
|
||||
import { ADD_CUSTOM_FIELD_TYPE_ICONS } from "../../../../components/controls/AddCustomField/AddCustomField.types";
|
||||
import type { AddCustomFieldType } from "../../../../components/controls/AddCustomField/AddCustomField.types";
|
||||
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
|
||||
import { reorderCustomMethodCardFieldBlocks } from "../../../../../lib/create/reorderCustomMethodCardFieldBlocks";
|
||||
|
||||
function DragHandleGlyph({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
width={16}
|
||||
height={16}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
aria-hidden
|
||||
>
|
||||
<circle cx={4} cy={4} r={1.25} fill="currentColor" />
|
||||
<circle cx={12} cy={4} r={1.25} fill="currentColor" />
|
||||
<circle cx={4} cy={8} r={1.25} fill="currentColor" />
|
||||
<circle cx={12} cy={8} r={1.25} fill="currentColor" />
|
||||
<circle cx={4} cy={12} r={1.25} fill="currentColor" />
|
||||
<circle cx={12} cy={12} r={1.25} fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export interface CustomMethodCardWizardBlocksListViewProps {
|
||||
blocks: CustomMethodCardFieldBlock[];
|
||||
fieldTypeLabels: Record<AddCustomFieldType, string>;
|
||||
dragHandleAriaLabel: string;
|
||||
listLabel: string;
|
||||
onBlocksReorder: (_next: CustomMethodCardFieldBlock[]) => void;
|
||||
}
|
||||
|
||||
function CustomMethodCardWizardBlocksListViewComponent({
|
||||
blocks,
|
||||
fieldTypeLabels,
|
||||
dragHandleAriaLabel,
|
||||
listLabel,
|
||||
onBlocksReorder,
|
||||
}: CustomMethodCardWizardBlocksListViewProps) {
|
||||
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
|
||||
const [overIndex, setOverIndex] = useState<number | null>(null);
|
||||
|
||||
const clearDragUi = useCallback(() => {
|
||||
setDraggingIndex(null);
|
||||
setOverIndex(null);
|
||||
}, []);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(index: number) => (e: DragEvent) => {
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("text/plain", String(index));
|
||||
setDraggingIndex(index);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback((index: number) => {
|
||||
return (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setOverIndex(index);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(index: number) => (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
const from = Number.parseInt(e.dataTransfer.getData("text/plain"), 10);
|
||||
if (Number.isNaN(from)) {
|
||||
clearDragUi();
|
||||
return;
|
||||
}
|
||||
onBlocksReorder(
|
||||
reorderCustomMethodCardFieldBlocks(blocks, from, index),
|
||||
);
|
||||
clearDragUi();
|
||||
},
|
||||
[blocks, clearDragUi, onBlocksReorder],
|
||||
);
|
||||
|
||||
return (
|
||||
<ul className="flex list-none flex-col gap-2 p-0" aria-label={listLabel}>
|
||||
{blocks.map((block, index) => {
|
||||
const kind = block.kind as AddCustomFieldType;
|
||||
const typeLabel = fieldTypeLabels[kind];
|
||||
const isOver = overIndex === index && draggingIndex !== index;
|
||||
return (
|
||||
<li
|
||||
key={block.id}
|
||||
className={`flex min-h-[52px] items-stretch gap-2 rounded-[var(--measures-radius-medium,8px)] border border-[var(--color-border-default-primary)] bg-[var(--color-surface-default-secondary)] pl-1 pr-3 py-2 transition-shadow ${
|
||||
isOver
|
||||
? "ring-2 ring-[var(--color-border-invert-primary)] ring-offset-2 ring-offset-[var(--color-surface-default-primary)]"
|
||||
: ""
|
||||
} ${draggingIndex === index ? "opacity-60" : ""}`}
|
||||
onDragOver={handleDragOver(index)}
|
||||
onDrop={handleDrop(index)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
draggable
|
||||
onDragStart={handleDragStart(index)}
|
||||
onDragEnd={clearDragUi}
|
||||
className="flex shrink-0 cursor-grab touch-manipulation items-center justify-center rounded-[var(--measures-radius-200,8px)] border-0 bg-transparent px-1 text-[var(--color-content-default-secondary)] active:cursor-grabbing focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)]"
|
||||
aria-label={dragHandleAriaLabel}
|
||||
>
|
||||
<DragHandleGlyph />
|
||||
</button>
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center self-center">
|
||||
<Icon
|
||||
name={ADD_CUSTOM_FIELD_TYPE_ICONS[kind]}
|
||||
size={24}
|
||||
className="text-[var(--color-content-default-brand-primary,#fefcc9)]"
|
||||
/>
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col justify-center gap-0.5">
|
||||
<span className="truncate font-inter text-[14px] font-medium leading-[18px] text-[var(--color-content-default-primary)]">
|
||||
{block.blockTitle.trim() || typeLabel}
|
||||
</span>
|
||||
<span className="font-inter text-[12px] leading-4 text-[var(--color-content-default-secondary)]">
|
||||
{typeLabel}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export const CustomMethodCardWizardBlocksListView = memo(
|
||||
CustomMethodCardWizardBlocksListViewComponent,
|
||||
);
|
||||
CustomMethodCardWizardBlocksListView.displayName =
|
||||
"CustomMethodCardWizardBlocksListView";
|
||||
+56
-4
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { getAssetPath } from "../../../../../lib/assetUtils";
|
||||
import InputWithCounter from "../../../../components/controls/InputWithCounter";
|
||||
import TextArea from "../../../../components/controls/TextArea";
|
||||
import TextInput from "../../../../components/controls/TextInput";
|
||||
@@ -28,11 +29,19 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
|
||||
onUploadBlockTitleChange,
|
||||
fileInputRef,
|
||||
onFileChosen,
|
||||
onClearPendingUpload,
|
||||
uploadAssetPreviewUrl = null,
|
||||
uploadPersisting = false,
|
||||
uploadBusyHint,
|
||||
uploadErrorMessage = null,
|
||||
proportionBlockTitle,
|
||||
proportionDefault,
|
||||
onProportionBlockTitleChange,
|
||||
onProportionDefaultChange,
|
||||
}: CustomMethodCardWizardFieldBodiesViewProps) {
|
||||
const uploadPreviewTrimmed = uploadAssetPreviewUrl?.trim() ?? "";
|
||||
const hasUploadPreview = uploadPreviewTrimmed.length > 0;
|
||||
|
||||
if (fieldType === "text") {
|
||||
return (
|
||||
<div className="flex flex-col gap-[var(--spacing-scale-024)]">
|
||||
@@ -120,10 +129,53 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
|
||||
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
|
||||
showHelpIcon
|
||||
/>
|
||||
<Upload
|
||||
hintText={copy.upload.uploadHint}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
/>
|
||||
{hasUploadPreview ? (
|
||||
<div className="relative inline-block max-w-full">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearPendingUpload}
|
||||
className="absolute right-[8px] top-[8px] z-[1] flex h-[32px] w-[32px] cursor-pointer items-center justify-center rounded-full bg-[var(--color-surface-default-secondary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-default-primary)]"
|
||||
aria-label={copy.upload.clearPendingUploadAriaLabel}
|
||||
title={copy.upload.clearPendingUploadTooltip}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- matches ModalHeader close control */}
|
||||
<img
|
||||
src={getAssetPath("assets/Icon_Close.svg")}
|
||||
alt=""
|
||||
className="h-[16px] w-[16px]"
|
||||
style={{
|
||||
filter: "brightness(0) invert(1)",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- blob or same-origin upload URL */}
|
||||
<img
|
||||
src={uploadPreviewTrimmed}
|
||||
alt={copy.upload.uploadPreviewImageAlt}
|
||||
className="max-h-[160px] max-w-full rounded-[var(--measures-radius-200,8px)] object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Upload
|
||||
active={!uploadPersisting}
|
||||
hintText={
|
||||
uploadPersisting && uploadBusyHint
|
||||
? uploadBusyHint
|
||||
: copy.upload.uploadHint
|
||||
}
|
||||
onClick={() => {
|
||||
if (!uploadPersisting) fileInputRef.current?.click();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{uploadErrorMessage ? (
|
||||
<p
|
||||
className="font-[family-name:var(--font-body)] text-[length:var(--font-size-body-s)] text-[var(--color-content-default-secondary)]"
|
||||
role="alert"
|
||||
>
|
||||
{uploadErrorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from "../../components/createFlowLayoutTokens";
|
||||
import { CommunicationMethodEditFields } from "../../components/methodEditFields";
|
||||
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
|
||||
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
|
||||
import { communicationPresetFor } from "../../../../../lib/create/finalReviewChipPresets";
|
||||
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
|
||||
import { mergePresetMethodsWithCustom } from "../../../../../lib/create/mergePresetMethodsWithCustom";
|
||||
@@ -146,6 +147,8 @@ export function CommunicationMethodsScreen() {
|
||||
const onCustomFieldBlocksChange = useCustomMethodCardFieldBlocksChange(
|
||||
createModalOpen ? pendingCardId : null,
|
||||
);
|
||||
const customModalReadOnly =
|
||||
pendingCardId !== null && selectedIds.includes(pendingCardId);
|
||||
|
||||
const handleCreateModalClose = useCallback(() => {
|
||||
setCreateModalOpen(false);
|
||||
@@ -293,7 +296,9 @@ export function CommunicationMethodsScreen() {
|
||||
key={pendingCardId}
|
||||
cardId={pendingCardId}
|
||||
blocksById={state.customMethodCardFieldBlocksById}
|
||||
onFieldBlocksChange={onCustomFieldBlocksChange}
|
||||
onFieldBlocksChange={
|
||||
customModalReadOnly ? undefined : onCustomFieldBlocksChange
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<CommunicationMethodEditFields
|
||||
@@ -309,6 +314,9 @@ export function CommunicationMethodsScreen() {
|
||||
isOpen={addCustomWizardOpen}
|
||||
onClose={handleCloseAddWizard}
|
||||
onFinalize={handleFinalizeCustomCard}
|
||||
onPersistCustomUploadFile={(file) =>
|
||||
uploadCreateFlowFile(file, "customMethodAttachment")
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from "../../components/createFlowLayoutTokens";
|
||||
import { ConflictManagementEditFields } from "../../components/methodEditFields";
|
||||
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
|
||||
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
|
||||
import { conflictManagementPresetFor } from "../../../../../lib/create/finalReviewChipPresets";
|
||||
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
|
||||
import { mergePresetMethodsWithCustom } from "../../../../../lib/create/mergePresetMethodsWithCustom";
|
||||
@@ -147,6 +148,8 @@ export function ConflictManagementScreen() {
|
||||
const onCustomFieldBlocksChange = useCustomMethodCardFieldBlocksChange(
|
||||
createModalOpen ? pendingCardId : null,
|
||||
);
|
||||
const customModalReadOnly =
|
||||
pendingCardId !== null && selectedIds.includes(pendingCardId);
|
||||
|
||||
const handleCreateModalClose = useCallback(() => {
|
||||
setCreateModalOpen(false);
|
||||
@@ -294,7 +297,9 @@ export function ConflictManagementScreen() {
|
||||
key={pendingCardId}
|
||||
cardId={pendingCardId}
|
||||
blocksById={state.customMethodCardFieldBlocksById}
|
||||
onFieldBlocksChange={onCustomFieldBlocksChange}
|
||||
onFieldBlocksChange={
|
||||
customModalReadOnly ? undefined : onCustomFieldBlocksChange
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ConflictManagementEditFields
|
||||
@@ -310,6 +315,9 @@ export function ConflictManagementScreen() {
|
||||
isOpen={addCustomWizardOpen}
|
||||
onClose={handleCloseAddWizard}
|
||||
onFinalize={handleFinalizeCustomCard}
|
||||
onPersistCustomUploadFile={(file) =>
|
||||
uploadCreateFlowFile(file, "customMethodAttachment")
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from "../../components/createFlowLayoutTokens";
|
||||
import { MembershipMethodEditFields } from "../../components/methodEditFields";
|
||||
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
|
||||
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
|
||||
import { membershipPresetFor } from "../../../../../lib/create/finalReviewChipPresets";
|
||||
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
|
||||
import { mergePresetMethodsWithCustom } from "../../../../../lib/create/mergePresetMethodsWithCustom";
|
||||
@@ -144,6 +145,8 @@ export function MembershipMethodsScreen() {
|
||||
const onCustomFieldBlocksChange = useCustomMethodCardFieldBlocksChange(
|
||||
createModalOpen ? pendingCardId : null,
|
||||
);
|
||||
const customModalReadOnly =
|
||||
pendingCardId !== null && selectedIds.includes(pendingCardId);
|
||||
|
||||
const handleCreateModalClose = useCallback(() => {
|
||||
setCreateModalOpen(false);
|
||||
@@ -287,7 +290,9 @@ export function MembershipMethodsScreen() {
|
||||
key={pendingCardId}
|
||||
cardId={pendingCardId}
|
||||
blocksById={state.customMethodCardFieldBlocksById}
|
||||
onFieldBlocksChange={onCustomFieldBlocksChange}
|
||||
onFieldBlocksChange={
|
||||
customModalReadOnly ? undefined : onCustomFieldBlocksChange
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<MembershipMethodEditFields
|
||||
@@ -303,6 +308,9 @@ export function MembershipMethodsScreen() {
|
||||
isOpen={addCustomWizardOpen}
|
||||
onClose={handleCloseAddWizard}
|
||||
onFinalize={handleFinalizeCustomCard}
|
||||
onPersistCustomUploadFile={(file) =>
|
||||
uploadCreateFlowFile(file, "customMethodAttachment")
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -86,6 +86,12 @@ export function CommunityReviewScreen() {
|
||||
? state.communityContext.trim()
|
||||
: undefined;
|
||||
|
||||
const avatarUrl =
|
||||
typeof state.communityAvatarUrl === "string" &&
|
||||
state.communityAvatarUrl.trim().length > 0
|
||||
? state.communityAvatarUrl.trim()
|
||||
: null;
|
||||
|
||||
return (
|
||||
<CreateFlowStepShell
|
||||
variant="wideGridLoosePadding"
|
||||
@@ -109,7 +115,9 @@ export function CommunityReviewScreen() {
|
||||
size={lgUp ? "L" : "M"}
|
||||
expanded={false}
|
||||
backgroundColor="bg-[var(--color-teal-teal50,#c9fef9)]"
|
||||
logoUrl={getAssetPath(vectorMarkPath("mutual-aid"))}
|
||||
logoUrl={
|
||||
avatarUrl ?? getAssetPath(vectorMarkPath("mutual-aid"))
|
||||
}
|
||||
logoAlt={cardTitle}
|
||||
className="rounded-[24px]"
|
||||
/>
|
||||
|
||||
@@ -30,6 +30,7 @@ import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering
|
||||
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
|
||||
import { DecisionApproachEditFields } from "../../components/methodEditFields";
|
||||
import CustomMethodCardWizard from "../../components/CustomMethodCardWizard";
|
||||
import { uploadCreateFlowFile } from "../../../../../lib/create/uploadToServer";
|
||||
import { decisionApproachPresetFor } from "../../../../../lib/create/finalReviewChipPresets";
|
||||
import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks";
|
||||
import { mergePresetMethodsWithCustom } from "../../../../../lib/create/mergePresetMethodsWithCustom";
|
||||
@@ -143,6 +144,8 @@ export function DecisionApproachesScreen() {
|
||||
const onCustomFieldBlocksChange = useCustomMethodCardFieldBlocksChange(
|
||||
createModalOpen ? pendingCardId : null,
|
||||
);
|
||||
const customModalReadOnly =
|
||||
pendingCardId !== null && selectedIds.includes(pendingCardId);
|
||||
|
||||
const handleToggleExpand = useCallback(() => {
|
||||
markCreateFlowInteraction();
|
||||
@@ -333,7 +336,9 @@ export function DecisionApproachesScreen() {
|
||||
key={pendingCardId}
|
||||
cardId={pendingCardId}
|
||||
blocksById={state.customMethodCardFieldBlocksById}
|
||||
onFieldBlocksChange={onCustomFieldBlocksChange}
|
||||
onFieldBlocksChange={
|
||||
customModalReadOnly ? undefined : onCustomFieldBlocksChange
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<DecisionApproachEditFields
|
||||
@@ -349,6 +354,9 @@ export function DecisionApproachesScreen() {
|
||||
isOpen={addCustomWizardOpen}
|
||||
onClose={handleCloseAddWizard}
|
||||
onFinalize={handleFinalizeCustomCard}
|
||||
onPersistCustomUploadFile={(file) =>
|
||||
uploadCreateFlowFile(file, "customMethodAttachment")
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,21 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
} from "react";
|
||||
import Upload from "../../../../components/controls/Upload";
|
||||
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||
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 { getAssetPath } from "../../../../../lib/assetUtils";
|
||||
import {
|
||||
UploadToServerError,
|
||||
uploadCreateFlowFile,
|
||||
} from "../../../../../lib/create/uploadToServer";
|
||||
import {
|
||||
clearPendingCommunityAvatarFile,
|
||||
storePendingCommunityAvatarFile,
|
||||
} from "../../../../../lib/create/pendingCommunityAvatarUpload";
|
||||
|
||||
/** Create Community — Figma Flow — Upload `20094:41524`. */
|
||||
export function CommunityUploadScreen() {
|
||||
const m = useMessages();
|
||||
const u = m.create.community.communityUpload;
|
||||
const { markCreateFlowInteraction } = useCreateFlow();
|
||||
const { markCreateFlowInteraction, state, updateState } = useCreateFlow();
|
||||
const tUpload = useTranslation("create.upload");
|
||||
|
||||
const handleUploadClick = () => {
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [signedIn, setSignedIn] = useState<boolean | null>(null);
|
||||
const [localPreviewUrl, setLocalPreviewUrl] = 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 resolveUploadError = useCallback(
|
||||
(err: unknown) => {
|
||||
if (err instanceof UploadToServerError) {
|
||||
if (err.status === 413) return tUpload("errors.tooLarge");
|
||||
if (err.status === 401) return tUpload("errors.unauthorized");
|
||||
if (err.code === "server_misconfigured") {
|
||||
return tUpload("errors.misconfigured");
|
||||
}
|
||||
}
|
||||
return tUpload("errors.generic");
|
||||
},
|
||||
[tUpload],
|
||||
);
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
markCreateFlowInteraction();
|
||||
setErrorMessage(null);
|
||||
|
||||
if (signedIn) {
|
||||
setBusy(true);
|
||||
try {
|
||||
const { url } = await uploadCreateFlowFile(file, "communityAvatar");
|
||||
setLocalPreviewUrl((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
return null;
|
||||
});
|
||||
updateState({ communityAvatarUrl: url });
|
||||
} catch (err) {
|
||||
setErrorMessage(resolveUploadError(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (signedIn === false) {
|
||||
try {
|
||||
await storePendingCommunityAvatarFile(file);
|
||||
setLocalPreviewUrl((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
return URL.createObjectURL(file);
|
||||
});
|
||||
} catch {
|
||||
setErrorMessage(tUpload("errors.generic"));
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
markCreateFlowInteraction,
|
||||
resolveUploadError,
|
||||
signedIn,
|
||||
tUpload,
|
||||
updateState,
|
||||
],
|
||||
);
|
||||
|
||||
const handleClearPendingUpload = useCallback(() => {
|
||||
markCreateFlowInteraction();
|
||||
};
|
||||
setErrorMessage(null);
|
||||
setLocalPreviewUrl((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
return null;
|
||||
});
|
||||
if (
|
||||
typeof state.communityAvatarUrl === "string" &&
|
||||
state.communityAvatarUrl.trim().length > 0
|
||||
) {
|
||||
updateState({ communityAvatarUrl: undefined });
|
||||
}
|
||||
// Clear any anonymous staged blob so the post-sign-in flush won't resurrect it.
|
||||
void clearPendingCommunityAvatarFile();
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
}, [markCreateFlowInteraction, state.communityAvatarUrl, updateState]);
|
||||
|
||||
const displaySrc =
|
||||
typeof state.communityAvatarUrl === "string" &&
|
||||
state.communityAvatarUrl.trim().length > 0
|
||||
? state.communityAvatarUrl.trim()
|
||||
: localPreviewUrl;
|
||||
const hasPreview = typeof displaySrc === "string" && displaySrc.length > 0;
|
||||
|
||||
return (
|
||||
<CreateFlowStepShell
|
||||
@@ -32,13 +156,65 @@ export function CommunityUploadScreen() {
|
||||
justification="center"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<Upload
|
||||
active={true}
|
||||
showHelpIcon={false}
|
||||
hintText={u.hintText}
|
||||
onClick={handleUploadClick}
|
||||
/>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="sr-only"
|
||||
tabIndex={-1}
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
aria-label={u.hintText}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<div className="flex w-full flex-col items-center gap-3">
|
||||
{hasPreview ? (
|
||||
<div className="relative inline-block max-w-full">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearPendingUpload}
|
||||
className="absolute right-[8px] top-[8px] z-[1] flex h-[32px] w-[32px] cursor-pointer items-center justify-center rounded-full bg-[var(--color-surface-default-secondary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-default-primary)]"
|
||||
aria-label={u.clearPendingUploadAriaLabel}
|
||||
title={u.clearPendingUploadTooltip}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- matches ModalHeader close control */}
|
||||
<img
|
||||
src={getAssetPath("assets/Icon_Close.svg")}
|
||||
alt=""
|
||||
className="h-[16px] w-[16px]"
|
||||
style={{
|
||||
filter: "brightness(0) invert(1)",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- user/device file or same-origin upload URL */}
|
||||
<img
|
||||
src={displaySrc ?? ""}
|
||||
alt={u.previewAlt}
|
||||
className="max-h-[200px] max-w-full rounded-[var(--measures-radius-200,8px)] object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Upload
|
||||
active={!busy}
|
||||
showHelpIcon={false}
|
||||
hintText={busy ? u.uploadingLabel : u.hintText}
|
||||
onClick={() => {
|
||||
if (!busy) fileInputRef.current?.click();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{signedIn === false ? (
|
||||
<p className="max-w-[474px] text-center font-[family-name:var(--font-body)] text-[length:var(--font-size-body-s)] text-[var(--color-content-default-tertiary)]">
|
||||
{u.signInToUploadNote}
|
||||
</p>
|
||||
) : null}
|
||||
{errorMessage ? (
|
||||
<p
|
||||
className="max-w-[474px] text-center font-[family-name:var(--font-body)] text-[length:var(--font-size-body-s)] text-[var(--color-content-default-secondary)]"
|
||||
role="alert"
|
||||
>
|
||||
{errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</CreateFlowStepShell>
|
||||
|
||||
@@ -108,6 +108,11 @@ export interface CreateFlowState {
|
||||
communityContext?: string;
|
||||
/** Email collected on the “Save your progress” step (Figma Flow — Text `20097:14948`). */
|
||||
communitySaveEmail?: string;
|
||||
/**
|
||||
* Public app path for the uploaded community image (e.g. `/api/uploads/{uuid}`).
|
||||
* Set after successful `POST /api/uploads` with purpose `communityAvatar`.
|
||||
*/
|
||||
communityAvatarUrl?: string;
|
||||
/** Selected chip ids from `community-size` (MultiSelect). */
|
||||
selectedCommunitySizeIds?: string[];
|
||||
/** Selected chip ids from `community-structure` (organization types). */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { CreateFlowState } from "../types";
|
||||
import { migrateLegacyCreateFlowState } from "../../../../lib/create/migrateLegacyCreateFlowState";
|
||||
import { clearPendingCommunityAvatarFile } from "../../../../lib/create/pendingCommunityAvatarUpload";
|
||||
|
||||
/** Anonymous in-progress create flow (local only until magic-link transfer). */
|
||||
export const CREATE_FLOW_ANONYMOUS_KEY = "create-flow-anonymous" as const;
|
||||
@@ -53,6 +54,7 @@ export function clearAnonymousCreateFlowStorage(): void {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
void clearPendingCommunityAvatarFile();
|
||||
}
|
||||
|
||||
export function setTransferPendingFlag(): void {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiRoute } from "../../../../lib/server/apiRoute";
|
||||
import {
|
||||
notFound,
|
||||
serverMisconfigured,
|
||||
} from "../../../../lib/server/responses";
|
||||
import { resolveUploadedFileById } from "../../../../lib/server/uploads/resolveUploadedFile";
|
||||
import { getUploadRootFromEnv } from "../../../../lib/server/uploads/uploadRoot";
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
/**
|
||||
* Public read for opaque upload ids (no auth). Unguessable UUID stem;
|
||||
* do not use for sensitive documents without revisiting policy.
|
||||
*/
|
||||
export const GET = apiRoute<RouteContext>(
|
||||
"uploads.byId",
|
||||
async (_request, context) => {
|
||||
if (!getUploadRootFromEnv()) {
|
||||
return serverMisconfigured(
|
||||
"File uploads are not configured (UPLOAD_ROOT is unset).",
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = await context.params;
|
||||
const resolved = await resolveUploadedFileById(id);
|
||||
if (!resolved) {
|
||||
return notFound("Upload not found");
|
||||
}
|
||||
|
||||
const body = await readFile(resolved.absolutePath);
|
||||
return new NextResponse(new Uint8Array(body), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": resolved.contentType,
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,111 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { isDatabaseConfigured } from "../../../lib/server/env";
|
||||
import {
|
||||
dbUnavailable,
|
||||
errorJson,
|
||||
serverMisconfigured,
|
||||
unauthorized,
|
||||
rateLimited,
|
||||
} from "../../../lib/server/responses";
|
||||
import { getSessionUser } from "../../../lib/server/session";
|
||||
import { apiRoute } from "../../../lib/server/apiRoute";
|
||||
import { rateLimitKey } from "../../../lib/server/rateLimit";
|
||||
import { saveCreateFlowUpload } from "../../../lib/server/uploads/saveCreateFlowUpload";
|
||||
import { getUploadRootFromEnv } from "../../../lib/server/uploads/uploadRoot";
|
||||
import {
|
||||
CREATE_FLOW_UPLOAD_MAX_BYTES,
|
||||
type CreateFlowUploadPurpose,
|
||||
} from "../../../lib/server/uploads/uploadConstants";
|
||||
|
||||
function isPurpose(x: string): x is CreateFlowUploadPurpose {
|
||||
return x === "communityAvatar" || x === "customMethodAttachment";
|
||||
}
|
||||
|
||||
export const POST = apiRoute("uploads.post", async (request: NextRequest) => {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return dbUnavailable();
|
||||
}
|
||||
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
if (!getUploadRootFromEnv()) {
|
||||
return serverMisconfigured(
|
||||
"File uploads are not configured (UPLOAD_ROOT is unset).",
|
||||
);
|
||||
}
|
||||
|
||||
const rl = rateLimitKey(`upload:${user.id}`, 5_000);
|
||||
if (rl.ok === false) {
|
||||
return rateLimited(rl.retryAfterMs);
|
||||
}
|
||||
|
||||
let formData: FormData;
|
||||
try {
|
||||
formData = await request.formData();
|
||||
} catch {
|
||||
return errorJson(
|
||||
"payload_too_large",
|
||||
"Upload body is too large or malformed.",
|
||||
413,
|
||||
);
|
||||
}
|
||||
|
||||
const purposeRaw = formData.get("purpose");
|
||||
const file = formData.get("file");
|
||||
|
||||
if (typeof purposeRaw !== "string" || !isPurpose(purposeRaw)) {
|
||||
return errorJson(
|
||||
"validation_error",
|
||||
"Invalid or missing `purpose` (expected communityAvatar | customMethodAttachment).",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
if (!(file instanceof File)) {
|
||||
return errorJson(
|
||||
"validation_error",
|
||||
"Missing `file` field (multipart file).",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
if (file.size > CREATE_FLOW_UPLOAD_MAX_BYTES) {
|
||||
return errorJson(
|
||||
"payload_too_large",
|
||||
`File exceeds maximum allowed size (${CREATE_FLOW_UPLOAD_MAX_BYTES} bytes).`,
|
||||
413,
|
||||
);
|
||||
}
|
||||
|
||||
const buf = Buffer.from(await file.arrayBuffer());
|
||||
const mimeType = file.type || "application/octet-stream";
|
||||
|
||||
const saved = await saveCreateFlowUpload({
|
||||
purpose: purposeRaw,
|
||||
buffer: buf,
|
||||
mimeType,
|
||||
});
|
||||
|
||||
if ("error" in saved) {
|
||||
if (saved.error === "misconfigured") {
|
||||
return serverMisconfigured(
|
||||
"File uploads are not configured (UPLOAD_ROOT is unset).",
|
||||
);
|
||||
}
|
||||
return errorJson(
|
||||
"validation_error",
|
||||
"File type or size is not allowed for this upload purpose.",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
url: saved.urlPath,
|
||||
id: saved.id,
|
||||
mimeType: saved.mimeType,
|
||||
byteLength: saved.byteLength,
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,15 @@
|
||||
import type { IconName } from "../../asset/icon";
|
||||
|
||||
export type AddCustomFieldType = "text" | "badges" | "upload" | "proportion";
|
||||
|
||||
/** Icons for each addable field type (wizard + summaries). */
|
||||
export const ADD_CUSTOM_FIELD_TYPE_ICONS = {
|
||||
text: "text_block",
|
||||
badges: "tags",
|
||||
upload: "image",
|
||||
proportion: "number",
|
||||
} as const satisfies Record<AddCustomFieldType, IconName>;
|
||||
|
||||
export interface AddCustomFieldProps {
|
||||
/** When true, show the 2×2 field-type grid; when false, show the primary CTA. */
|
||||
active: boolean;
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import Icon, { type IconName } from "../../asset/icon";
|
||||
import Icon from "../../asset/icon";
|
||||
import Vertical from "../../buttons/Vertical";
|
||||
import type {
|
||||
AddCustomFieldType,
|
||||
AddCustomFieldViewProps,
|
||||
import {
|
||||
ADD_CUSTOM_FIELD_TYPE_ICONS,
|
||||
type AddCustomFieldType,
|
||||
type AddCustomFieldViewProps,
|
||||
} from "./AddCustomField.types";
|
||||
|
||||
const FIELD_TYPE_ICONS: Record<AddCustomFieldType, IconName> = {
|
||||
text: "text_block",
|
||||
badges: "tags", // tag / chip list (filename: tags.svg)
|
||||
upload: "image", // image / file upload (filename: image.svg)
|
||||
proportion: "number", // numeric / proportion field (closest asset: number.svg)
|
||||
};
|
||||
|
||||
function FieldTypeButton({
|
||||
type,
|
||||
label,
|
||||
@@ -32,7 +26,7 @@ function FieldTypeButton({
|
||||
>
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center">
|
||||
<Icon
|
||||
name={FIELD_TYPE_ICONS[type]}
|
||||
name={ADD_CUSTOM_FIELD_TYPE_ICONS[type]}
|
||||
size={32}
|
||||
className="text-[var(--color-content-default-brand-primary,#fefcc9)]"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user