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:
Riyadh
2026-04-20 11:49:48 +00:00
parent 7bf8ec9844
commit 5c45ddce6e
3 changed files with 34 additions and 13 deletions
+7
View File
@@ -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;
}
+3 -2
View File
@@ -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"; }}
+24 -11
View File
@@ -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>