fix(ads): render image in AdCard feed, add image upload to ad dialog

This commit is contained in:
admin committed 2026-06-21 16:21:38 +02:00
1 parent 0f34a2c9c6
commit 6f671cfc63
3 files changed
+228 -43

No files matched your search

+162 -32
View File
@@ -1,15 +1,17 @@
"use client";
import { useState } from "react";
import { useState, useRef, useCallback } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
import { Plus, Pencil, Trash2, Loader2, Check, X } from "lucide-react";
import { getMediaUrl } from "@/lib/media-url";
import { Plus, Pencil, Trash2, Loader2, Check, X, ImagePlus, ImageOff } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
type AdForm = {
title: string;
@@ -18,6 +20,7 @@ type AdForm = {
isActive: boolean;
startDate: string;
endDate: string;
imageKey: string;
};
const emptyForm: AdForm = {
@@ -27,8 +30,13 @@ const emptyForm: AdForm = {
isActive: true,
startDate: "",
endDate: "",
imageKey: "",
};
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"] as const;
type AllowedType = (typeof ALLOWED_TYPES)[number];
const MAX_SIZE = 10 * 1024 * 1024;
export default function AdminAdsPage() {
const trpc = useTRPC();
const qc = useQueryClient();
@@ -36,20 +44,23 @@ export default function AdminAdsPage() {
const [editingId, setEditingId] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [imageUploading, setImageUploading] = useState(false);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const { data: rawAds, isLoading } = useQuery(trpc.admin.listAds.queryOptions());
const ads = rawAds ?? [];
const invalidate = () => void qc.invalidateQueries({ queryKey: trpc.admin.listAds.queryKey() });
const getPresignedUrl = useMutation(trpc.admin.getAdImagePresignedUrl.mutationOptions());
const createAd = useMutation(
trpc.admin.createAd.mutationOptions({ onSuccess: () => { invalidate(); closeDialog(); } })
);
const updateAd = useMutation(
trpc.admin.updateAd.mutationOptions({ onSuccess: () => { invalidate(); closeDialog(); } })
);
const deleteAd = useMutation(
trpc.admin.deleteAd.mutationOptions({ onSuccess: () => { invalidate(); setDeleteId(null); } })
);
@@ -57,6 +68,7 @@ export default function AdminAdsPage() {
function openCreate() {
setForm(emptyForm);
setEditingId(null);
setImagePreview(null);
setDialogOpen(true);
}
@@ -68,8 +80,10 @@ export default function AdminAdsPage() {
isActive: ad.isActive,
startDate: ad.startDate ? new Date(ad.startDate).toISOString().slice(0, 16) : "",
endDate: ad.endDate ? new Date(ad.endDate).toISOString().slice(0, 16) : "",
imageKey: ad.imageKey ?? "",
});
setEditingId(ad.id);
setImagePreview(ad.imageKey ? (getMediaUrl(ad.imageKey) ?? null) : null);
setDialogOpen(true);
}
@@ -77,6 +91,7 @@ export default function AdminAdsPage() {
setDialogOpen(false);
setEditingId(null);
setForm(emptyForm);
setImagePreview(null);
}
function submitForm() {
@@ -85,6 +100,7 @@ export default function AdminAdsPage() {
caption: form.caption || undefined,
targetUrl: form.targetUrl || undefined,
isActive: form.isActive,
imageKey: form.imageKey || undefined,
startDate: form.startDate ? new Date(form.startDate).toISOString() : undefined,
endDate: form.endDate ? new Date(form.endDate).toISOString() : undefined,
};
@@ -95,6 +111,54 @@ export default function AdminAdsPage() {
}
}
const uploadImage = useCallback(
async (file: File) => {
if (file.size > MAX_SIZE) {
toast.error("Bild muss unter 10 MB sein.");
return;
}
if (!ALLOWED_TYPES.includes(file.type as AllowedType)) {
toast.error("Nur JPG, PNG oder WebP erlaubt.");
return;
}
setImageUploading(true);
try {
const { presignedUrl, key } = await getPresignedUrl.mutateAsync({
contentType: file.type as AllowedType,
fileSize: file.size,
});
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.onload = () =>
xhr.status >= 200 && xhr.status < 300 ? resolve() : reject(new Error(`Upload failed: ${xhr.status}`));
xhr.onerror = () => reject(new Error("Network error"));
xhr.open("PUT", presignedUrl);
xhr.setRequestHeader("Content-Type", file.type);
xhr.send(file);
});
// Show local preview immediately
const localUrl = URL.createObjectURL(file);
setImagePreview(localUrl);
setForm((f) => ({ ...f, imageKey: key }));
toast.success("Bild hochgeladen");
} catch {
toast.error("Upload fehlgeschlagen — bitte erneut versuchen");
} finally {
setImageUploading(false);
}
},
[getPresignedUrl]
);
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (file) void uploadImage(file);
e.target.value = "";
}
const isPending = createAd.isPending || updateAd.isPending;
return (
@@ -112,7 +176,7 @@ export default function AdminAdsPage() {
<Loader2 className="h-5 w-5 animate-spin text-zinc-500" />
</div>
) : ads.length === 0 ? (
<p className="text-sm text-zinc-500 text-center py-12">No ads yet — create one above</p>
<p className="text-sm text-zinc-500 text-center py-12">Noch keine Ads — oben erstellen</p>
) : (
<div className="space-y-2">
{ads.map((ad) => (
@@ -120,13 +184,27 @@ export default function AdminAdsPage() {
key={ad.id}
className="flex items-center gap-4 rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-3"
>
{/* Thumbnail */}
{ad.imageKey ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={getMediaUrl(ad.imageKey) ?? ""}
alt=""
className="h-12 w-12 rounded-lg object-cover shrink-0"
/>
) : (
<div className="h-12 w-12 rounded-lg bg-zinc-800 flex items-center justify-center shrink-0">
<ImageOff className="h-5 w-5 text-zinc-600" />
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-zinc-100 truncate">{ad.title}</p>
{ad.isActive ? (
<Badge className="bg-green-700 text-green-100 text-[10px]">Active</Badge>
<Badge className="bg-green-700 text-green-100 text-[10px]">Aktiv</Badge>
) : (
<Badge variant="secondary" className="text-[10px]">Inactive</Badge>
<Badge variant="secondary" className="text-[10px]">Inaktiv</Badge>
)}
</div>
{ad.caption && (
@@ -136,21 +214,12 @@ export default function AdminAdsPage() {
<p className="text-xs text-zinc-600 truncate">{ad.targetUrl}</p>
)}
</div>
<div className="flex gap-2 shrink-0">
<Button
size="sm"
variant="ghost"
className="text-zinc-400 hover:text-zinc-100"
onClick={() => openEdit(ad)}
>
<Button size="sm" variant="ghost" className="text-zinc-400 hover:text-zinc-100" onClick={() => openEdit(ad)}>
<Pencil className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
className="text-red-400 hover:text-red-300"
onClick={() => setDeleteId(ad.id)}
>
<Button size="sm" variant="ghost" className="text-red-400 hover:text-red-300" onClick={() => setDeleteId(ad.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
@@ -163,11 +232,71 @@ export default function AdminAdsPage() {
<Dialog open={dialogOpen} onOpenChange={(o) => { if (!o) closeDialog(); }}>
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-md">
<DialogHeader>
<DialogTitle>{editingId ? "Edit ad" : "Create ad"}</DialogTitle>
<DialogTitle>{editingId ? "Ad bearbeiten" : "Ad erstellen"}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
{/* Image upload zone */}
<div>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={handleFileChange}
/>
{imagePreview ? (
<div className="relative group cursor-pointer rounded-xl overflow-hidden" onClick={() => fileInputRef.current?.click()}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imagePreview}
alt="Ad preview"
className="w-full h-40 object-cover"
/>
<div className="absolute inset-0 flex flex-col items-center justify-center gap-1 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity">
{imageUploading ? (
<Loader2 className="h-6 w-6 text-white animate-spin" />
) : (
<>
<ImagePlus className="h-6 w-6 text-white" />
<span className="text-xs text-white font-medium">Bild ändern</span>
</>
)}
</div>
{/* Remove button */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setImagePreview(null);
setForm((f) => ({ ...f, imageKey: "" }));
}}
className="absolute top-2 right-2 h-6 w-6 rounded-full bg-black/60 flex items-center justify-center text-white hover:bg-black/80"
>
<X className="h-3 w-3" />
</button>
</div>
) : (
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={imageUploading}
className="w-full h-28 rounded-xl border-2 border-dashed border-zinc-700 hover:border-zinc-500 flex flex-col items-center justify-center gap-2 text-zinc-500 hover:text-zinc-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{imageUploading ? (
<Loader2 className="h-6 w-6 animate-spin" />
) : (
<>
<ImagePlus className="h-6 w-6" />
<span className="text-xs">Bild hochladen (JPG, PNG, WebP · max 10 MB)</span>
</>
)}
</button>
)}
</div>
<Input
placeholder="Title *"
placeholder="Titel *"
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500"
@@ -180,14 +309,14 @@ export default function AdminAdsPage() {
rows={3}
/>
<Input
placeholder="Target URL (https://…)"
placeholder="Ziel-URL (https://…)"
value={form.targetUrl}
onChange={(e) => setForm({ ...form, targetUrl: e.target.value })}
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500"
/>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="text-xs text-zinc-500">Start date</label>
<label className="text-xs text-zinc-500">Startdatum</label>
<Input
type="datetime-local"
value={form.startDate}
@@ -196,7 +325,7 @@ export default function AdminAdsPage() {
/>
</div>
<div className="space-y-1">
<label className="text-xs text-zinc-500">End date</label>
<label className="text-xs text-zinc-500">Enddatum</label>
<Input
type="datetime-local"
value={form.endDate}
@@ -210,17 +339,18 @@ export default function AdminAdsPage() {
checked={form.isActive}
onCheckedChange={(v: boolean) => setForm({ ...form, isActive: v })}
/>
<span className="text-sm text-zinc-300">Active</span>
<span className="text-sm text-zinc-300">Aktiv</span>
</div>
</div>
<DialogFooter className="gap-2 mt-2">
<Button variant="ghost" className="text-zinc-400" onClick={closeDialog}>
<X className="h-4 w-4 mr-1.5" />
Cancel
Abbrechen
</Button>
<Button
onClick={submitForm}
disabled={isPending || !form.title.trim()}
disabled={isPending || imageUploading || !form.title.trim()}
className="bg-orange-500 hover:bg-orange-600 text-white"
>
{isPending ? (
@@ -228,7 +358,7 @@ export default function AdminAdsPage() {
) : (
<Check className="h-4 w-4 mr-1.5" />
)}
{editingId ? "Save" : "Create"}
{editingId ? "Speichern" : "Erstellen"}
</Button>
</DialogFooter>
</DialogContent>
@@ -238,12 +368,12 @@ export default function AdminAdsPage() {
<Dialog open={!!deleteId} onOpenChange={(o) => { if (!o) setDeleteId(null); }}>
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-sm">
<DialogHeader>
<DialogTitle>Delete ad</DialogTitle>
<DialogTitle>Ad löschen</DialogTitle>
</DialogHeader>
<p className="text-sm text-zinc-400">Delete this advertisement permanently?</p>
<p className="text-sm text-zinc-400">Diese Anzeige dauerhaft löschen?</p>
<DialogFooter className="gap-2">
<Button variant="ghost" className="text-zinc-400" onClick={() => setDeleteId(null)}>
Cancel
Abbrechen
</Button>
<Button
variant="destructive"
@@ -251,7 +381,7 @@ export default function AdminAdsPage() {
disabled={deleteAd.isPending}
>
{deleteAd.isPending && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
Delete
Löschen
</Button>
</DialogFooter>
</DialogContent>
+44 -11
View File
@@ -1,4 +1,7 @@
"use client";
import { ExternalLink } from "lucide-react";
import { getMediaUrl } from "@/lib/media-url";
type AdCardAd = {
id: string;
@@ -9,18 +12,48 @@ type AdCardAd = {
};
export function AdCard({ ad }: { ad: AdCardAd }) {
const imageUrl = getMediaUrl(ad.imageKey);
const inner = (
<div className="rounded-2xl border border-orange-200/30 bg-orange-50/5 px-5 py-4 flex flex-col gap-2">
<div className="flex items-center gap-2">
<span className="text-[10px] font-semibold uppercase tracking-widest text-orange-400 bg-orange-400/10 px-2 py-0.5 rounded-full">
Sponsored
</span>
{ad.targetUrl && <ExternalLink className="h-3 w-3 text-muted-foreground ml-auto" />}
</div>
<p className="text-sm font-semibold text-foreground">{ad.title}</p>
{ad.caption && (
<p className="text-sm text-muted-foreground leading-relaxed">{ad.caption}</p>
<div className="rounded-2xl border border-orange-200/20 bg-card overflow-hidden">
{/* Image */}
{imageUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={imageUrl}
alt={ad.title}
className="w-full object-cover max-h-80"
/>
)}
{/* Text area */}
<div className="px-4 py-3 flex flex-col gap-1.5">
{/* Sponsored badge row */}
<div className="flex items-center justify-between">
<span className="text-[10px] font-semibold uppercase tracking-widest text-orange-400 bg-orange-400/10 px-2 py-0.5 rounded-full">
Sponsored
</span>
{ad.targetUrl && (
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground" />
)}
</div>
<p className="text-sm font-semibold text-foreground leading-snug">
{ad.title}
</p>
{ad.caption && (
<p className="text-sm text-muted-foreground leading-relaxed">
{ad.caption}
</p>
)}
{ad.targetUrl && (
<p className="text-xs text-orange-400/70 truncate mt-0.5">
{ad.targetUrl.replace(/^https?:\/\//, "")}
</p>
)}
</div>
</div>
);
@@ -31,7 +64,7 @@ export function AdCard({ ad }: { ad: AdCardAd }) {
target="_blank"
rel="noopener noreferrer sponsored"
className="block no-underline hover:opacity-90 transition-opacity"
aria-label={`Sponsored: ${ad.title}`}
aria-label={`Gesponsert: ${ad.title}`}
>
{inner}
</a>
+22
View File
@@ -1,8 +1,14 @@
import "server-only";
import { z } from "zod";
import { randomUUID } from "crypto";
import { TRPCError } from "@trpc/server";
import { AdminRoleType, type PrismaClient } from "@prisma/client";
import { router, protectedProcedure } from "@/trpc/init";
import {
createSignedUploadUrl,
ALLOWED_CONTENT_TYPES,
MAX_FILE_SIZE,
} from "@/lib/supabase-storage";
async function assertAdmin(
prisma: PrismaClient,
@@ -287,6 +293,22 @@ export const adminRouter = router({
return { success: true };
}),
// ─── Ad media upload ─────────────────────────────────────────────────────────
getAdImagePresignedUrl: protectedProcedure
.input(
z.object({
contentType: z.enum(ALLOWED_CONTENT_TYPES),
fileSize: z.number().max(MAX_FILE_SIZE, "Image must be under 10 MB"),
})
)
.mutation(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId);
const ext = input.contentType.split("/")[1]!.replace("jpeg", "jpg");
const key = `ads/${ctx.userId}/${randomUUID()}.${ext}`;
const { signedUrl } = await createSignedUploadUrl(key);
return { presignedUrl: signedUrl, key };
}),
// ─── Advertisements ──────────────────────────────────────────────────────────
listAds: protectedProcedure.query(async ({ ctx }) => {
await assertAdmin(ctx.prisma, ctx.userId);