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>;
|
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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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,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,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
export interface ItemImage {
|
||||||
|
id: string;
|
||||||
|
itemId: string;
|
||||||
|
url: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
Vendored
+4
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user