add image

This commit is contained in:
Jurgis Sakalauskas
2025-10-16 09:33:41 +03:00
parent cea323ec3b
commit 9cdcd23fd9
16 changed files with 906 additions and 47 deletions
+36
View File
@@ -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
View File
@@ -45,6 +45,7 @@ export interface CreateItemPayload {
attributes?: Record<string, unknown>; attributes?: Record<string, unknown>;
sourceUrl?: string | null; sourceUrl?: string | null;
sourceUrlId?: string | null; sourceUrlId?: string | null;
defaultImageId?: string | null;
} }
interface SingleItemResponse { interface SingleItemResponse {
@@ -57,6 +58,8 @@ export interface ImportedItemData {
note: string | null; note: string | null;
attributes: Record<string, unknown>; attributes: Record<string, unknown>;
sourceUrl?: string | null; sourceUrl?: string | null;
images?: Array<{ id?: string; url: string }>;
defaultImageId?: string | null;
} }
export const createItem = async ( export const createItem = async (
@@ -74,7 +77,9 @@ export const createItem = async (
note: payload.note ?? null, note: payload.note ?? null,
attributes: payload.attributes ?? {}, attributes: payload.attributes ?? {},
sourceUrl: payload.sourceUrl ?? null, 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>; attributes?: Record<string, unknown>;
sourceUrl?: string | null; sourceUrl?: string | null;
sourceUrlId?: string | null; sourceUrlId?: string | null;
defaultImageId?: string | null;
} }
export const updateItem = async ( export const updateItem = async (
@@ -145,6 +151,10 @@ export const updateItem = async (
body.sourceUrlId = payload.sourceUrlId ?? null; body.sourceUrlId = payload.sourceUrlId ?? null;
} }
if ('defaultImageId' in payload) {
body.defaultImageId = payload.defaultImageId ?? null;
}
const response = await apiFetch<SingleItemResponse>(`/items/${itemId}`, { const response = await apiFetch<SingleItemResponse>(`/items/${itemId}`, {
method: 'PATCH', method: 'PATCH',
body body
@@ -152,3 +162,8 @@ export const updateItem = async (
return response.data; return response.data;
}; };
export const fetchItem = async (itemId: string): Promise<Item> => {
const response = await apiFetch<SingleItemResponse>(`/items/${itemId}`);
return response.data;
};
+327 -6
View File
@@ -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 { CreateItemPayload, ImportedItemData } from '../api/items';
import type { AttributeScalarType } from '../utils/attribute-values'; import type { AttributeScalarType } from '../utils/attribute-values';
import { inferScalarValue } 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 { export interface ItemFormModalProps {
isOpen: boolean; isOpen: boolean;
mode: 'url' | 'manual' | 'edit'; mode: 'url' | 'manual' | 'edit';
initialUrl: string | null; initialUrl: string | null;
onClose: () => void; onClose: () => void;
onSuccess: () => void; onSuccess: () => void;
onSubmit: (payload: CreateItemPayload) => Promise<unknown>; onSubmit: (payload: ItemFormSubmitPayload) => Promise<unknown>;
isSubmitting: boolean; isSubmitting: boolean;
projectAttributes: string[]; projectAttributes: string[];
initialData: ImportedItemData | null; initialData: ImportedItemData | null;
@@ -26,8 +65,6 @@ interface AttributeEntry {
isArrayMember: boolean; isArrayMember: boolean;
} }
const createUniqueId = () => Math.random().toString(36).slice(2);
const createBlankEntry = (key = '', isArrayMember = false): AttributeEntry => ({ const createBlankEntry = (key = '', isArrayMember = false): AttributeEntry => ({
id: createUniqueId(), id: createUniqueId(),
key, key,
@@ -308,7 +345,11 @@ export function ItemFormModal({
const [note, setNote] = useState(''); const [note, setNote] = useState('');
const [sourceUrl, setSourceUrl] = useState(''); const [sourceUrl, setSourceUrl] = useState('');
const [attributeEntries, setAttributeEntries] = useState<AttributeEntry[]>([]); 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 [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 isFormDisabled = isSubmitting || isInitialDataLoading;
const trackedAttributeMetadata = useMemo( const trackedAttributeMetadata = useMemo(
() => computeTrackedKeyMetadata(projectAttributes), () => computeTrackedKeyMetadata(projectAttributes),
@@ -324,6 +365,13 @@ export function ItemFormModal({
[trackedAttributeMetadata] [trackedAttributeMetadata]
); );
const updateImageEntries = useCallback(
(updater: (previous: ImageEntry[]) => ImageEntry[]) => {
setImageEntries((previous) => updater(previous));
},
[]
);
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
return; return;
@@ -374,6 +422,37 @@ export function ItemFormModal({
trackedAttributeMetadata 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]); }, [isOpen, initialUrl, initialData, projectAttributes, trackedAttributeMetadata]);
useEffect(() => { useEffect(() => {
@@ -389,6 +468,9 @@ export function ItemFormModal({
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]); }, [isOpen, onClose]);
const initialDataStatusLabel =
mode === 'edit' ? 'Loading item details…' : 'Importing item details…';
if (!isOpen) { if (!isOpen) {
return null; 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 { try {
const payload: CreateItemPayload = { const payload: CreateItemPayload = {
manufacturer: manufacturer.trim() ? manufacturer.trim() : null, manufacturer: manufacturer.trim() ? manufacturer.trim() : null,
@@ -489,7 +635,14 @@ export function ItemFormModal({
payload.status = 'active'; payload.status = 'active';
} }
await onSubmit(payload); await onSubmit({
itemPayload: payload,
imageChanges: {
toCreate: imagesToCreate,
toDelete: imagesToDelete,
defaultSelection
}
});
onSuccess(); onSuccess();
} catch (submitError) { } catch (submitError) {
setError((submitError as Error).message); setError((submitError as Error).message);
@@ -560,7 +713,7 @@ export function ItemFormModal({
d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"
/> />
</svg> </svg>
<span>Importing item details</span> <span>{initialDataStatusLabel}</span>
</div> </div>
) : null} ) : null}
@@ -837,6 +990,174 @@ export function ItemFormModal({
</div> </div>
</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="md:col-span-2 flex items-center justify-between">
<div className="text-xs text-slate-500"> <div className="text-xs text-slate-500">
Fields marked with * are required. Remove any attribute row you do not need. Fields marked with * are required. Remove any attribute row you do not need.
@@ -1,7 +1,9 @@
import { useMemo, useRef, useState } from 'react'; import { useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import type { Item } from '@shared/models/item'; import type { Item } from '@shared/models/item';
import { useQueryClient } from '@tanstack/react-query';
import { import {
projectsKeys,
useCreateItemMutation, useCreateItemMutation,
useUpdateItemMutation, useUpdateItemMutation,
useProjectItemsQuery, useProjectItemsQuery,
@@ -9,12 +11,13 @@ import {
useUpdateProjectMutation useUpdateProjectMutation
} from '../../query/projects'; } from '../../query/projects';
import { import {
fetchItem,
importItemFromUrl, importItemFromUrl,
type CreateItemPayload,
type ImportedItemData, type ImportedItemData,
type UpdateItemPayload type UpdateItemPayload
} from '../../api/items'; } 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 { ProjectHeader } from './project-header';
import { ProjectDescriptionSection } from './project-description-section'; import { ProjectDescriptionSection } from './project-description-section';
import { TrackedAttributesSection } from './tracked-attributes-section'; import { TrackedAttributesSection } from './tracked-attributes-section';
@@ -27,6 +30,7 @@ export function ProjectDetailPage() {
const createItemMutation = useCreateItemMutation(projectId); const createItemMutation = useCreateItemMutation(projectId);
const updateItemMutation = useUpdateItemMutation(projectId); const updateItemMutation = useUpdateItemMutation(projectId);
const updateProjectMutation = useUpdateProjectMutation(projectId); const updateProjectMutation = useUpdateProjectMutation(projectId);
const queryClient = useQueryClient();
const [quickUrl, setQuickUrl] = useState(''); const [quickUrl, setQuickUrl] = useState('');
const [quickError, setQuickError] = useState<string | null>(null); const [quickError, setQuickError] = useState<string | null>(null);
@@ -37,7 +41,12 @@ export function ProjectDetailPage() {
const [isImportingItem, setIsImportingItem] = useState(false); const [isImportingItem, setIsImportingItem] = useState(false);
const [importError, setImportError] = useState<string | null>(null); const [importError, setImportError] = useState<string | null>(null);
const importRequestIdRef = useRef(0); const importRequestIdRef = useRef(0);
const editRequestIdRef = useRef(0);
const [itemBeingEdited, setItemBeingEdited] = useState<Item | null>(null); 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 project = projectQuery.data;
const items = useMemo(() => itemsQuery.data?.data ?? [], [itemsQuery.data]); const items = useMemo(() => itemsQuery.data?.data ?? [], [itemsQuery.data]);
@@ -97,7 +106,11 @@ export function ProjectDetailPage() {
setQuickError(null); setQuickError(null);
setImportError(null); setImportError(null);
setImportedItemData(null); setImportedItemData(null);
setEditItemInitialData(null);
setEditItemError(null);
setIsLoadingItemDetails(false);
setItemBeingEdited(null); setItemBeingEdited(null);
editRequestIdRef.current += 1;
const requestId = importRequestIdRef.current + 1; const requestId = importRequestIdRef.current + 1;
importRequestIdRef.current = requestId; importRequestIdRef.current = requestId;
@@ -134,18 +147,74 @@ export function ProjectDetailPage() {
setImportedItemData(null); setImportedItemData(null);
setIsImportingItem(false); setIsImportingItem(false);
importRequestIdRef.current += 1; importRequestIdRef.current += 1;
editRequestIdRef.current += 1;
setItemBeingEdited(null); setItemBeingEdited(null);
setEditItemInitialData(null);
setEditItemError(null);
setIsLoadingItemDetails(false);
setIsSavingItem(false);
openItemModal('manual', quickUrl.trim() || null); openItemModal('manual', quickUrl.trim() || null);
}; };
const handleEditItem = (item: Item) => { const handleEditItem = async (item: Item) => {
setQuickError(null); setQuickError(null);
setImportError(null); setImportError(null);
setImportedItemData(null); setImportedItemData(null);
setIsImportingItem(false); setIsImportingItem(false);
importRequestIdRef.current += 1; 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); setItemBeingEdited(item);
openItemModal('edit', item.sourceUrl ?? null); 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 = () => { const handleModalClose = () => {
@@ -155,7 +224,12 @@ export function ProjectDetailPage() {
setImportedItemData(null); setImportedItemData(null);
setIsImportingItem(false); setIsImportingItem(false);
importRequestIdRef.current += 1; importRequestIdRef.current += 1;
editRequestIdRef.current += 1;
setItemBeingEdited(null); setItemBeingEdited(null);
setEditItemInitialData(null);
setEditItemError(null);
setIsLoadingItemDetails(false);
setIsSavingItem(false);
}; };
const handleModalSuccess = () => { const handleModalSuccess = () => {
@@ -168,7 +242,12 @@ export function ProjectDetailPage() {
setImportedItemData(null); setImportedItemData(null);
setIsImportingItem(false); setIsImportingItem(false);
importRequestIdRef.current += 1; importRequestIdRef.current += 1;
editRequestIdRef.current += 1;
setItemBeingEdited(null); setItemBeingEdited(null);
setEditItemInitialData(null);
setEditItemError(null);
setIsLoadingItemDetails(false);
setIsSavingItem(false);
}; };
const handleDescriptionUpdate = async (description: string | null) => { const handleDescriptionUpdate = async (description: string | null) => {
@@ -179,24 +258,126 @@ export function ProjectDetailPage() {
await updateProjectMutation.mutateAsync({ attributes }); await updateProjectMutation.mutateAsync({ attributes });
}; };
const handleUpdateItemSubmit = (payload: CreateItemPayload) => { const handleCreateItemSubmit = async (payload: ItemFormSubmitPayload) => {
if (!itemBeingEdited) { if (!projectId) {
return Promise.reject(new Error('No item selected for editing')); throw new Error('Project identifier is missing. Reload the page and try again.');
} }
const updatePayload: UpdateItemPayload = { setIsSavingItem(true);
manufacturer: payload.manufacturer,
model: payload.model,
note: payload.note,
attributes: payload.attributes,
sourceUrl: payload.sourceUrl,
sourceUrlId: payload.sourceUrlId
};
return updateItemMutation.mutateAsync({ try {
itemId: itemBeingEdited.id, const createdItem = await createItemMutation.mutateAsync(payload.itemPayload);
payload: updatePayload
}); 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) { if (!projectId) {
@@ -225,27 +406,32 @@ export function ProjectDetailPage() {
const modalInitialData = const modalInitialData =
itemModalMode === 'url' itemModalMode === 'url'
? importedItemData ? importedItemData
: itemModalMode === 'edit' && itemBeingEdited : itemModalMode === 'edit'
? { ? editItemInitialData
manufacturer: itemBeingEdited.manufacturer,
model: itemBeingEdited.model,
note: itemBeingEdited.note,
attributes: itemBeingEdited.attributes ?? {},
sourceUrl: itemBeingEdited.sourceUrl ?? null
}
: null; : null;
const itemModalSubmit = const itemModalSubmit =
itemModalMode === 'edit' ? handleUpdateItemSubmit : createItemMutation.mutateAsync; itemModalMode === 'edit' ? handleUpdateItemSubmit : handleCreateItemSubmit;
const itemModalIsSubmitting = itemModalMode === 'edit' const itemModalIsSubmitting =
? updateItemMutation.isPending isSavingItem ||
: createItemMutation.isPending; (itemModalMode === 'edit'
? updateItemMutation.isPending
: createItemMutation.isPending);
const itemModalInitialDataLoading = 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 ( return (
<div className="flex flex-col gap-10"> <div className="flex flex-col gap-10">
@@ -0,0 +1,51 @@
import type { Request, Response } from 'express';
import {
createImage as createImageRepo,
deleteImage as deleteImageRepo,
listImagesByItemId
} from '../db/images-repository.js';
import { HttpError } from '../errors/http-error.js';
import { parseUuid } from '../validation/common.js';
import { parseImageCreatePayload } from '../validation/images.js';
export const listItemImages = async (req: Request, res: Response) => {
const itemId = parseUuid(req.params.itemId, 'itemId');
const images = await listImagesByItemId(itemId);
res.json({ data: images });
};
export const createItemImage = async (req: Request, res: Response) => {
const itemId = parseUuid(req.params.itemId, 'itemId');
const payload = parseImageCreatePayload(req.body);
try {
const image = await createImageRepo({
itemId,
url: payload.url
});
res.status(201).json({ data: image });
} catch (error) {
if (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code: string }).code === '23503'
) {
throw new HttpError(404, 'Item not found');
}
throw error;
}
};
export const deleteItemImage = async (req: Request, res: Response) => {
const imageId = parseUuid(req.params.imageId, 'imageId');
const deleted = await deleteImageRepo(imageId);
if (!deleted) {
throw new HttpError(404, 'Image not found');
}
res.status(204).send();
};
@@ -6,6 +6,7 @@ import {
listItems as listItemsRepo, listItems as listItemsRepo,
updateItem as updateItemRepo updateItem as updateItemRepo
} from '../db/items-repository.js'; } from '../db/items-repository.js';
import { findImageById } from '../db/images-repository.js';
import { HttpError } from '../errors/http-error.js'; import { HttpError } from '../errors/http-error.js';
import { resolveUrlId } from './url-helpers.js'; import { resolveUrlId } from './url-helpers.js';
import { parsePaginationParams, parseUuid } from '../validation/common.js'; import { parsePaginationParams, parseUuid } from '../validation/common.js';
@@ -53,6 +54,7 @@ export const createItem = async (req: Request, res: Response) => {
manufacturer: payload.manufacturer, manufacturer: payload.manufacturer,
model: payload.model, model: payload.model,
sourceUrlId, sourceUrlId,
defaultImageId: payload.defaultImageId,
status: payload.status, status: payload.status,
note: payload.note, note: payload.note,
attributes: payload.attributes attributes: payload.attributes
@@ -95,10 +97,28 @@ export const updateItem = async (req: Request, res: Response) => {
}); });
} }
let defaultImageId: string | null | undefined;
if ('defaultImageId' in payload) {
defaultImageId = payload.defaultImageId ?? null;
if (defaultImageId !== null) {
const image = await findImageById(defaultImageId);
if (!image) {
throw new HttpError(404, 'Default image not found');
}
if (image.itemId !== itemId) {
throw new HttpError(
400,
'defaultImageId must reference an image belonging to this item'
);
}
}
}
const item = await updateItemRepo(itemId, { const item = await updateItemRepo(itemId, {
manufacturer: payload.manufacturer, manufacturer: payload.manufacturer,
model: payload.model, model: payload.model,
sourceUrlId, sourceUrlId,
defaultImageId,
status: payload.status, status: payload.status,
note: payload.note, note: payload.note,
attributes: payload.attributes attributes: payload.attributes
+86
View File
@@ -0,0 +1,86 @@
import { query } from './pool.js';
export interface ImageRow {
id: string;
item_id: string;
url: string;
created_at: Date;
updated_at: Date;
}
export interface ImageRecord {
id: string;
itemId: string;
url: string;
createdAt: string;
updatedAt: string;
}
const mapImageRow = (row: ImageRow): ImageRecord => ({
id: row.id,
itemId: row.item_id,
url: row.url,
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString()
});
export const findImageById = async (id: string): Promise<ImageRecord | null> => {
const result = await query<ImageRow>(
`
SELECT id, item_id, url, created_at, updated_at
FROM images
WHERE id = $1
`,
[id]
);
if (result.rows.length === 0) {
return null;
}
return mapImageRow(result.rows[0]);
};
export const listImagesByItemId = async (itemId: string): Promise<ImageRecord[]> => {
const result = await query<ImageRow>(
`
SELECT id, item_id, url, created_at, updated_at
FROM images
WHERE item_id = $1
ORDER BY created_at ASC, id ASC
`,
[itemId]
);
return result.rows.map(mapImageRow);
};
export interface CreateImageParams {
itemId: string;
url: string;
}
export const createImage = async (params: CreateImageParams): Promise<ImageRecord> => {
const result = await query<ImageRow>(
`
INSERT INTO images (item_id, url)
VALUES ($1, $2)
RETURNING id, item_id, url, created_at, updated_at
`,
[params.itemId, params.url]
);
return mapImageRow(result.rows[0]);
};
export const deleteImage = async (id: string): Promise<boolean> => {
const result = await query(
`
DELETE FROM images
WHERE id = $1
`,
[id]
);
return (result.rowCount ?? 0) > 0;
};
+22 -5
View File
@@ -1,5 +1,7 @@
import type { Item, ItemPriceSummary, ItemStatus } from '@shared/models/item'; import type { Item, ItemPriceSummary, ItemStatus } from '@shared/models/item';
import type { ItemImage } from '@shared/models/item-image';
import { query } from './pool.js'; import { query } from './pool.js';
import { listImagesByItemId } from './images-repository.js';
const itemColumns = ` const itemColumns = `
i.id, i.id,
@@ -7,6 +9,7 @@ const itemColumns = `
i.manufacturer, i.manufacturer,
i.model, i.model,
i.source_url_id, i.source_url_id,
i.default_image_id,
i.status, i.status,
i.note, i.note,
i.attributes, i.attributes,
@@ -34,6 +37,7 @@ export interface ItemRow {
manufacturer: string | null; manufacturer: string | null;
model: string; model: string;
source_url_id: string | null; source_url_id: string | null;
default_image_id: string | null;
status: ItemStatus; status: ItemStatus;
note: string | null; note: string | null;
attributes: Record<string, unknown>; attributes: Record<string, unknown>;
@@ -57,7 +61,7 @@ export interface ItemRow {
export type ItemRecord = Item; export type ItemRecord = Item;
const mapItemRow = (row: ItemRow): ItemRecord => { const mapItemRow = (row: ItemRow, images?: ItemImage[]): ItemRecord => {
const priceCount = row.price_count ?? 0; const priceCount = row.price_count ?? 0;
const priceSummary: ItemPriceSummary | null = const priceSummary: ItemPriceSummary | null =
priceCount > 0 && row.price_min_amount !== null && row.price_max_amount !== null priceCount > 0 && row.price_min_amount !== null && row.price_max_amount !== null
@@ -86,13 +90,15 @@ const mapItemRow = (row: ItemRow): ItemRecord => {
manufacturer: row.manufacturer, manufacturer: row.manufacturer,
model: row.model, model: row.model,
sourceUrlId: row.source_url_id, sourceUrlId: row.source_url_id,
defaultImageId: row.default_image_id,
sourceUrl: row.source_url, sourceUrl: row.source_url,
status: row.status, status: row.status,
note: row.note, note: row.note,
attributes: row.attributes ?? {}, attributes: row.attributes ?? {},
createdAt: row.created_at.toISOString(), createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(), updatedAt: row.updated_at.toISOString(),
priceSummary priceSummary,
images: images && images.length ? images : images ? [] : undefined
}; };
}; };
@@ -148,7 +154,7 @@ export const listItems = async (options: ListItemsOptions): Promise<ItemRecord[]
params params
); );
return result.rows.map(mapItemRow); return result.rows.map((row) => mapItemRow(row));
}; };
export interface CreateItemParams { export interface CreateItemParams {
@@ -156,6 +162,7 @@ export interface CreateItemParams {
manufacturer: string | null; manufacturer: string | null;
model: string; model: string;
sourceUrlId: string | null; sourceUrlId: string | null;
defaultImageId: string | null;
status: ItemStatus; status: ItemStatus;
note: string | null; note: string | null;
attributes: Record<string, unknown>; attributes: Record<string, unknown>;
@@ -169,11 +176,12 @@ export const createItem = async (params: CreateItemParams): Promise<ItemRecord>
manufacturer, manufacturer,
model, model,
source_url_id, source_url_id,
default_image_id,
status, status,
note, note,
attributes attributes
) )
VALUES ($1, $2, $3, $4, $5, $6, $7) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id RETURNING id
`, `,
[ [
@@ -181,6 +189,7 @@ export const createItem = async (params: CreateItemParams): Promise<ItemRecord>
params.manufacturer, params.manufacturer,
params.model, params.model,
params.sourceUrlId, params.sourceUrlId,
params.defaultImageId,
params.status, params.status,
params.note, params.note,
params.attributes params.attributes
@@ -233,13 +242,16 @@ export const getItemById = async (id: string): Promise<ItemRecord | null> => {
return null; return null;
} }
return mapItemRow(result.rows[0]); const images = await listImagesByItemId(id);
return mapItemRow(result.rows[0], images);
}; };
export interface UpdateItemParams { export interface UpdateItemParams {
manufacturer?: string | null; manufacturer?: string | null;
model?: string; model?: string;
sourceUrlId?: string | null; sourceUrlId?: string | null;
defaultImageId?: string | null;
status?: ItemStatus; status?: ItemStatus;
note?: string | null; note?: string | null;
attributes?: Record<string, unknown>; attributes?: Record<string, unknown>;
@@ -267,6 +279,11 @@ export const updateItem = async (
fields.push(`source_url_id = $${values.length}`); fields.push(`source_url_id = $${values.length}`);
} }
if (params.defaultImageId !== undefined) {
values.push(params.defaultImageId);
fields.push(`default_image_id = $${values.length}`);
}
if (params.status !== undefined) { if (params.status !== undefined) {
values.push(params.status); values.push(params.status);
fields.push(`status = $${values.length}`); fields.push(`status = $${values.length}`);
@@ -0,0 +1,22 @@
import type { Migration } from './types.js';
export const migration: Migration = {
name: '0006-create-images-table',
up: async (client) => {
await client.query('CREATE EXTENSION IF NOT EXISTS "pgcrypto";');
await client.query(`
CREATE TABLE IF NOT EXISTS images (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE,
url TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`);
await client.query(`
CREATE INDEX IF NOT EXISTS images_item_id_idx ON images (item_id);
`);
}
};
@@ -0,0 +1,16 @@
import type { Migration } from './types.js';
export const migration: Migration = {
name: '0007-add-default-image-to-items',
up: async (client) => {
await client.query(`
ALTER TABLE items
ADD COLUMN IF NOT EXISTS default_image_id UUID REFERENCES images(id) ON DELETE SET NULL
`);
await client.query(`
CREATE INDEX IF NOT EXISTS items_default_image_id_idx
ON items (default_image_id)
`);
}
};
+4
View File
@@ -4,6 +4,8 @@ import { migration as createProjectsTable } from './0002-create-projects-table.j
import { migration as createUrlsTable } from './0003-create-urls-table.js'; import { migration as createUrlsTable } from './0003-create-urls-table.js';
import { migration as createItemsTable } from './0004-create-items-table.js'; import { migration as createItemsTable } from './0004-create-items-table.js';
import { migration as createItemPricesTable } from './0005-create-item-prices-table.js'; import { migration as createItemPricesTable } from './0005-create-item-prices-table.js';
import { migration as createImagesTable } from './0006-create-images-table.js';
import { migration as addDefaultImageToItems } from './0007-add-default-image-to-items.js';
export const migrations: Migration[] = [ export const migrations: Migration[] = [
enablePgvector, enablePgvector,
@@ -11,4 +13,6 @@ export const migrations: Migration[] = [
createUrlsTable, createUrlsTable,
createItemsTable, createItemsTable,
createItemPricesTable, createItemPricesTable,
createImagesTable,
addDefaultImageToItems,
]; ];
+10
View File
@@ -7,6 +7,11 @@ import {
listItems, listItems,
updateItem updateItem
} from '../controllers/items-controller.js'; } from '../controllers/items-controller.js';
import {
createItemImage,
deleteItemImage,
listItemImages
} from '../controllers/images-controller.js';
import { import {
createItemPrice, createItemPrice,
deleteItemPrice, deleteItemPrice,
@@ -42,6 +47,11 @@ apiRouter.get('/items/:itemId', asyncHandler(getItem));
apiRouter.patch('/items/:itemId', asyncHandler(updateItem)); apiRouter.patch('/items/:itemId', asyncHandler(updateItem));
apiRouter.delete('/items/:itemId', asyncHandler(deleteItem)); apiRouter.delete('/items/:itemId', asyncHandler(deleteItem));
// Item images
apiRouter.get('/items/:itemId/images', asyncHandler(listItemImages));
apiRouter.post('/items/:itemId/images', asyncHandler(createItemImage));
apiRouter.delete('/images/:imageId', asyncHandler(deleteItemImage));
// Item prices // Item prices
apiRouter.get('/items/:itemId/prices', asyncHandler(listItemPrices)); apiRouter.get('/items/:itemId/prices', asyncHandler(listItemPrices));
apiRouter.post('/items/:itemId/prices', asyncHandler(createItemPrice)); apiRouter.post('/items/:itemId/prices', asyncHandler(createItemPrice));
+34
View File
@@ -0,0 +1,34 @@
import { HttpError } from '../errors/http-error.js';
export interface ImageCreateInput {
url: string;
}
export const parseImageCreatePayload = (payload: unknown): ImageCreateInput => {
if (payload === null || typeof payload !== 'object') {
throw new HttpError(400, 'Body must be an object');
}
const { url } = payload as Record<string, unknown>;
if (typeof url !== 'string' || url.trim().length === 0) {
throw new HttpError(400, 'url must be a non-empty string');
}
const trimmed = url.trim();
if (trimmed.length > 2048) {
throw new HttpError(400, 'url must be 2048 characters or fewer');
}
try {
const parsed = new URL(trimmed);
if (!['http:', 'https:'].includes(parsed.protocol)) {
throw new HttpError(400, 'url must use http or https scheme');
}
} catch {
throw new HttpError(400, 'url must be a valid URL');
}
return { url: trimmed };
};
+33 -3
View File
@@ -23,6 +23,7 @@ export interface ItemCreateInput {
model: string; model: string;
sourceUrlId: string | null; sourceUrlId: string | null;
sourceUrl: string | null; sourceUrl: string | null;
defaultImageId: string | null;
status: ItemStatus; status: ItemStatus;
note: string | null; note: string | null;
attributes: Record<string, unknown>; attributes: Record<string, unknown>;
@@ -43,7 +44,8 @@ export const parseItemCreatePayload = (payload: unknown): ItemCreateInput => {
note = null, note = null,
attributes, attributes,
sourceUrl, sourceUrl,
sourceUrlId sourceUrlId,
defaultImageId
} = payload as Record<string, unknown>; } = payload as Record<string, unknown>;
const manufacturerValue = const manufacturerValue =
@@ -81,6 +83,16 @@ export const parseItemCreatePayload = (payload: unknown): ItemCreateInput => {
normalizedSourceUrl = sourceUrl; normalizedSourceUrl = sourceUrl;
} }
if (defaultImageId !== undefined && defaultImageId !== null) {
if (typeof defaultImageId !== 'string' || defaultImageId.trim().length === 0) {
throw new HttpError(
400,
'defaultImageId must be null when creating an item'
);
}
throw new HttpError(400, 'defaultImageId cannot be set when creating an item');
}
return { return {
manufacturer: manufacturer:
manufacturerValue === null ? null : (manufacturerValue as string).trim(), manufacturerValue === null ? null : (manufacturerValue as string).trim(),
@@ -89,7 +101,8 @@ export const parseItemCreatePayload = (payload: unknown): ItemCreateInput => {
note: note === null ? null : (note as string), note: note === null ? null : (note as string),
attributes: ensureAttributesObject(attributes), attributes: ensureAttributesObject(attributes),
sourceUrlId: normalizedSourceUrlId, sourceUrlId: normalizedSourceUrlId,
sourceUrl: normalizedSourceUrl sourceUrl: normalizedSourceUrl,
defaultImageId: null
}; };
}; };
@@ -107,7 +120,8 @@ export const parseItemUpdatePayload = (payload: unknown): ItemUpdateInput => {
note, note,
attributes, attributes,
sourceUrl, sourceUrl,
sourceUrlId sourceUrlId,
defaultImageId
} = payload as Record<string, unknown>; } = payload as Record<string, unknown>;
if (manufacturer !== undefined || brand !== undefined) { if (manufacturer !== undefined || brand !== undefined) {
@@ -170,6 +184,22 @@ export const parseItemUpdatePayload = (payload: unknown): ItemUpdateInput => {
} }
} }
if (defaultImageId !== undefined) {
if (defaultImageId === null) {
result.defaultImageId = null;
} else if (
typeof defaultImageId === 'string' &&
defaultImageId.trim().length > 0
) {
result.defaultImageId = parseUuid(defaultImageId, 'defaultImageId');
} else {
throw new HttpError(
400,
'defaultImageId must be a non-empty string or null'
);
}
}
if (Object.keys(result).length === 0) { if (Object.keys(result).length === 0) {
throw new HttpError(400, 'At least one field must be provided for update'); throw new HttpError(400, 'At least one field must be provided for update');
} }
+7
View File
@@ -0,0 +1,7 @@
export interface ItemImage {
id: string;
itemId: string;
url: string;
createdAt: string;
updatedAt: string;
}
+4
View File
@@ -16,6 +16,8 @@ export interface ItemPriceSummary {
usedHasMixedCurrency: boolean; usedHasMixedCurrency: boolean;
} }
import type { ItemImage } from './item-image';
export interface Item { export interface Item {
id: string; id: string;
projectId: string; projectId: string;
@@ -23,10 +25,12 @@ export interface Item {
model: string; model: string;
sourceUrlId: string | null; sourceUrlId: string | null;
sourceUrl: string | null; sourceUrl: string | null;
defaultImageId: string | null;
status: ItemStatus; status: ItemStatus;
note: string | null; note: string | null;
attributes: Record<string, unknown>; attributes: Record<string, unknown>;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
priceSummary: ItemPriceSummary | null; priceSummary: ItemPriceSummary | null;
images?: ItemImage[];
} }