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:
@@ -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:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,11 @@
|
||||
"servicePrice": "السعر",
|
||||
"serviceAvailable": "متاح؟",
|
||||
"serviceImageUrl": "رابط الصورة",
|
||||
"serviceImage": "صورة الخدمة",
|
||||
"uploadImage": "رفع صورة",
|
||||
"replaceImage": "استبدال الصورة",
|
||||
"uploading": "جاري الرفع...",
|
||||
"uploadFailed": "فشل رفع الصورة",
|
||||
"users": "المستخدمين",
|
||||
"username": "اسم المستخدم",
|
||||
"email": "البريد الإلكتروني",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
"references": [
|
||||
{
|
||||
"path": "../../lib/api-client-react"
|
||||
},
|
||||
{
|
||||
"path": "../../lib/object-storage-web"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user