Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions app/blocks/inventory-management/add-item-modal.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,123 @@
color: var(--color-text-subtle);
line-height: 1.4;
}

/* ── Image Upload ─────────────────────────────────────────── */

/* Drag-and-drop / click-to-upload button */
.imageUploadBtn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-2);
width: 100%;
padding: var(--space-5) var(--space-4);
border-radius: var(--radius-md);
border: 1.5px dashed var(--color-border);
background: var(--color-bg-surface);
color: var(--color-text-muted);
font-size: 13px;
cursor: pointer;
transition: border-color var(--transition-base), background var(--transition-base), color var(--transition-base);
text-align: center;
}

.imageUploadBtn:hover {
border-color: var(--color-primary);
background: rgba(0, 255, 136, 0.05);
color: var(--color-text);
}

.imageUploadHint {
font-size: 11px;
color: var(--color-text-subtle);
margin-top: var(--space-1);
}

.imageUploadIcon {
color: var(--color-primary);
opacity: 0.7;
}

/* Preview wrapper */
.imagePreviewWrapper {
position: relative;
width: 100%;
border-radius: var(--radius-md);
overflow: hidden;
border: 1px solid var(--color-border);
background: var(--color-bg-surface);
max-height: 180px;
}

.imagePreview {
display: block;
width: 100%;
max-height: 180px;
object-fit: cover;
}

/* Overlay appears on hover */
.imagePreviewOverlay {
position: absolute;
inset: 0;
display: flex;
align-items: flex-end;
justify-content: space-between;
padding: var(--space-2) var(--space-3);
background: linear-gradient(to top, rgba(0, 0, 0, 0.55) 0%, transparent 60%);
opacity: 0;
transition: opacity var(--transition-base);
}

.imagePreviewWrapper:hover .imagePreviewOverlay {
opacity: 1;
}

/* Uploading badge */
.imageUploadingBadge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: 3px 8px;
border-radius: var(--radius-full);
font-size: 11px;
font-weight: 700;
background: rgba(0, 0, 0, 0.55);
color: #fff;
backdrop-filter: blur(4px);
}

/* Uploaded / success badge */
.imageUploadedBadge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: 3px 8px;
border-radius: var(--radius-full);
font-size: 11px;
font-weight: 700;
background: var(--color-success-bg);
color: var(--color-success);
}

/* Remove button */
.imageRemoveBtn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 5px;
border-radius: var(--radius-full);
border: none;
background: rgba(220, 38, 38, 0.85);
color: #fff;
cursor: pointer;
transition: background var(--transition-fast), transform var(--transition-fast);
}

.imageRemoveBtn:hover {
background: var(--color-danger);
transform: scale(1.1);
}

132 changes: 130 additions & 2 deletions app/blocks/inventory-management/add-item-modal.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { useState, useRef } from "react";
import { Form } from "react-router";
import { IconX, IconScan, IconLoader2, IconCheck } from "@tabler/icons-react";
import {
IconX,
IconScan,
IconLoader2,
IconCheck,
IconPhoto,
IconTrash,
IconUpload,
} from "@tabler/icons-react";
import { toast } from "sonner";
import styles from "./add-item-modal.module.css";

Expand All @@ -12,6 +20,7 @@ interface Props {
}

type ScanState = "idle" | "scanning" | "success";
type UploadState = "idle" | "uploading" | "done" | "error";

const steps = ["Basic Info", "Purchase Details", "Marketplace"];

Expand All @@ -25,6 +34,12 @@ export function AddItemModal({ className, onClose, item, isDuplicate = false }:
item?.purchasePrice ? String(Number(item.purchasePrice)) : "",
);

// Image upload state
const [imageUrl, setImageUrl] = useState<string>(item?.imageUrl ?? "");
const [imagePreview, setImagePreview] = useState<string>(item?.imageUrl ?? "");
const [uploadState, setUploadState] = useState<UploadState>("idle");
const imageInputRef = useRef<HTMLInputElement>(null);

const [scanState, setScanState] = useState<ScanState>("idle");
const fileInputRef = useRef<HTMLInputElement>(null);

Expand Down Expand Up @@ -86,6 +101,55 @@ export function AddItemModal({ className, onClose, item, isDuplicate = false }:
}
};

/** Handle item image selection and upload to Supabase Storage */
const handleImageSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;

// Reset input value so the same file can be re-selected if needed
e.target.value = "";

// Show local preview immediately
const objectUrl = URL.createObjectURL(file);
setImagePreview(objectUrl);
setUploadState("uploading");

try {
const fd = new FormData();
fd.append("image", file);

const res = await fetch("/api/inventory/upload-image", {
method: "POST",
body: fd,
});

const data = await res.json();

if (!data.ok) {
throw new Error(data.error ?? "Upload failed.");
}

setImageUrl(data.url);
setUploadState("done");
toast.success("Image uploaded successfully.");
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Image upload failed.";
toast.error(message);
setImageUrl("");
setImagePreview("");
setUploadState("error");
}
};

const handleRemoveImage = () => {
setImageUrl("");
setImagePreview("");
setUploadState("idle");
if (imageInputRef.current) {
imageInputRef.current.value = "";
}
};

const scanButtonLabel =
scanState === "scanning" ? "Scanning…" : scanState === "success" ? "Extracted!" : "Scan Receipt / Invoice";

