mirror of
https://github.com/sakaljurgis/best-choice.git
synced 2026-07-08 21:47:40 +00:00
add image
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
import type { ItemImage } from '@shared/models/item-image';
|
||||
import { apiFetch } from './http-client';
|
||||
|
||||
interface ItemImagesResponse {
|
||||
data: ItemImage[];
|
||||
}
|
||||
|
||||
interface SingleImageResponse {
|
||||
data: ItemImage;
|
||||
}
|
||||
|
||||
export const fetchItemImages = async (itemId: string): Promise<ItemImage[]> => {
|
||||
const response = await apiFetch<ItemImagesResponse>(`/items/${itemId}/images`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const createItemImage = async (
|
||||
itemId: string,
|
||||
url: string
|
||||
): Promise<ItemImage> => {
|
||||
const response = await apiFetch<SingleImageResponse>(
|
||||
`/items/${itemId}/images`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: { url }
|
||||
}
|
||||
);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteItemImage = async (imageId: string): Promise<void> => {
|
||||
await apiFetch(`/images/${imageId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
};
|
||||
+16
-1
@@ -45,6 +45,7 @@ export interface CreateItemPayload {
|
||||
attributes?: Record<string, unknown>;
|
||||
sourceUrl?: string | null;
|
||||
sourceUrlId?: string | null;
|
||||
defaultImageId?: string | null;
|
||||
}
|
||||
|
||||
interface SingleItemResponse {
|
||||
@@ -57,6 +58,8 @@ export interface ImportedItemData {
|
||||
note: string | null;
|
||||
attributes: Record<string, unknown>;
|
||||
sourceUrl?: string | null;
|
||||
images?: Array<{ id?: string; url: string }>;
|
||||
defaultImageId?: string | null;
|
||||
}
|
||||
|
||||
export const createItem = async (
|
||||
@@ -74,7 +77,9 @@ export const createItem = async (
|
||||
note: payload.note ?? null,
|
||||
attributes: payload.attributes ?? {},
|
||||
sourceUrl: payload.sourceUrl ?? null,
|
||||
sourceUrlId: payload.sourceUrlId ?? null
|
||||
sourceUrlId: payload.sourceUrlId ?? null,
|
||||
defaultImageId:
|
||||
payload.defaultImageId === undefined ? null : payload.defaultImageId
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -109,6 +114,7 @@ export interface UpdateItemPayload {
|
||||
attributes?: Record<string, unknown>;
|
||||
sourceUrl?: string | null;
|
||||
sourceUrlId?: string | null;
|
||||
defaultImageId?: string | null;
|
||||
}
|
||||
|
||||
export const updateItem = async (
|
||||
@@ -145,6 +151,10 @@ export const updateItem = async (
|
||||
body.sourceUrlId = payload.sourceUrlId ?? null;
|
||||
}
|
||||
|
||||
if ('defaultImageId' in payload) {
|
||||
body.defaultImageId = payload.defaultImageId ?? null;
|
||||
}
|
||||
|
||||
const response = await apiFetch<SingleItemResponse>(`/items/${itemId}`, {
|
||||
method: 'PATCH',
|
||||
body
|
||||
@@ -152,3 +162,8 @@ export const updateItem = async (
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const fetchItem = async (itemId: string): Promise<Item> => {
|
||||
const response = await apiFetch<SingleItemResponse>(`/items/${itemId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -1,15 +1,54 @@
|
||||
import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { CreateItemPayload, ImportedItemData } from '../api/items';
|
||||
import type { AttributeScalarType } from '../utils/attribute-values';
|
||||
import { inferScalarValue } from '../utils/attribute-values';
|
||||
|
||||
interface ImageEntry {
|
||||
id: string;
|
||||
existingId: string | null;
|
||||
url: string;
|
||||
hasPreviewError: boolean;
|
||||
}
|
||||
|
||||
const IMAGE_URL_MAX_LENGTH = 2048;
|
||||
|
||||
const createUniqueId = () => Math.random().toString(36).slice(2);
|
||||
|
||||
const createBlankImageEntry = (): ImageEntry => ({
|
||||
id: createUniqueId(),
|
||||
existingId: null,
|
||||
url: '',
|
||||
hasPreviewError: false
|
||||
});
|
||||
|
||||
const isValidImageUrl = (value: string): boolean => {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export interface ItemFormSubmitPayload {
|
||||
itemPayload: CreateItemPayload;
|
||||
imageChanges: {
|
||||
toCreate: Array<{ id: string; url: string }>;
|
||||
toDelete: string[];
|
||||
defaultSelection:
|
||||
| { type: 'existing'; id: string }
|
||||
| { type: 'new'; id: string }
|
||||
| null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ItemFormModalProps {
|
||||
isOpen: boolean;
|
||||
mode: 'url' | 'manual' | 'edit';
|
||||
initialUrl: string | null;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
onSubmit: (payload: CreateItemPayload) => Promise<unknown>;
|
||||
onSubmit: (payload: ItemFormSubmitPayload) => Promise<unknown>;
|
||||
isSubmitting: boolean;
|
||||
projectAttributes: string[];
|
||||
initialData: ImportedItemData | null;
|
||||
@@ -26,8 +65,6 @@ interface AttributeEntry {
|
||||
isArrayMember: boolean;
|
||||
}
|
||||
|
||||
const createUniqueId = () => Math.random().toString(36).slice(2);
|
||||
|
||||
const createBlankEntry = (key = '', isArrayMember = false): AttributeEntry => ({
|
||||
id: createUniqueId(),
|
||||
key,
|
||||
@@ -308,7 +345,11 @@ export function ItemFormModal({
|
||||
const [note, setNote] = useState('');
|
||||
const [sourceUrl, setSourceUrl] = useState('');
|
||||
const [attributeEntries, setAttributeEntries] = useState<AttributeEntry[]>([]);
|
||||
const [imageEntries, setImageEntries] = useState<ImageEntry[]>([]);
|
||||
const [selectedDefaultImageId, setSelectedDefaultImageId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const initialImageIdsRef = useRef<Set<string>>(new Set());
|
||||
const initialImageUrlsRef = useRef<Map<string, string>>(new Map());
|
||||
const isFormDisabled = isSubmitting || isInitialDataLoading;
|
||||
const trackedAttributeMetadata = useMemo(
|
||||
() => computeTrackedKeyMetadata(projectAttributes),
|
||||
@@ -324,6 +365,13 @@ export function ItemFormModal({
|
||||
[trackedAttributeMetadata]
|
||||
);
|
||||
|
||||
const updateImageEntries = useCallback(
|
||||
(updater: (previous: ImageEntry[]) => ImageEntry[]) => {
|
||||
setImageEntries((previous) => updater(previous));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
@@ -374,6 +422,37 @@ export function ItemFormModal({
|
||||
trackedAttributeMetadata
|
||||
)
|
||||
);
|
||||
|
||||
const existingImageIds = new Set<string>();
|
||||
const existingImageUrlMap = new Map<string, string>();
|
||||
const initialImages = initialData?.images ?? [];
|
||||
const nextImageEntries: ImageEntry[] = initialImages.map((image) => {
|
||||
const existingId = image.id ?? null;
|
||||
const entryId = existingId ?? createUniqueId();
|
||||
if (existingId) {
|
||||
existingImageIds.add(existingId);
|
||||
existingImageUrlMap.set(existingId, image.url.trim());
|
||||
}
|
||||
return {
|
||||
id: entryId,
|
||||
existingId,
|
||||
url: image.url,
|
||||
hasPreviewError: false
|
||||
};
|
||||
});
|
||||
|
||||
initialImageIdsRef.current = existingImageIds;
|
||||
initialImageUrlsRef.current = existingImageUrlMap;
|
||||
setImageEntries(nextImageEntries);
|
||||
|
||||
if (initialData?.defaultImageId) {
|
||||
const entryWithDefault = nextImageEntries.find(
|
||||
(entry) => entry.existingId === initialData.defaultImageId
|
||||
);
|
||||
setSelectedDefaultImageId(entryWithDefault ? entryWithDefault.id : null);
|
||||
} else {
|
||||
setSelectedDefaultImageId(null);
|
||||
}
|
||||
}, [isOpen, initialUrl, initialData, projectAttributes, trackedAttributeMetadata]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -389,6 +468,9 @@ export function ItemFormModal({
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const initialDataStatusLabel =
|
||||
mode === 'edit' ? 'Loading item details…' : 'Importing item details…';
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
@@ -476,6 +558,70 @@ export function ItemFormModal({
|
||||
}
|
||||
});
|
||||
|
||||
const normalizedImages = imageEntries.map((entry) => ({
|
||||
...entry,
|
||||
url: entry.url.trim()
|
||||
}));
|
||||
|
||||
const imagesToCreateMap = new Map<string, string>();
|
||||
const imagesToDeleteSet = new Set(initialImageIdsRef.current);
|
||||
const existingImageUrlMap = initialImageUrlsRef.current;
|
||||
|
||||
for (const image of normalizedImages) {
|
||||
if (!image.url.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (image.url.length > IMAGE_URL_MAX_LENGTH) {
|
||||
setError('Image URL must be 2048 characters or fewer.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isValidImageUrl(image.url)) {
|
||||
setError('Each image must use a valid http(s) URL.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (image.existingId) {
|
||||
const initialUrl = existingImageUrlMap.get(image.existingId) ?? '';
|
||||
if (image.url === initialUrl) {
|
||||
imagesToDeleteSet.delete(image.existingId);
|
||||
} else {
|
||||
imagesToCreateMap.set(image.id, image.url);
|
||||
}
|
||||
} else {
|
||||
imagesToCreateMap.set(image.id, image.url);
|
||||
}
|
||||
}
|
||||
|
||||
let defaultSelection: ItemFormSubmitPayload['imageChanges']['defaultSelection'] =
|
||||
null;
|
||||
|
||||
if (selectedDefaultImageId) {
|
||||
const selectedEntry = normalizedImages.find(
|
||||
(entry) => entry.id === selectedDefaultImageId
|
||||
);
|
||||
if (selectedEntry) {
|
||||
if (
|
||||
selectedEntry.existingId &&
|
||||
!imagesToDeleteSet.has(selectedEntry.existingId) &&
|
||||
selectedEntry.url.length
|
||||
) {
|
||||
defaultSelection = { type: 'existing', id: selectedEntry.existingId };
|
||||
} else if (selectedEntry.url.length) {
|
||||
imagesToCreateMap.set(selectedEntry.id, selectedEntry.url);
|
||||
defaultSelection = { type: 'new', id: selectedEntry.id };
|
||||
} else {
|
||||
defaultSelection = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const imagesToCreate = Array.from(imagesToCreateMap.entries()).map(
|
||||
([id, url]) => ({ id, url })
|
||||
);
|
||||
const imagesToDelete = Array.from(imagesToDeleteSet);
|
||||
|
||||
try {
|
||||
const payload: CreateItemPayload = {
|
||||
manufacturer: manufacturer.trim() ? manufacturer.trim() : null,
|
||||
@@ -489,7 +635,14 @@ export function ItemFormModal({
|
||||
payload.status = 'active';
|
||||
}
|
||||
|
||||
await onSubmit(payload);
|
||||
await onSubmit({
|
||||
itemPayload: payload,
|
||||
imageChanges: {
|
||||
toCreate: imagesToCreate,
|
||||
toDelete: imagesToDelete,
|
||||
defaultSelection
|
||||
}
|
||||
});
|
||||
onSuccess();
|
||||
} catch (submitError) {
|
||||
setError((submitError as Error).message);
|
||||
@@ -560,7 +713,7 @@ export function ItemFormModal({
|
||||
d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Importing item details…</span>
|
||||
<span>{initialDataStatusLabel}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -837,6 +990,174 @@ export function ItemFormModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium text-slate-700">Images</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
updateImageEntries((previous) => [
|
||||
...previous,
|
||||
createBlankImageEntry()
|
||||
]);
|
||||
setError(null);
|
||||
}}
|
||||
disabled={isFormDisabled}
|
||||
className="rounded-md border border-blue-600 px-4 py-2 text-xs font-semibold uppercase tracking-wide text-blue-600 transition hover:bg-blue-50 disabled:cursor-not-allowed disabled:border-slate-200 disabled:text-slate-400 disabled:hover:bg-transparent"
|
||||
>
|
||||
Add Image
|
||||
</button>
|
||||
</div>
|
||||
{imageEntries.length ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
{imageEntries.map((image) => {
|
||||
const trimmedUrl = image.url.trim();
|
||||
const isDefault = selectedDefaultImageId === image.id;
|
||||
const cardHighlight = isDefault
|
||||
? 'border-blue-400 shadow-md shadow-blue-100'
|
||||
: 'border-slate-200';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={image.id}
|
||||
className={`rounded-lg border ${cardHighlight} bg-white p-4 shadow-sm`}
|
||||
>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:gap-5">
|
||||
<div className="flex h-28 w-full items-center justify-center overflow-hidden rounded-md border border-slate-200 bg-slate-50 md:h-24 md:w-24">
|
||||
{trimmedUrl ? (
|
||||
image.hasPreviewError ? (
|
||||
<span className="px-2 text-center text-xs text-slate-500">
|
||||
Preview unavailable
|
||||
</span>
|
||||
) : (
|
||||
<img
|
||||
src={trimmedUrl}
|
||||
alt="Item preview"
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
onError={() => {
|
||||
updateImageEntries((previous) =>
|
||||
previous.map((entry) =>
|
||||
entry.id === image.id
|
||||
? { ...entry, hasPreviewError: true }
|
||||
: entry
|
||||
)
|
||||
);
|
||||
}}
|
||||
onLoad={() => {
|
||||
updateImageEntries((previous) =>
|
||||
previous.map((entry) =>
|
||||
entry.id === image.id && entry.hasPreviewError
|
||||
? { ...entry, hasPreviewError: false }
|
||||
: entry
|
||||
)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<span className="px-3 text-center text-xs text-slate-500">
|
||||
Add an image URL to preview it here.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 space-y-3">
|
||||
<input
|
||||
type="url"
|
||||
value={image.url}
|
||||
disabled={isFormDisabled}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
updateImageEntries((previous) =>
|
||||
previous.map((entry) =>
|
||||
entry.id === image.id
|
||||
? {
|
||||
...entry,
|
||||
url: nextValue,
|
||||
hasPreviewError: false
|
||||
}
|
||||
: entry
|
||||
)
|
||||
);
|
||||
if (
|
||||
selectedDefaultImageId === image.id &&
|
||||
!nextValue.trim()
|
||||
) {
|
||||
setSelectedDefaultImageId(null);
|
||||
}
|
||||
setError(null);
|
||||
}}
|
||||
placeholder="https://example.com/photo.jpg"
|
||||
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 disabled:cursor-not-allowed disabled:border-slate-200 disabled:bg-slate-100"
|
||||
/>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<label className="flex items-center gap-2 text-xs font-medium text-slate-600">
|
||||
<input
|
||||
type="radio"
|
||||
name="default-image"
|
||||
value={image.id}
|
||||
checked={isDefault}
|
||||
disabled={isFormDisabled || !trimmedUrl.length}
|
||||
onChange={() => {
|
||||
if (!trimmedUrl.length) {
|
||||
setSelectedDefaultImageId(null);
|
||||
return;
|
||||
}
|
||||
setSelectedDefaultImageId(image.id);
|
||||
setError(null);
|
||||
}}
|
||||
className="h-4 w-4 border-slate-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span>Use as default image</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
updateImageEntries((previous) =>
|
||||
previous.filter((entry) => entry.id !== image.id)
|
||||
);
|
||||
if (selectedDefaultImageId === image.id) {
|
||||
setSelectedDefaultImageId(null);
|
||||
}
|
||||
setError(null);
|
||||
}}
|
||||
disabled={isFormDisabled}
|
||||
className="rounded-md border border-red-200 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-red-600 transition hover:bg-red-50 disabled:cursor-not-allowed disabled:border-slate-200 disabled:text-slate-400 disabled:hover:bg-transparent"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-400">
|
||||
No images added yet. Use the button above to attach product photos.
|
||||
</p>
|
||||
)}
|
||||
{selectedDefaultImageId ? (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedDefaultImageId(null);
|
||||
setError(null);
|
||||
}}
|
||||
disabled={isFormDisabled}
|
||||
className="rounded-md border border-slate-300 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-slate-600 transition hover:bg-slate-100 disabled:cursor-not-allowed disabled:border-slate-200 disabled:text-slate-400 disabled:hover:bg-transparent"
|
||||
>
|
||||
Clear Default Image
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<p className="text-xs text-slate-500">
|
||||
Previews load directly from the provided URLs. Broken previews will not block saving.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 flex items-center justify-between">
|
||||
<div className="text-xs text-slate-500">
|
||||
Fields marked with * are required. Remove any attribute row you do not need.
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import type { Item } from '@shared/models/item';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
projectsKeys,
|
||||
useCreateItemMutation,
|
||||
useUpdateItemMutation,
|
||||
useProjectItemsQuery,
|
||||
@@ -9,12 +11,13 @@ import {
|
||||
useUpdateProjectMutation
|
||||
} from '../../query/projects';
|
||||
import {
|
||||
fetchItem,
|
||||
importItemFromUrl,
|
||||
type CreateItemPayload,
|
||||
type ImportedItemData,
|
||||
type UpdateItemPayload
|
||||
} from '../../api/items';
|
||||
import { ItemFormModal } from '../../components/item-form-modal';
|
||||
import { createItemImage, deleteItemImage } from '../../api/images';
|
||||
import { ItemFormModal, type ItemFormSubmitPayload } from '../../components/item-form-modal';
|
||||
import { ProjectHeader } from './project-header';
|
||||
import { ProjectDescriptionSection } from './project-description-section';
|
||||
import { TrackedAttributesSection } from './tracked-attributes-section';
|
||||
@@ -27,6 +30,7 @@ export function ProjectDetailPage() {
|
||||
const createItemMutation = useCreateItemMutation(projectId);
|
||||
const updateItemMutation = useUpdateItemMutation(projectId);
|
||||
const updateProjectMutation = useUpdateProjectMutation(projectId);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [quickUrl, setQuickUrl] = useState('');
|
||||
const [quickError, setQuickError] = useState<string | null>(null);
|
||||
@@ -37,7 +41,12 @@ export function ProjectDetailPage() {
|
||||
const [isImportingItem, setIsImportingItem] = useState(false);
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const importRequestIdRef = useRef(0);
|
||||
const editRequestIdRef = useRef(0);
|
||||
const [itemBeingEdited, setItemBeingEdited] = useState<Item | null>(null);
|
||||
const [editItemInitialData, setEditItemInitialData] = useState<ImportedItemData | null>(null);
|
||||
const [isLoadingItemDetails, setIsLoadingItemDetails] = useState(false);
|
||||
const [editItemError, setEditItemError] = useState<string | null>(null);
|
||||
const [isSavingItem, setIsSavingItem] = useState(false);
|
||||
|
||||
const project = projectQuery.data;
|
||||
const items = useMemo(() => itemsQuery.data?.data ?? [], [itemsQuery.data]);
|
||||
@@ -97,7 +106,11 @@ export function ProjectDetailPage() {
|
||||
setQuickError(null);
|
||||
setImportError(null);
|
||||
setImportedItemData(null);
|
||||
setEditItemInitialData(null);
|
||||
setEditItemError(null);
|
||||
setIsLoadingItemDetails(false);
|
||||
setItemBeingEdited(null);
|
||||
editRequestIdRef.current += 1;
|
||||
|
||||
const requestId = importRequestIdRef.current + 1;
|
||||
importRequestIdRef.current = requestId;
|
||||
@@ -134,18 +147,74 @@ export function ProjectDetailPage() {
|
||||
setImportedItemData(null);
|
||||
setIsImportingItem(false);
|
||||
importRequestIdRef.current += 1;
|
||||
editRequestIdRef.current += 1;
|
||||
setItemBeingEdited(null);
|
||||
setEditItemInitialData(null);
|
||||
setEditItemError(null);
|
||||
setIsLoadingItemDetails(false);
|
||||
setIsSavingItem(false);
|
||||
openItemModal('manual', quickUrl.trim() || null);
|
||||
};
|
||||
|
||||
const handleEditItem = (item: Item) => {
|
||||
const handleEditItem = async (item: Item) => {
|
||||
setQuickError(null);
|
||||
setImportError(null);
|
||||
setImportedItemData(null);
|
||||
setIsImportingItem(false);
|
||||
importRequestIdRef.current += 1;
|
||||
editRequestIdRef.current += 1;
|
||||
setEditItemError(null);
|
||||
setIsSavingItem(false);
|
||||
setIsLoadingItemDetails(true);
|
||||
const initialImages =
|
||||
item.images?.map((image) => ({ id: image.id, url: image.url })) ?? [];
|
||||
setEditItemInitialData({
|
||||
manufacturer: item.manufacturer,
|
||||
model: item.model,
|
||||
note: item.note,
|
||||
attributes: item.attributes ?? {},
|
||||
sourceUrl: item.sourceUrl ?? null,
|
||||
images: initialImages,
|
||||
defaultImageId: item.defaultImageId ?? null
|
||||
});
|
||||
setItemBeingEdited(item);
|
||||
openItemModal('edit', item.sourceUrl ?? null);
|
||||
|
||||
const requestId = editRequestIdRef.current;
|
||||
|
||||
try {
|
||||
const fullItem = await fetchItem(item.id);
|
||||
if (editRequestIdRef.current !== requestId) {
|
||||
return;
|
||||
}
|
||||
setItemBeingEdited(fullItem);
|
||||
setEditItemInitialData({
|
||||
manufacturer: fullItem.manufacturer,
|
||||
model: fullItem.model,
|
||||
note: fullItem.note,
|
||||
attributes: fullItem.attributes ?? {},
|
||||
sourceUrl: fullItem.sourceUrl ?? null,
|
||||
images:
|
||||
fullItem.images?.map((image) => ({
|
||||
id: image.id,
|
||||
url: image.url
|
||||
})) ?? [],
|
||||
defaultImageId: fullItem.defaultImageId ?? null
|
||||
});
|
||||
} catch (error) {
|
||||
if (editRequestIdRef.current !== requestId) {
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
error instanceof Error && error.message.trim().length
|
||||
? error.message.trim()
|
||||
: 'Failed to load item details.';
|
||||
setEditItemError(message);
|
||||
} finally {
|
||||
if (editRequestIdRef.current === requestId) {
|
||||
setIsLoadingItemDetails(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleModalClose = () => {
|
||||
@@ -155,7 +224,12 @@ export function ProjectDetailPage() {
|
||||
setImportedItemData(null);
|
||||
setIsImportingItem(false);
|
||||
importRequestIdRef.current += 1;
|
||||
editRequestIdRef.current += 1;
|
||||
setItemBeingEdited(null);
|
||||
setEditItemInitialData(null);
|
||||
setEditItemError(null);
|
||||
setIsLoadingItemDetails(false);
|
||||
setIsSavingItem(false);
|
||||
};
|
||||
|
||||
const handleModalSuccess = () => {
|
||||
@@ -168,7 +242,12 @@ export function ProjectDetailPage() {
|
||||
setImportedItemData(null);
|
||||
setIsImportingItem(false);
|
||||
importRequestIdRef.current += 1;
|
||||
editRequestIdRef.current += 1;
|
||||
setItemBeingEdited(null);
|
||||
setEditItemInitialData(null);
|
||||
setEditItemError(null);
|
||||
setIsLoadingItemDetails(false);
|
||||
setIsSavingItem(false);
|
||||
};
|
||||
|
||||
const handleDescriptionUpdate = async (description: string | null) => {
|
||||
@@ -179,24 +258,126 @@ export function ProjectDetailPage() {
|
||||
await updateProjectMutation.mutateAsync({ attributes });
|
||||
};
|
||||
|
||||
const handleUpdateItemSubmit = (payload: CreateItemPayload) => {
|
||||
if (!itemBeingEdited) {
|
||||
return Promise.reject(new Error('No item selected for editing'));
|
||||
const handleCreateItemSubmit = async (payload: ItemFormSubmitPayload) => {
|
||||
if (!projectId) {
|
||||
throw new Error('Project identifier is missing. Reload the page and try again.');
|
||||
}
|
||||
|
||||
const updatePayload: UpdateItemPayload = {
|
||||
manufacturer: payload.manufacturer,
|
||||
model: payload.model,
|
||||
note: payload.note,
|
||||
attributes: payload.attributes,
|
||||
sourceUrl: payload.sourceUrl,
|
||||
sourceUrlId: payload.sourceUrlId
|
||||
};
|
||||
setIsSavingItem(true);
|
||||
|
||||
return updateItemMutation.mutateAsync({
|
||||
itemId: itemBeingEdited.id,
|
||||
payload: updatePayload
|
||||
});
|
||||
try {
|
||||
const createdItem = await createItemMutation.mutateAsync(payload.itemPayload);
|
||||
|
||||
const createdImageIds = new Map<string, string>();
|
||||
if (payload.imageChanges.toCreate.length) {
|
||||
await Promise.all(
|
||||
payload.imageChanges.toCreate.map(async (image) => {
|
||||
const created = await createItemImage(createdItem.id, image.url);
|
||||
createdImageIds.set(image.id, created.id);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const { defaultSelection } = payload.imageChanges;
|
||||
if (defaultSelection) {
|
||||
let resolvedDefaultId: string | null = null;
|
||||
if (defaultSelection.type === 'existing') {
|
||||
resolvedDefaultId = defaultSelection.id;
|
||||
} else {
|
||||
const mappedId = createdImageIds.get(defaultSelection.id);
|
||||
if (!mappedId) {
|
||||
throw new Error('Failed to determine the default image.');
|
||||
}
|
||||
resolvedDefaultId = mappedId;
|
||||
}
|
||||
|
||||
if (resolvedDefaultId) {
|
||||
await updateItemMutation.mutateAsync({
|
||||
itemId: createdItem.id,
|
||||
payload: { defaultImageId: resolvedDefaultId }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: projectsKeys.items(projectId) });
|
||||
await queryClient.invalidateQueries({ queryKey: projectsKeys.detail(projectId) });
|
||||
} finally {
|
||||
setIsSavingItem(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateItemSubmit = async (payload: ItemFormSubmitPayload) => {
|
||||
if (!itemBeingEdited) {
|
||||
throw new Error('No item selected for editing');
|
||||
}
|
||||
|
||||
setIsSavingItem(true);
|
||||
|
||||
try {
|
||||
const createdImageIds = new Map<string, string>();
|
||||
|
||||
if (payload.imageChanges.toCreate.length) {
|
||||
await Promise.all(
|
||||
payload.imageChanges.toCreate.map(async (image) => {
|
||||
const created = await createItemImage(itemBeingEdited.id, image.url);
|
||||
createdImageIds.set(image.id, created.id);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const { defaultSelection, toDelete } = payload.imageChanges;
|
||||
let resolvedDefaultId: string | null | undefined;
|
||||
|
||||
if (defaultSelection) {
|
||||
if (defaultSelection.type === 'existing') {
|
||||
resolvedDefaultId = defaultSelection.id;
|
||||
} else {
|
||||
const mappedId = createdImageIds.get(defaultSelection.id);
|
||||
if (!mappedId) {
|
||||
throw new Error('Failed to determine the default image.');
|
||||
}
|
||||
resolvedDefaultId = mappedId;
|
||||
}
|
||||
} else if (itemBeingEdited.defaultImageId) {
|
||||
resolvedDefaultId = null;
|
||||
}
|
||||
|
||||
const updatePayload: UpdateItemPayload = {
|
||||
manufacturer: payload.itemPayload.manufacturer,
|
||||
model: payload.itemPayload.model,
|
||||
note: payload.itemPayload.note,
|
||||
attributes: payload.itemPayload.attributes,
|
||||
sourceUrl: payload.itemPayload.sourceUrl,
|
||||
sourceUrlId: payload.itemPayload.sourceUrlId
|
||||
};
|
||||
|
||||
if (resolvedDefaultId !== undefined) {
|
||||
updatePayload.defaultImageId = resolvedDefaultId;
|
||||
}
|
||||
|
||||
await updateItemMutation.mutateAsync({
|
||||
itemId: itemBeingEdited.id,
|
||||
payload: updatePayload
|
||||
});
|
||||
|
||||
if (toDelete.length) {
|
||||
const protectedId =
|
||||
typeof resolvedDefaultId === 'string' ? resolvedDefaultId : null;
|
||||
const targets = protectedId
|
||||
? toDelete.filter((id) => id !== protectedId)
|
||||
: toDelete;
|
||||
if (targets.length) {
|
||||
await Promise.all(targets.map((id) => deleteItemImage(id)));
|
||||
}
|
||||
}
|
||||
|
||||
if (projectId) {
|
||||
await queryClient.invalidateQueries({ queryKey: projectsKeys.items(projectId) });
|
||||
await queryClient.invalidateQueries({ queryKey: projectsKeys.detail(projectId) });
|
||||
}
|
||||
} finally {
|
||||
setIsSavingItem(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!projectId) {
|
||||
@@ -225,27 +406,32 @@ export function ProjectDetailPage() {
|
||||
const modalInitialData =
|
||||
itemModalMode === 'url'
|
||||
? importedItemData
|
||||
: itemModalMode === 'edit' && itemBeingEdited
|
||||
? {
|
||||
manufacturer: itemBeingEdited.manufacturer,
|
||||
model: itemBeingEdited.model,
|
||||
note: itemBeingEdited.note,
|
||||
attributes: itemBeingEdited.attributes ?? {},
|
||||
sourceUrl: itemBeingEdited.sourceUrl ?? null
|
||||
}
|
||||
: itemModalMode === 'edit'
|
||||
? editItemInitialData
|
||||
: null;
|
||||
|
||||
const itemModalSubmit =
|
||||
itemModalMode === 'edit' ? handleUpdateItemSubmit : createItemMutation.mutateAsync;
|
||||
itemModalMode === 'edit' ? handleUpdateItemSubmit : handleCreateItemSubmit;
|
||||
|
||||
const itemModalIsSubmitting = itemModalMode === 'edit'
|
||||
? updateItemMutation.isPending
|
||||
: createItemMutation.isPending;
|
||||
const itemModalIsSubmitting =
|
||||
isSavingItem ||
|
||||
(itemModalMode === 'edit'
|
||||
? updateItemMutation.isPending
|
||||
: createItemMutation.isPending);
|
||||
|
||||
const itemModalInitialDataLoading =
|
||||
itemModalMode === 'url' ? isImportingItem : false;
|
||||
itemModalMode === 'url'
|
||||
? isImportingItem
|
||||
: itemModalMode === 'edit'
|
||||
? isLoadingItemDetails
|
||||
: false;
|
||||
|
||||
const itemModalInitialDataError = itemModalMode === 'url' ? importError : null;
|
||||
const itemModalInitialDataError =
|
||||
itemModalMode === 'url'
|
||||
? importError
|
||||
: itemModalMode === 'edit'
|
||||
? editItemError
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
|
||||
Reference in New Issue
Block a user