diff --git a/src/app/p/[secret]/ads/page.tsx b/src/app/p/[secret]/ads/page.tsx index 2efa040..a0e5447 100644 --- a/src/app/p/[secret]/ads/page.tsx +++ b/src/app/p/[secret]/ads/page.tsx @@ -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(null); const [dialogOpen, setDialogOpen] = useState(false); const [deleteId, setDeleteId] = useState(null); + const [imageUploading, setImageUploading] = useState(false); + const [imagePreview, setImagePreview] = useState(null); + const fileInputRef = useRef(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((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) { + 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() { ) : ads.length === 0 ? ( -

No ads yet — create one above

+

Noch keine Ads — oben erstellen

) : (
{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 + + ) : ( +
+ +
+ )} +

{ad.title}

{ad.isActive ? ( - Active + Aktiv ) : ( - Inactive + Inaktiv )}
{ad.caption && ( @@ -136,21 +214,12 @@ export default function AdminAdsPage() {

{ad.targetUrl}

)}
+
- -
@@ -163,11 +232,71 @@ export default function AdminAdsPage() { { if (!o) closeDialog(); }}> - {editingId ? "Edit ad" : "Create ad"} + {editingId ? "Ad bearbeiten" : "Ad erstellen"} +
+ {/* Image upload zone */} +
+ + {imagePreview ? ( +
fileInputRef.current?.click()}> + {/* eslint-disable-next-line @next/next/no-img-element */} + Ad preview +
+ {imageUploading ? ( + + ) : ( + <> + + Bild ändern + + )} +
+ {/* Remove button */} + +
+ ) : ( + + )} +
+ 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} /> setForm({ ...form, targetUrl: e.target.value })} className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500" />
- +
- + setForm({ ...form, isActive: v })} /> - Active + Aktiv
+ @@ -238,12 +368,12 @@ export default function AdminAdsPage() { { if (!o) setDeleteId(null); }}> - Delete ad + Ad löschen -

Delete this advertisement permanently?

+

Diese Anzeige dauerhaft löschen?

diff --git a/src/components/feed/AdCard.tsx b/src/components/feed/AdCard.tsx index 941be37..a9d969e 100644 --- a/src/components/feed/AdCard.tsx +++ b/src/components/feed/AdCard.tsx @@ -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 = ( -
-
- - Sponsored - - {ad.targetUrl && } -
-

{ad.title}

- {ad.caption && ( -

{ad.caption}

+
+ {/* Image */} + {imageUrl && ( + // eslint-disable-next-line @next/next/no-img-element + {ad.title} )} + + {/* Text area */} +
+ {/* Sponsored badge row */} +
+ + Sponsored + + {ad.targetUrl && ( + + )} +
+ +

+ {ad.title} +

+ + {ad.caption && ( +

+ {ad.caption} +

+ )} + + {ad.targetUrl && ( +

+ {ad.targetUrl.replace(/^https?:\/\//, "")} +

+ )} +
); @@ -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} diff --git a/src/trpc/routers/admin.ts b/src/trpc/routers/admin.ts index d92dbec..010d2bc 100644 --- a/src/trpc/routers/admin.ts +++ b/src/trpc/routers/admin.ts @@ -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);