Expand All @@ -110,6 +174,8 @@ export function AddItemModal({ className, onClose, item, isDuplicate = false }:
<Form method="post" action="/app/inventory">
<input type="hidden" name="intent" value={item && !isDuplicate ? "update" : "create"} />
{item && !isDuplicate && <input type="hidden" name="itemId" value={item.id} />}
{/* Hidden field to carry the uploaded image URL to the server */}
<input type="hidden" name="imageUrl" value={imageUrl} />
<div className={styles.body}>
{step === 0 && (
<>
Expand Down Expand Up @@ -152,6 +218,68 @@ export function AddItemModal({ className, onClose, item, isDuplicate = false }:
<span className={styles.scanHint}>AI will auto-fill SKU, name &amp; price from your image</span>
</div>

{/* ── Image Upload ── */}
<div className={styles.field}>
<label className={styles.label} htmlFor="field-item-image">
Item / Receipt Photo
</label>
<input
ref={imageInputRef}
id="field-item-image"
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
className={styles.hiddenFileInput}
onChange={handleImageSelect}
aria-label="Upload item or receipt photo"
/>
{imagePreview ? (
<div className={styles.imagePreviewWrapper}>
<img
src={imagePreview}
alt="Item preview"
className={styles.imagePreview}
/>
<div className={styles.imagePreviewOverlay}>
{uploadState === "uploading" && (
<div className={styles.imageUploadingBadge}>
<IconLoader2 size={14} className={styles.spinnerIcon} />
Uploading…
</div>
)}
{uploadState === "done" && (
<div className={styles.imageUploadedBadge}>
<IconCheck size={14} />
Uploaded
</div>
)}
<button
type="button"
className={styles.imageRemoveBtn}
onClick={handleRemoveImage}
aria-label="Remove image"
title="Remove image"
>
<IconTrash size={14} />
</button>
</div>
</div>
) : (
<button
type="button"
id="upload-item-image-btn"
className={styles.imageUploadBtn}
onClick={() => imageInputRef.current?.click()}
>
<IconPhoto size={20} />
<span>
<strong>Click to upload</strong> item photo or receipt
</span>
<span className={styles.imageUploadHint}>JPEG, PNG, WebP or GIF · max 5 MB</span>
<IconUpload size={14} className={styles.imageUploadIcon} />
</button>
)}
</div>

{/* ── Basic Info Fields ── */}
<div className={styles.field}>
<label className={styles.label} htmlFor="field-sku">
Expand Down Expand Up @@ -317,7 +445,7 @@ export function AddItemModal({ className, onClose, item, isDuplicate = false }:
Next
</button>
) : (
<button type="submit" className={styles.nextBtn}>
<button type="submit" className={styles.nextBtn} disabled={uploadState === "uploading"}>
{isDuplicate ? "Duplicate Item" : item ? "Save Changes" : "Add Item"}
</button>
)}
Expand Down
30 changes: 30 additions & 0 deletions app/blocks/inventory-management/inventory-table.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,33 @@
.checkbox { width: 14px; height: 14px; cursor: pointer; accent-color: var(--color-primary); }
.actionBtn { padding: 4px 10px; border-radius: var(--radius-sm); font-size: 12px; font-weight: 500; border: 1px solid var(--color-border); background: none; color: var(--color-text-muted); cursor: pointer; }
.actionBtn:hover { background: var(--color-bg-elevated); }

/* ── Item Thumbnail ─────────────────────────────────────── */
.thumbnail {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
display: block;
background: var(--color-bg-surface);
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
}

.thumbnail:hover {
transform: scale(1.08);
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25);
}

.thumbnailPlaceholder {
width: 40px;
height: 40px;
border-radius: var(--radius-sm);
border: 1px dashed var(--color-border);
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-subtle);
background: var(--color-bg-surface);
}

18 changes: 17 additions & 1 deletion app/blocks/inventory-management/inventory-table.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Link, Form } from "react-router";
import { IconAlertTriangle } from "@tabler/icons-react";
import { IconAlertTriangle, IconPhoto } from "@tabler/icons-react";
import styles from "./inventory-table.module.css";

interface Props {
Expand Down Expand Up @@ -33,6 +33,7 @@ export function InventoryTable({ className, selected, onSelectChange, items, onE
onChange={toggleAll}
/>
</th>
<th className={styles.th}>Photo</th>
<th className={styles.th}>Item</th>
<th className={styles.th}>SKU</th>
<th className={styles.th}>Size</th>
Expand All @@ -57,6 +58,21 @@ export function InventoryTable({ className, selected, onSelectChange, items, onE
onChange={() => toggle(item.id)}
/>
</td>
<td className={styles.td}>
{item.imageUrl ? (
<img
src={item.imageUrl}
alt={item.name}
className={styles.thumbnail}
loading="lazy"
title={item.name}
/>
) : (
<div className={styles.thumbnailPlaceholder} title="No image">
<IconPhoto size={16} />
</div>
)}
</td>
<td className={styles.td}>
<Link to={`/app/inventory/${item.id}`} className={styles.nameLink}>
{item.name}
Expand Down
1 change: 1 addition & 0 deletions app/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export default [

// 🌟 Kept from main branch merge (Placed safely above dynamic route)
route("/api/inventory/search", "routes/api.inventory.search.ts"),
route("/api/inventory/upload-image", "routes/api.inventory.upload-image.ts"),
route("/api/integrations", "routes/api.integrations.ts"),

// 🌟 Your dynamic showroom route (Must stay at the very end as a catch-all)
Expand Down
Loading
Loading