fix(ads): render image in AdCard feed, add image upload to ad dialog
This commit is contained in:
1 parent
0f34a2c9c6
commit
6f671cfc63
3 files changed
+228
-43
No files matched your search
+162
-32
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user