Add ability to upload and manage service images

Integrates Uppy.js for file uploads, adds new API endpoints for requesting upload URLs, and updates UI components to support image uploads.
This commit is contained in:
Riyadh
2026-04-20 10:55:11 +00:00
parent ef76e2dc15
commit 0efc4032be
23 changed files with 3479 additions and 358 deletions
+7
View File
@@ -76,5 +76,12 @@
"vite": "catalog:",
"wouter": "^3.3.5",
"zod": "catalog:"
},
"dependencies": {
"@uppy/aws-s3": "^5.1.0",
"@uppy/core": "^5.2.0",
"@uppy/dashboard": "^5.1.1",
"@uppy/react": "^5.2.0",
"@workspace/object-storage-web": "workspace:*"
}
}
+5
View File
@@ -80,6 +80,11 @@
"servicePrice": "السعر",
"serviceAvailable": "متاح؟",
"serviceImageUrl": "رابط الصورة",
"serviceImage": "صورة الخدمة",
"uploadImage": "رفع صورة",
"replaceImage": "استبدال الصورة",
"uploading": "جاري الرفع...",
"uploadFailed": "فشل رفع الصورة",
"users": "المستخدمين",
"username": "اسم المستخدم",
"email": "البريد الإلكتروني",
+5
View File
@@ -80,6 +80,11 @@
"servicePrice": "Price",
"serviceAvailable": "Available?",
"serviceImageUrl": "Image URL",
"serviceImage": "Service Image",
"uploadImage": "Upload Image",
"replaceImage": "Replace Image",
"uploading": "Uploading...",
"uploadFailed": "Image upload failed",
"users": "Users",
"username": "Username",
"email": "Email",
+83 -13
View File
@@ -20,7 +20,8 @@ import {
} from "@workspace/api-client-react";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/contexts/AuthContext";
import { ArrowRight, ArrowLeft, Settings, Plus, Pencil, Trash2, Shield } from "lucide-react";
import { useUpload } from "@workspace/object-storage-web";
import { ArrowRight, ArrowLeft, Settings, Plus, Pencil, Trash2, Shield, Upload, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -221,7 +222,6 @@ export default function AdminPage() {
{ field: "descriptionAr", label: t("admin.serviceDescriptionAr") },
{ field: "descriptionEn", label: t("admin.serviceDescriptionEn") },
{ field: "price", label: t("admin.servicePrice") },
{ field: "imageUrl", label: t("admin.serviceImageUrl") },
] as { field: keyof ServiceForm; label: string }[]
).map(({ field, label }) => (
<div key={field} className="space-y-1">
@@ -230,20 +230,22 @@ export default function AdminPage() {
value={String(editingService.form[field])}
onChange={(e) => setEditingService({ ...editingService, form: { ...editingService.form, [field]: e.target.value } })}
className="bg-white/70 border-slate-200"
placeholder={field === "imageUrl" ? "https://..." : undefined}
/>
</div>
))}
{editingService.form.imageUrl && (
<div className="rounded-xl overflow-hidden border border-slate-200 bg-slate-50">
<img
src={editingService.form.imageUrl}
alt=""
className="w-full h-32 object-cover"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}
/>
</div>
)}
<div className="space-y-2">
<Label>{t("admin.serviceImage")}</Label>
<ServiceImageUploader
value={editingService.form.imageUrl}
onChange={(url) =>
setEditingService({
...editingService,
form: { ...editingService.form, imageUrl: url },
})
}
/>
</div>
<div className="flex items-center justify-between">
<Label>{t("admin.serviceAvailable")}</Label>
<Switch
@@ -491,3 +493,71 @@ export default function AdminPage() {
</div>
);
}
function ServiceImageUploader({
value,
onChange,
}: {
value: string;
onChange: (url: string) => void;
}) {
const { t } = useTranslation();
const { toast } = useToast();
const { uploadFile, isUploading, progress } = useUpload({
onSuccess: (resp) => {
onChange(`/api/storage${resp.objectPath}`);
},
onError: (err) => {
toast({ title: t("admin.uploadFailed"), description: err.message, variant: "destructive" });
},
});
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<label className="cursor-pointer">
<input
type="file"
accept="image/*"
disabled={isUploading}
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
void uploadFile(file);
}
e.currentTarget.value = "";
}}
/>
<span className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-slate-200 bg-white text-sm text-slate-700 hover:bg-slate-50 transition">
{isUploading ? <Loader2 size={16} className="animate-spin" /> : <Upload size={16} />}
{isUploading
? `${t("admin.uploading")} ${progress}%`
: value
? t("admin.replaceImage")
: t("admin.uploadImage")}
</span>
</label>
{value && !isUploading && (
<button
type="button"
onClick={() => onChange("")}
className="px-3 py-2 rounded-lg border border-slate-200 bg-white text-sm text-slate-500 hover:text-destructive hover:border-destructive/40"
>
<Trash2 size={14} />
</button>
)}
</div>
{value && (
<div className="rounded-xl overflow-hidden border border-slate-200 bg-slate-50">
<img
src={value}
alt=""
className="w-full h-32 object-cover"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}
/>
</div>
)}
</div>
);
}
+3
View File
@@ -17,6 +17,9 @@
"references": [
{
"path": "../../lib/api-client-react"
},
{
"path": "../../lib/object-storage-web"
}
]
}