Add button and custom modal flow implemented
This commit is contained in:
@@ -20,3 +20,7 @@ NEXT_PUBLIC_ENABLE_BACKEND_SYNC=
|
||||
|
||||
# Optional: URL shown on /monitor when using external storage (Grafana, Kibana, vendor RUM, etc.).
|
||||
# NEXT_PUBLIC_RUM_DASHBOARD_URL=
|
||||
|
||||
# Writable directory for `POST /api/uploads` (community photo + custom-method attachments).
|
||||
# In production (e.g. Cloudron localstorage mount), set to the mounted path. Local dev example:
|
||||
# UPLOAD_ROOT="/absolute/path/to/community-rule/var/uploads"
|
||||
|
||||
@@ -17,6 +17,9 @@ npm-cache/
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# Local user uploads (see UPLOAD_ROOT in .env.example)
|
||||
/var/uploads
|
||||
|
||||
# Playwright
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
|
||||
@@ -40,6 +40,8 @@ deployment-pipeline work.
|
||||
| GET | `/api/auth/magic-link/verify` | Validate token, set cookie, redirect. |
|
||||
| POST | `/api/auth/logout` | Clear session. |
|
||||
| GET / PUT | `/api/drafts/me` | Load or save the create-flow draft. |
|
||||
| POST | `/api/uploads` | Authenticated multipart upload (create-flow images / PDFs); requires `UPLOAD_ROOT`. |
|
||||
| GET | `/api/uploads/[id]` | Stream a previously uploaded file by opaque id (public read). |
|
||||
| GET / POST | `/api/rules` | List or publish rules. |
|
||||
| GET | `/api/templates` | List curated templates. Optional repeatable `facet.<group>=<value>` query params re-rank results (and may include `scores` in the JSON). See [docs/guides/template-recommendation-matrix.md](docs/guides/template-recommendation-matrix.md) §9.1. |
|
||||
| GET | `/api/create-flow/methods` | Facet-aware scores for custom-rule card steps: required `section` (`communication` \| `membership` \| `decisionApproaches` \| `conflictManagement`) and optional `facet.*` params (same facet groups as `/api/templates`). Returns `methods` with match metadata for re-ordering in the wizard. |
|
||||
|
||||
@@ -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)]"
|
||||
/>
|
||||
|
||||
@@ -493,6 +493,43 @@ _Section B — Final Review screen `+` button per category:_
|
||||
|
||||
---
|
||||
|
||||
## Ticket 21 — Create flow file uploads (community photo + custom method attachments)
|
||||
|
||||
**Depends on:** **Ticket 3 / [CR-74](https://linear.app/community-rule/issue/CR-74/backend-magic-link-sign-in-ui-apis-ticket-3-cr-75-done)** (session for authenticated routes); **Ticket 5 / [CR-76](https://linear.app/community-rule/issue/CR-76/backend-harden-server-draft-sync-save-and-exit-post-login-transfer)** (draft JSON carries new URL fields). **Related:** **[CR-58](https://linear.app/community-rule/issue/CR-58)** (Figma Avatar / broader upload routing — may share `/api/uploads` later).
|
||||
|
||||
**Server / admin:** Persist uploads on disk under **`UPLOAD_ROOT`** (document in [`.env.example`](../../.env.example)); production aligns with Cloudron **localstorage** mount ([`docs/guides/ops-backend-deploy.md`](ops-backend-deploy.md)). **Not** storing binaries inside `RuleDraft.payload` / publish JSON — only **HTTPS-relative URLs** (or opaque ids served by the app).
|
||||
|
||||
**Goal:** Replace **filename-only** / **stub** upload UX with real files for (1) **Create Community** `community-upload` and (2) **custom method** wizard / modal **upload** field blocks. Draft and publish JSON carry stable URLs like other strings.
|
||||
|
||||
**Context (repo today):**
|
||||
|
||||
- [`CommunityUploadScreen.tsx`](../../app/(app)/create/screens/upload/CommunityUploadScreen.tsx) has no `<input type="file">` or `CreateFlowState` image field.
|
||||
- [`lib/create/customMethodCardFieldBlocks.ts`](../../lib/create/customMethodCardFieldBlocks.ts) upload blocks have optional **`fileName`** only.
|
||||
- **Anonymous uploads:** `POST` must stay **`getSessionUser()` required** (same as [`PUT /api/drafts/me`](../../app/api/drafts/me/route.ts)). **Do not** ship unauthenticated multipart without abuse controls. **Recommended:** client **staging** (prefer **IndexedDB**) + **flush** after session via one helper (e.g. after [`PostLoginDraftTransfer`](../../app/(app)/create/PostLoginDraftTransfer.tsx) / session-ready); **alt:** gate picker until signed in.
|
||||
|
||||
**Implementation (sketch):**
|
||||
|
||||
1. **`lib/server/uploads/`** — save stream to UUID filename under `UPLOAD_ROOT`; resolve path safely (no traversal).
|
||||
2. **`POST /api/uploads`** — `apiRoute`, multipart `formData`, `purpose` enum (`communityAvatar` | `customMethodAttachment` or similar), MIME + size allowlists, optional rate limit alignment with magic-link patterns.
|
||||
3. **`GET /api/uploads/[id]`** (or equivalent) — stream file by opaque id; document **public vs session-gated** read policy for v1.
|
||||
4. **Client:** thin `uploadCreateFlowFile(file, purpose)` (e.g. under `lib/create/`); i18n for errors; loading UX per alerts rules.
|
||||
5. **State + Zod:** `communityAvatarUrl` (name TBD) on [`CreateFlowState`](../../app/(app)/create/types.ts); upload blocks gain **`assetUrl`** (keep `fileName` for display); [`createFlowSchemas.ts`](../../lib/server/validation/createFlowSchemas.ts) updated.
|
||||
6. **UI:** wire [`CommunityUploadScreen`](../../app/(app)/create/screens/upload/CommunityUploadScreen.tsx); wizard + [`CustomMethodCardFieldBlocksSummary`](../../app/(app)/create/components/CustomMethodCardFieldBlocksSummary.tsx).
|
||||
7. **Publish:** extend [`buildPublishPayload.ts`](../../lib/create/buildPublishPayload.ts) / `document` **if** product needs URLs for readers; else TODO in ticket / PR.
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- [ ] Signed-in user completes upload on community step; URL persists in draft when sync is on.
|
||||
- [ ] Custom-method upload block stores `assetUrl` after upload; visible in modal / final review paths.
|
||||
- [ ] Anonymous flow does not claim server upload without session; agreed staging strategy works or picker is gated with copy.
|
||||
- [ ] Unit tests: validation, path safety; targeted Vitest for happy path where practical.
|
||||
|
||||
**Linear:** [CR-113](https://linear.app/community-rule/issue/CR-113/backend-create-flow-file-uploads-community-photo-custom-method) (**Backlog**).
|
||||
|
||||
**Internal plan:** Cursor plan `create_flow_real_uploads_ebeecca5.plan.md` (workspace `.cursor/plans/`) expands sequence diagrams and rollout order.
|
||||
|
||||
---
|
||||
|
||||
## Ticket 9 — Persist web vitals outside `.next` (prefer external RUM)
|
||||
|
||||
**Depends on:** none (orthogonal).
|
||||
@@ -732,16 +769,17 @@ All six are titled `[Backend] …`, assigned to Vinod, in the **community-rule**
|
||||
| 18 | 18 | Stakeholder invites (confirm-stakeholders) |
|
||||
| 19 | 19 | `Add` button behavior (custom-rule pages + Final Review) |
|
||||
| 20 | 20 | Change account email (verified) **Backlog — CR-103** |
|
||||
| 21 | 21 | Create flow file uploads **Backlog — [CR-113](https://linear.app/community-rule/issue/CR-113/backend-create-flow-file-uploads-community-photo-custom-method)** |
|
||||
|
||||
**Follow-up (no doc ticket #):** **[CR-93](https://linear.app/community-rule/issue/CR-93/product-rank-template-cards-by-community-facets-reuse-get-apitemplates)** — marketing template grids ranked by user facets (API-ready; tests deferred with that issue).
|
||||
|
||||
Tickets **10–11** can be deferred without blocking the core “auth + drafts + publish + templates” vertical slice. **Ticket 6 / CR-77** (publish) is **Done**. **Ticket 16** / **CR-88** (facet data + APIs + wizard method ranking) shipped **after 7–8**; **CR-93** tracks **marketing** template grids ranked by user facets (API-ready). **Ticket 17** / **CR-89** (**[Done](https://linear.app/community-rule/issue/CR-89/product-canon-custom-create-rule-wizard-routes-resume-progress-repo)**) canonizes the **custom** wizard in [`docs/create-flow.md`](create-flow.md) (progress bar, `[screenId]` routing). **Draft resume / hydration** follow-ups: **CR-86**. **Tickets 13–14** are parallel (**CR-84** / **CR-85** — both **Done**). **Ticket 15 / CR-86** is **parallel** (publish prerequisite met); implementation backlog. **Ticket 20 / [CR-103](https://linear.app/community-rule/issue/CR-103/backend-change-account-email-verify-new-address-conflict-session)** tracks **verified change account email** (split from Ticket 15). **Ticket 18** (**[CR-90](https://linear.app/community-rule/issue/CR-90/productbackend-invite-stakeholders-email-from-confirm-stakeholders)**) adds real **email-based stakeholder invites** to the `confirm-stakeholders` step — currently ships as a label-only chip list despite copy promising invites; **parallel** to the main chain, awaits design + product brief before implementation. **Ticket 19** (**[CR-91](https://linear.app/community-rule/issue/CR-91/productdesign-add-button-behavior-on-custom-rule-pages-and-final)**) is a **product/design** clarification ticket: the `Add` affordance is inconsistent across custom-rule pages (full custom-chip flow only on `core-values`; an `add` link that just expands the card stack on the four card-style pages) and the Final Review screen renders a `+` button per category that today is a no-op; needs a brief + Figma before any implementation lands.
|
||||
Tickets **10–11** can be deferred without blocking the core “auth + drafts + publish + templates” vertical slice. **Ticket 6 / CR-77** (publish) is **Done**. **Ticket 16** / **CR-88** (facet data + APIs + wizard method ranking) shipped **after 7–8**; **CR-93** tracks **marketing** template grids ranked by user facets (API-ready). **Ticket 17** / **CR-89** (**[Done](https://linear.app/community-rule/issue/CR-89/product-canon-custom-create-rule-wizard-routes-resume-progress-repo)**) canonizes the **custom** wizard in [`docs/create-flow.md`](create-flow.md) (progress bar, `[screenId]` routing). **Draft resume / hydration** follow-ups: **CR-86**. **Tickets 13–14** are parallel (**CR-84** / **CR-85** — both **Done**). **Ticket 15 / CR-86** is **parallel** (publish prerequisite met); implementation backlog. **Ticket 20 / [CR-103](https://linear.app/community-rule/issue/CR-103/backend-change-account-email-verify-new-address-conflict-session)** tracks **verified change account email** (split from Ticket 15). **Ticket 18** (**[CR-90](https://linear.app/community-rule/issue/CR-90/productbackend-invite-stakeholders-email-from-confirm-stakeholders)**) adds real **email-based stakeholder invites** to the `confirm-stakeholders` step — currently ships as a label-only chip list despite copy promising invites; **parallel** to the main chain, awaits design + product brief before implementation. **Ticket 19** (**[CR-91](https://linear.app/community-rule/issue/CR-91/productdesign-add-button-behavior-on-custom-rule-pages-and-final)**) is a **product/design** clarification ticket: the `Add` affordance is inconsistent across custom-rule pages (full custom-chip flow only on `core-values`; an `add` link that just expands the card stack on the four card-style pages) and the Final Review screen renders a `+` button per category that today is a no-op; needs a brief + Figma before any implementation lands. **Ticket 21** (**[CR-113](https://linear.app/community-rule/issue/CR-113/backend-create-flow-file-uploads-community-photo-custom-method)**) — create-flow **authenticated file uploads** for community photo + custom-method attachment blocks; see Ticket 21 body in this doc. Implementation follows [`docs/guides/ops-backend-deploy.md`](ops-backend-deploy.md) localstorage guidance and avoids unauthenticated multipart in v1.
|
||||
|
||||
---
|
||||
|
||||
## Linear (Community-rule team)
|
||||
|
||||
**Main chain (historical):** **CR-72 → CR-83** was the original **strict sequence**; **repo + Linear status today:** **CR-72–CR-79**, **CR-83**, **CR-84**, **CR-85**, **CR-88**, **CR-89** are **Done**; **CR-77** (publish) **Done**; **CR-80–CR-81** remain **Backlog** (web vitals, public rule detail). **CR-82** covered by local `migrate:smoke` (see Ticket 11). **CR-83** (admin handoff) shipped as a narrow handoff sheet; the actual Cloudron deployment pipeline is split into the **`[Backend]` follow-up tickets** filed under it (env-var bridging → image registry → staging → production cutover → operator runbook → legacy decommission). **Parallel (still open):** **CR-86** / Ticket 15 (**Backlog** — publish **not** a blocker); **CR-103** / Ticket 20 (change account email); **CR-93** (**Backlog**); **CR-90** / Ticket 18 (stakeholder invites); **CR-91** / Ticket 19 (`Add` button behavior).
|
||||
**Main chain (historical):** **CR-72 → CR-83** was the original **strict sequence**; **repo + Linear status today:** **CR-72–CR-79**, **CR-83**, **CR-84**, **CR-85**, **CR-88**, **CR-89** are **Done**; **CR-77** (publish) **Done**; **CR-80–CR-81** remain **Backlog** (web vitals, public rule detail). **CR-82** covered by local `migrate:smoke` (see Ticket 11). **CR-83** (admin handoff) shipped as a narrow handoff sheet; the actual Cloudron deployment pipeline is split into the **`[Backend]` follow-up tickets** filed under it (env-var bridging → image registry → staging → production cutover → operator runbook → legacy decommission). **Parallel (still open):** **CR-86** / Ticket 15 (**Backlog** — publish **not** a blocker); **CR-103** / Ticket 20 (change account email); **CR-93** (**Backlog**); **CR-90** / Ticket 18 (stakeholder invites); **CR-91** / Ticket 19 (`Add` button behavior); **Ticket 21 / [CR-113](https://linear.app/community-rule/issue/CR-113/backend-create-flow-file-uploads-community-photo-custom-method)** (create-flow file uploads).
|
||||
|
||||
| Doc ticket | Linear | Title (short) |
|
||||
| ---------: | --------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- |
|
||||
@@ -773,6 +811,7 @@ Tickets **10–11** can be deferred without blocking the core “auth + drafts +
|
||||
| 18 | [CR-90](https://linear.app/community-rule/issue/CR-90/productbackend-invite-stakeholders-email-from-confirm-stakeholders) | Stakeholder invites (confirm-stakeholders) |
|
||||
| 19 | [CR-91](https://linear.app/community-rule/issue/CR-91/productdesign-add-button-behavior-on-custom-rule-pages-and-final) | `Add` button behavior (custom-rule + Final Review) |
|
||||
| 20 | [CR-103](https://linear.app/community-rule/issue/CR-103/backend-change-account-email-verify-new-address-conflict-session) | Change account email (verify new address) **Backlog** |
|
||||
| 21 | [CR-113](https://linear.app/community-rule/issue/CR-113/backend-create-flow-file-uploads-community-photo-custom-method) | Create flow file uploads (community + custom blocks) **Backlog** |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -75,6 +75,10 @@ per-app in the manifest and provisioned at install time.
|
||||
with the legacy service; SES relay accepts it).
|
||||
- `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` — turns on Postgres draft
|
||||
persistence for signed-in users. Required in production.
|
||||
- `UPLOAD_ROOT` — absolute path to a writable directory (typically on the
|
||||
Cloudron **localstorage** mount) for `POST /api/uploads` (community photo +
|
||||
custom-method attachments). When unset, upload routes return
|
||||
`server_misconfigured`. See [CONTRIBUTING.md](../../CONTRIBUTING.md) API table.
|
||||
|
||||
## 4. Platform settings
|
||||
|
||||
|
||||
@@ -161,6 +161,20 @@ export function buildPublishPayload(
|
||||
document.methodSelections = methodSelections;
|
||||
}
|
||||
|
||||
const avatar =
|
||||
typeof state.communityAvatarUrl === "string" &&
|
||||
state.communityAvatarUrl.trim().length > 0
|
||||
? state.communityAvatarUrl.trim()
|
||||
: undefined;
|
||||
if (avatar) {
|
||||
document.communityAvatarUrl = avatar;
|
||||
}
|
||||
|
||||
const fieldBlocks = state.customMethodCardFieldBlocksById;
|
||||
if (fieldBlocks && Object.keys(fieldBlocks).length > 0) {
|
||||
document.customMethodCardFieldBlocksById = fieldBlocks;
|
||||
}
|
||||
|
||||
if (summary !== undefined) {
|
||||
return { ok: true, title, summary, document };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/** Multipart field `purpose` for `POST /api/uploads` — keep in sync with server validation. */
|
||||
export const CREATE_FLOW_UPLOAD_PURPOSES = [
|
||||
"communityAvatar",
|
||||
"customMethodAttachment",
|
||||
] as const;
|
||||
|
||||
export type CreateFlowUploadPurpose =
|
||||
(typeof CREATE_FLOW_UPLOAD_PURPOSES)[number];
|
||||
@@ -19,6 +19,8 @@ export type CustomMethodCardFieldBlock =
|
||||
id: string;
|
||||
blockTitle: string;
|
||||
fileName?: string;
|
||||
/** App path from `POST /api/uploads` (e.g. `/api/uploads/{uuid}`). */
|
||||
assetUrl?: string;
|
||||
}
|
||||
| {
|
||||
kind: "proportion";
|
||||
@@ -51,6 +53,7 @@ const customMethodUploadBlockSchema = z
|
||||
id: z.string().max(80),
|
||||
blockTitle: z.string().max(200),
|
||||
fileName: z.string().max(500).optional(),
|
||||
assetUrl: z.string().max(512).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* IndexedDB staging for community avatar when the user picks a file before
|
||||
* a session exists. Cleared after successful upload or explicit clear.
|
||||
*/
|
||||
|
||||
const DB_NAME = "community-rule-pending-uploads";
|
||||
const DB_VERSION = 1;
|
||||
const STORE = "communityAvatar";
|
||||
const KEY = "pending";
|
||||
|
||||
function openDb(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
req.onerror = () => reject(req.error ?? new Error("indexedDB open failed"));
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains(STORE)) {
|
||||
db.createObjectStore(STORE);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function storePendingCommunityAvatarFile(file: File): Promise<void> {
|
||||
const db = await openDb();
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, "readwrite");
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error ?? new Error("indexedDB write failed"));
|
||||
tx.objectStore(STORE).put(file, KEY);
|
||||
});
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** Read staged file without removing it (caller clears after successful upload). */
|
||||
export async function readPendingCommunityAvatarFile(): Promise<File | null> {
|
||||
const db = await openDb();
|
||||
try {
|
||||
return await new Promise<File | null>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, "readonly");
|
||||
tx.onerror = () => reject(tx.error ?? new Error("indexedDB read failed"));
|
||||
const getReq = tx.objectStore(STORE).get(KEY);
|
||||
getReq.onsuccess = () => {
|
||||
const v = getReq.result;
|
||||
resolve(v instanceof File ? v : null);
|
||||
};
|
||||
getReq.onerror = () => reject(getReq.error);
|
||||
});
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearPendingCommunityAvatarFile(): Promise<void> {
|
||||
if (typeof indexedDB === "undefined") return;
|
||||
const db = await openDb();
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, "readwrite");
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error ?? new Error("indexedDB clear failed"));
|
||||
tx.objectStore(STORE).delete(KEY);
|
||||
});
|
||||
} catch {
|
||||
// ignore missing DB / quota
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
@@ -113,6 +113,24 @@ export function createFlowStateFromPublishedRule(
|
||||
out.coreValueDetailsByChipId = coreValueDetailsByChipId;
|
||||
}
|
||||
|
||||
const avatarUrl =
|
||||
typeof doc.communityAvatarUrl === "string"
|
||||
? doc.communityAvatarUrl.trim()
|
||||
: "";
|
||||
if (avatarUrl.length > 0) {
|
||||
out.communityAvatarUrl = avatarUrl;
|
||||
}
|
||||
|
||||
const blocksRaw = doc.customMethodCardFieldBlocksById;
|
||||
if (
|
||||
blocksRaw &&
|
||||
typeof blocksRaw === "object" &&
|
||||
!Array.isArray(blocksRaw)
|
||||
) {
|
||||
out.customMethodCardFieldBlocksById =
|
||||
blocksRaw as NonNullable<CreateFlowState["customMethodCardFieldBlocksById"]>;
|
||||
}
|
||||
|
||||
const msRaw = doc.methodSelections;
|
||||
if (!msRaw || typeof msRaw !== "object" || Array.isArray(msRaw)) {
|
||||
out.sections = [];
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Immutable reorder for custom method card field blocks (wizard step 3, edit modal).
|
||||
*/
|
||||
export function reorderCustomMethodCardFieldBlocks<T>(
|
||||
blocks: readonly T[],
|
||||
fromIndex: number,
|
||||
toIndex: number,
|
||||
): T[] {
|
||||
if (fromIndex === toIndex) return [...blocks];
|
||||
if (fromIndex < 0 || toIndex < 0 || fromIndex >= blocks.length) {
|
||||
return [...blocks];
|
||||
}
|
||||
if (toIndex >= blocks.length) return [...blocks];
|
||||
const next = [...blocks];
|
||||
const [removed] = next.splice(fromIndex, 1);
|
||||
next.splice(toIndex, 0, removed);
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { CreateFlowUploadPurpose } from "./createFlowUploadPurpose";
|
||||
|
||||
export type UploadToServerResult = {
|
||||
url: string;
|
||||
id: string;
|
||||
mimeType: string;
|
||||
byteLength: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Authenticated multipart upload to `POST /api/uploads`.
|
||||
* Caller must have a session cookie (same-origin fetch).
|
||||
*/
|
||||
export async function uploadCreateFlowFile(
|
||||
file: File,
|
||||
purpose: CreateFlowUploadPurpose,
|
||||
): Promise<UploadToServerResult> {
|
||||
const formData = new FormData();
|
||||
formData.append("purpose", purpose);
|
||||
formData.append("file", file);
|
||||
|
||||
const res = await fetch("/api/uploads", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
credentials: "same-origin",
|
||||
});
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
body = {};
|
||||
}
|
||||
|
||||
const errParts = (() => {
|
||||
if (body && typeof body === "object" && "error" in body) {
|
||||
const e = (body as {
|
||||
error?: { message?: string; code?: string };
|
||||
}).error;
|
||||
if (!e) return { message: null as string | null, code: null as string | null };
|
||||
return {
|
||||
message: typeof e.message === "string" ? e.message : null,
|
||||
code: typeof e.code === "string" ? e.code : null,
|
||||
};
|
||||
}
|
||||
return { message: null, code: null };
|
||||
})();
|
||||
|
||||
if (!res.ok) {
|
||||
const fallback =
|
||||
res.status === 413
|
||||
? "FILE_TOO_LARGE"
|
||||
: res.status === 401
|
||||
? "UNAUTHORIZED"
|
||||
: "UPLOAD_FAILED";
|
||||
const code = errParts.code ?? errParts.message ?? fallback;
|
||||
throw new UploadToServerError(res.status, code);
|
||||
}
|
||||
|
||||
const data = body as {
|
||||
url?: string;
|
||||
id?: string;
|
||||
mimeType?: string;
|
||||
byteLength?: number;
|
||||
};
|
||||
if (
|
||||
typeof data.url !== "string" ||
|
||||
typeof data.id !== "string" ||
|
||||
typeof data.mimeType !== "string" ||
|
||||
typeof data.byteLength !== "number"
|
||||
) {
|
||||
throw new UploadToServerError(res.status, "INVALID_RESPONSE");
|
||||
}
|
||||
|
||||
return {
|
||||
url: data.url,
|
||||
id: data.id,
|
||||
mimeType: data.mimeType,
|
||||
byteLength: data.byteLength,
|
||||
};
|
||||
}
|
||||
|
||||
export class UploadToServerError extends Error {
|
||||
readonly status: number;
|
||||
readonly code: string;
|
||||
|
||||
constructor(status: number, code: string) {
|
||||
super(code);
|
||||
this.name = "UploadToServerError";
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import { access, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { getUploadRootFromEnv } from "./uploadRoot";
|
||||
import { isValidUploadFileId } from "./uploadConstants";
|
||||
|
||||
export type ResolvedUploadFile = {
|
||||
absolutePath: string;
|
||||
/** MIME inferred from extension (no sidecar file in v1). */
|
||||
contentType: string;
|
||||
};
|
||||
|
||||
function contentTypeForFilename(fileName: string): string {
|
||||
const ext = path.extname(fileName).toLowerCase();
|
||||
switch (ext) {
|
||||
case ".jpg":
|
||||
case ".jpeg":
|
||||
return "image/jpeg";
|
||||
case ".png":
|
||||
return "image/png";
|
||||
case ".webp":
|
||||
return "image/webp";
|
||||
case ".gif":
|
||||
return "image/gif";
|
||||
case ".pdf":
|
||||
return "application/pdf";
|
||||
default:
|
||||
return "application/octet-stream";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves `id` (UUID stem) to the single matching file `{id}.*` under UPLOAD_ROOT.
|
||||
* Returns null if missing, ambiguous, or invalid id.
|
||||
*/
|
||||
export async function resolveUploadedFileById(
|
||||
id: string,
|
||||
): Promise<ResolvedUploadFile | null> {
|
||||
if (!isValidUploadFileId(id)) return null;
|
||||
const root = getUploadRootFromEnv();
|
||||
if (!root) return null;
|
||||
|
||||
const entries = await readdir(root);
|
||||
const prefix = `${id}.`;
|
||||
const matches = entries.filter((e) => e.startsWith(prefix));
|
||||
if (matches.length !== 1) return null;
|
||||
|
||||
const absolutePath = path.join(root, matches[0]!);
|
||||
try {
|
||||
await access(absolutePath, fsConstants.R_OK);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
absolutePath,
|
||||
contentType: contentTypeForFilename(matches[0]!),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { CreateFlowUploadPurpose } from "./uploadConstants";
|
||||
import {
|
||||
extensionForMime,
|
||||
isAllowedMime,
|
||||
maxBytesForPurpose,
|
||||
} from "./uploadConstants";
|
||||
import { ensureUploadRootExists, getUploadRootFromEnv } from "./uploadRoot";
|
||||
|
||||
export type SaveCreateFlowUploadResult = {
|
||||
/** Filename stem (UUID) without extension — used in GET URL. */
|
||||
id: string;
|
||||
/** Full relative URL path for clients, e.g. `/api/uploads/abc`. */
|
||||
urlPath: string;
|
||||
mimeType: string;
|
||||
byteLength: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Writes bytes under `UPLOAD_ROOT/{id}{ext}` and returns a stable app URL path.
|
||||
*/
|
||||
export async function saveCreateFlowUpload(params: {
|
||||
purpose: CreateFlowUploadPurpose;
|
||||
buffer: Buffer;
|
||||
/** Declared MIME from the client `File.type` (validated server-side). */
|
||||
mimeType: string;
|
||||
}): Promise<SaveCreateFlowUploadResult | { error: "misconfigured" | "validation" }> {
|
||||
const root = getUploadRootFromEnv();
|
||||
if (!root) {
|
||||
return { error: "misconfigured" };
|
||||
}
|
||||
|
||||
const { purpose, buffer, mimeType } = params;
|
||||
if (buffer.length > maxBytesForPurpose(purpose)) {
|
||||
return { error: "validation" };
|
||||
}
|
||||
if (!isAllowedMime(purpose, mimeType)) {
|
||||
return { error: "validation" };
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const ext = extensionForMime(mimeType);
|
||||
const fileName = `${id}${ext}`;
|
||||
const absolutePath = path.join(root, fileName);
|
||||
|
||||
await ensureUploadRootExists(root);
|
||||
await writeFile(absolutePath, buffer, { mode: 0o644 });
|
||||
|
||||
return {
|
||||
id,
|
||||
urlPath: `/api/uploads/${id}`,
|
||||
mimeType: mimeType.toLowerCase().split(";")[0]?.trim() ?? "application/octet-stream",
|
||||
byteLength: buffer.length,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { CreateFlowUploadPurpose } from "../../create/createFlowUploadPurpose";
|
||||
|
||||
export type { CreateFlowUploadPurpose };
|
||||
export { CREATE_FLOW_UPLOAD_PURPOSES } from "../../create/createFlowUploadPurpose";
|
||||
|
||||
/** Max body size for multipart upload (bytes). */
|
||||
export const CREATE_FLOW_UPLOAD_MAX_BYTES = 12 * 1024 * 1024;
|
||||
|
||||
const COMMUNITY_MAX = 5 * 1024 * 1024;
|
||||
const CUSTOM_MAX = 10 * 1024 * 1024;
|
||||
|
||||
const IMAGE_MIMES = new Set([
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
]);
|
||||
|
||||
const CUSTOM_EXTRA_MIMES = new Set(["application/pdf"]);
|
||||
|
||||
export function maxBytesForPurpose(purpose: CreateFlowUploadPurpose): number {
|
||||
return purpose === "communityAvatar" ? COMMUNITY_MAX : CUSTOM_MAX;
|
||||
}
|
||||
|
||||
export function isAllowedMime(
|
||||
purpose: CreateFlowUploadPurpose,
|
||||
mime: string,
|
||||
): boolean {
|
||||
const m = mime.toLowerCase().split(";")[0]?.trim() ?? "";
|
||||
if (IMAGE_MIMES.has(m)) return true;
|
||||
if (purpose === "customMethodAttachment" && CUSTOM_EXTRA_MIMES.has(m)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Extension including dot, from normalized mime (lowercase). */
|
||||
export function extensionForMime(mime: string): string {
|
||||
const m = mime.toLowerCase().split(";")[0]?.trim() ?? "";
|
||||
switch (m) {
|
||||
case "image/jpeg":
|
||||
return ".jpg";
|
||||
case "image/png":
|
||||
return ".png";
|
||||
case "image/webp":
|
||||
return ".webp";
|
||||
case "image/gif":
|
||||
return ".gif";
|
||||
case "application/pdf":
|
||||
return ".pdf";
|
||||
default:
|
||||
return ".bin";
|
||||
}
|
||||
}
|
||||
|
||||
/** Strict id: uuid v4 filename stem (no extension in id param for GET). */
|
||||
const UPLOAD_ID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
export function isValidUploadFileId(id: string): boolean {
|
||||
return UPLOAD_ID_RE.test(id);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
/**
|
||||
* Directory for persisted user uploads (Cloudron localstorage mount in prod).
|
||||
* When unset, upload routes return `server_misconfigured`.
|
||||
*/
|
||||
export function getUploadRootFromEnv(): string | null {
|
||||
const raw = process.env.UPLOAD_ROOT?.trim();
|
||||
if (!raw) return null;
|
||||
return path.resolve(raw);
|
||||
}
|
||||
|
||||
export async function ensureUploadRootExists(root: string): Promise<void> {
|
||||
await mkdir(root, { recursive: true });
|
||||
}
|
||||
@@ -89,6 +89,7 @@ export const createFlowStateSchema = z
|
||||
summary: z.string().max(8000).optional(),
|
||||
communityContext: z.string().max(200).optional(),
|
||||
communitySaveEmail: z.string().max(320).optional(),
|
||||
communityAvatarUrl: z.string().max(512).optional(),
|
||||
selectedCommunitySizeIds: z.array(z.string()).optional(),
|
||||
selectedOrganizationTypeIds: z.array(z.string()).optional(),
|
||||
selectedScaleIds: z.array(z.string()).optional(),
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
{
|
||||
"title": "Add a photo to identify your group",
|
||||
"description": "This photo be used as a profile picture for your group and will be editable later. If possible, try to use a simple logo or graphic.",
|
||||
"hintText": "Add image from your device"
|
||||
"hintText": "Add image from your device",
|
||||
"signInToUploadNote": "Your photo will upload after you sign in (use Save progress from the next step, or Log in from the header).",
|
||||
"uploadingLabel": "Uploading your photo…",
|
||||
"previewAlt": "Selected community photo preview",
|
||||
"clearPendingUploadAriaLabel": "Remove uploaded photo",
|
||||
"clearPendingUploadTooltip": "Remove photo"
|
||||
}
|
||||
|
||||
@@ -19,8 +19,12 @@
|
||||
"description": "Configure a custom data structure for this policy by adding fields for text, proportions, multi-select options, or file uploads. This creates a reusable template with placeholders that allows your group to standardize how policy definitions are edited in the future."
|
||||
}
|
||||
},
|
||||
"step3BlocksList": {
|
||||
"listLabel": "Fields added to this policy",
|
||||
"dragHandleAriaLabel": "Drag to reorder this field"
|
||||
},
|
||||
"footer": {
|
||||
"finalize": "Finalize policy"
|
||||
"finalize": "Finalize"
|
||||
},
|
||||
"editModal": {
|
||||
"placeholderBody": "This policy uses the title and description you set when you created it. Extra section fields from preset templates are hidden here so you are not shown empty boxes that do not match what you configured.",
|
||||
@@ -64,7 +68,10 @@
|
||||
"blockTitleLabel": "Upload Block Title",
|
||||
"blockTitlePlaceholder": "Add your upload block title",
|
||||
"uploadFileInputAriaLabel": "Choose file for this upload block",
|
||||
"uploadHint": "Add images, PDFs, and other files to the policy"
|
||||
"uploadHint": "Add images, PDFs, and other files to the policy",
|
||||
"uploadPreviewImageAlt": "Preview of uploaded file",
|
||||
"clearPendingUploadAriaLabel": "Remove uploaded file",
|
||||
"clearPendingUploadTooltip": "Remove file"
|
||||
},
|
||||
"proportion": {
|
||||
"title": "Add proportion block",
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"title": "How should conflicts be resolved?",
|
||||
"description": "Upload supporting materials or examples that help describe how your community handles conflict."
|
||||
"errors": {
|
||||
"generic": "Something went wrong while uploading. Try again.",
|
||||
"tooLarge": "That file is too large. Try a smaller image or PDF.",
|
||||
"unauthorized": "Sign in to upload files. Use Save progress if you started without an account.",
|
||||
"misconfigured": "Uploads are not available on this server yet."
|
||||
},
|
||||
"uploading": "Uploading…"
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ import createFooter from "./create/footer.json";
|
||||
import createTopNav from "./create/topNav.json";
|
||||
import createDraftHydration from "./create/draftHydration.json";
|
||||
import createTemplateReview from "./create/templateReview.json";
|
||||
import createUpload from "./create/upload.json";
|
||||
|
||||
export default {
|
||||
common,
|
||||
@@ -107,6 +108,7 @@ export default {
|
||||
topNav: createTopNav,
|
||||
draftHydration: createDraftHydration,
|
||||
templateReview: createTemplateReview,
|
||||
upload: createUpload,
|
||||
},
|
||||
navigation,
|
||||
metadata,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
extensionForMime,
|
||||
isAllowedMime,
|
||||
isValidUploadFileId,
|
||||
maxBytesForPurpose,
|
||||
} from "../../lib/server/uploads/uploadConstants";
|
||||
|
||||
describe("createFlow upload constants", () => {
|
||||
it("maxBytesForPurpose caps community smaller than custom attachment", () => {
|
||||
expect(maxBytesForPurpose("communityAvatar")).toBe(5 * 1024 * 1024);
|
||||
expect(maxBytesForPurpose("customMethodAttachment")).toBe(10 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it("isAllowedMime allows images for both purposes", () => {
|
||||
expect(isAllowedMime("communityAvatar", "image/png")).toBe(true);
|
||||
expect(isAllowedMime("customMethodAttachment", "image/jpeg")).toBe(true);
|
||||
});
|
||||
|
||||
it("isAllowedMime allows pdf only for customMethodAttachment", () => {
|
||||
expect(isAllowedMime("communityAvatar", "application/pdf")).toBe(false);
|
||||
expect(isAllowedMime("customMethodAttachment", "application/pdf")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("extensionForMime maps common types", () => {
|
||||
expect(extensionForMime("image/png")).toBe(".png");
|
||||
expect(extensionForMime("image/jpeg")).toBe(".jpg");
|
||||
expect(extensionForMime("application/pdf")).toBe(".pdf");
|
||||
});
|
||||
|
||||
it("isValidUploadFileId rejects traversal and non-uuid", () => {
|
||||
expect(isValidUploadFileId("../etc/passwd")).toBe(false);
|
||||
expect(isValidUploadFileId("not-a-uuid")).toBe(false);
|
||||
expect(
|
||||
isValidUploadFileId("550e8400-e29b-41d4-a716-446655440000"),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { reorderCustomMethodCardFieldBlocks } from "../../lib/create/reorderCustomMethodCardFieldBlocks";
|
||||
|
||||
describe("reorderCustomMethodCardFieldBlocks", () => {
|
||||
it("returns a new array with the item moved forward", () => {
|
||||
const blocks = ["a", "b", "c"];
|
||||
expect(reorderCustomMethodCardFieldBlocks(blocks, 0, 2)).toEqual([
|
||||
"b",
|
||||
"c",
|
||||
"a",
|
||||
]);
|
||||
expect(blocks).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("returns a new array with the item moved backward", () => {
|
||||
expect(
|
||||
reorderCustomMethodCardFieldBlocks(["a", "b", "c"], 2, 0),
|
||||
).toEqual(["c", "a", "b"]);
|
||||
});
|
||||
|
||||
it("returns a shallow copy when from and to are equal", () => {
|
||||
const blocks = ["a", "b"];
|
||||
const next = reorderCustomMethodCardFieldBlocks(blocks, 1, 1);
|
||||
expect(next).toEqual(blocks);
|
||||
expect(next).not.toBe(blocks);
|
||||
});
|
||||
|
||||
it("returns a copy when indices are out of range", () => {
|
||||
const blocks = ["a"];
|
||||
expect(reorderCustomMethodCardFieldBlocks(blocks, -1, 0)).toEqual(["a"]);
|
||||
expect(reorderCustomMethodCardFieldBlocks(blocks, 0, 5)).toEqual(["a"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user