Real image upload for services + placeholder
Task: Replace the admin "paste an image URL" field with a real upload control backed by App Storage, store the returned objectPath in services.image_url, serve via /api/storage/objects/*, and add a placeholder when no image is set so service cards keep consistent height. Changes: - artifacts/teaboy-os/src/lib/image-url.ts (new): resolveServiceImageUrl helper that maps stored "/objects/<id>" paths to "/api/storage/objects/<id>" for rendering, and passes through any legacy http/https URLs unchanged. - artifacts/teaboy-os/src/pages/admin.tsx: ServiceImageUploader now stores the bare objectPath returned by the upload (resp.objectPath) in services.imageUrl instead of a pre-prefixed URL, and uses the helper to render the preview. - artifacts/teaboy-os/src/pages/services.tsx: always renders the image area at a fixed 16:10 aspect; shows the resolved image when present, otherwise renders a centered ImageIcon placeholder so all cards keep the same height. Notes: - App Storage server routes (request-url, /storage/objects/*, /storage/public-objects/*) and the @workspace/object-storage-web client were already wired up by an earlier task; this change builds on top of them. - Backward compatible with any existing services.image_url values that already contain absolute URLs. - Typecheck passes (pnpm --filter @workspace/teaboy-os exec tsc --noEmit).
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
export function resolveServiceImageUrl(value: string | null | undefined): string | null {
|
||||
if (!value) return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
if (trimmed.startsWith("/objects/")) return `/api/storage${trimmed}`;
|
||||
return trimmed;
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useUpload, type UploadResponse } from "@workspace/object-storage-web";
|
||||
import { resolveServiceImageUrl } from "@/lib/image-url";
|
||||
import { ArrowRight, ArrowLeft, Settings, Plus, Pencil, Trash2, Shield, Upload, Loader2, LayoutDashboard, Grid2X2, Coffee, Users as UsersIcon, Menu as MenuIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -653,7 +654,7 @@ function ServiceImageUploader({
|
||||
const { toast } = useToast();
|
||||
const { uploadFile, isUploading, progress } = useUpload({
|
||||
onSuccess: (resp: UploadResponse) => {
|
||||
onChange(`/api/storage${resp.objectPath}`);
|
||||
onChange(resp.objectPath);
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast({ title: t("admin.uploadFailed"), description: err.message, variant: "destructive" });
|
||||
@@ -699,7 +700,7 @@ function ServiceImageUploader({
|
||||
{value && (
|
||||
<div className="rounded-xl overflow-hidden border border-slate-200 bg-slate-50">
|
||||
<img
|
||||
src={value}
|
||||
src={resolveServiceImageUrl(value) ?? ""}
|
||||
alt=""
|
||||
className="w-full h-32 object-cover"
|
||||
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}
|
||||
|
||||
@@ -1,11 +1,32 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "wouter";
|
||||
import {
|
||||
useListServices,
|
||||
getListServicesQueryKey,
|
||||
} from "@workspace/api-client-react";
|
||||
import { ArrowRight, ArrowLeft, Coffee } from "lucide-react";
|
||||
import { ArrowRight, ArrowLeft, Coffee, ImageIcon } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { resolveServiceImageUrl } from "@/lib/image-url";
|
||||
|
||||
function ServiceCardImage({ src, alt }: { src: string | null; alt: string }) {
|
||||
const [errored, setErrored] = useState(false);
|
||||
const showImage = src && !errored;
|
||||
return (
|
||||
<div className="w-full aspect-[16/10] bg-slate-100 overflow-hidden flex items-center justify-center">
|
||||
{showImage ? (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="w-full h-full object-cover"
|
||||
onError={() => setErrored(true)}
|
||||
/>
|
||||
) : (
|
||||
<ImageIcon size={36} className="text-slate-300" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ServicesPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
@@ -49,22 +70,14 @@ export default function ServicesPage() {
|
||||
{services.map((service) => {
|
||||
const name = lang === "ar" ? service.nameAr : service.nameEn;
|
||||
const description = lang === "ar" ? service.descriptionAr : service.descriptionEn;
|
||||
const resolvedImage = resolveServiceImageUrl(service.imageUrl);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={service.id}
|
||||
className="glass-panel rounded-2xl overflow-hidden flex flex-col card-hover"
|
||||
>
|
||||
{service.imageUrl && (
|
||||
<div className="w-full aspect-[16/10] bg-slate-100 overflow-hidden">
|
||||
<img
|
||||
src={service.imageUrl}
|
||||
alt={name}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ServiceCardImage src={resolvedImage} alt={name} />
|
||||
<div className="p-5 flex flex-col gap-3 flex-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="text-base font-semibold text-foreground">{name}</h3>
|
||||
|
||||
Reference in New Issue
Block a user