feat(admin): add hidden admin panel, ban system, ads, and legal pages

- Admin panel at /p/[ADMIN_SECRET]/* — middleware returns 404 for wrong/missing secret
- tRPC adminRouter: getStats, listPosts, deletePost, listComments, deleteComment,
  listUsers, banUser/unbanUser, listAds, createAd/updateAd/deleteAd, getActiveAds,
  listModerators, grantRole/revokeRole, getModerationLog, getActiveBan
- Prisma schema: AdminRole, UserBan, Advertisement, ModerationLog models added
- Ban enforcement: (app)/layout.tsx checks active ban on every request → /banned
- Feed ad injection: active ads shown every 7th post in FeedList (AdCard component)
- Clerk middleware.ts: protects all routes; /p/[secret] validated before auth check
- Impressum + Datenschutz pages: contact data base64-obfuscated, decoded client-side only
- shadcn Switch component added
- ADMIN_SECRET + ADMIN_OWNER_ID documented in .env.example
This commit is contained in:
admin committed 2026-06-21 15:21:07 +02:00
1 parent e407064866
commit b5f02fe7a4
18 files changed
+1815 -6

No files matched your search

+42
View File
@@ -0,0 +1,42 @@
import { ExternalLink } from "lucide-react";
type AdCardAd = {
id: string;
title: string;
caption: string | null;
targetUrl: string | null;
imageKey: string | null;
};
export function AdCard({ ad }: { ad: AdCardAd }) {
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>
);
if (ad.targetUrl) {
return (
<a
href={ad.targetUrl}
target="_blank"
rel="noopener noreferrer sponsored"
className="block no-underline hover:opacity-90 transition-opacity"
aria-label={`Sponsored: ${ad.title}`}
>
{inner}
</a>
);
}
return inner;
}
+29 -5
View File
@@ -3,13 +3,15 @@
import { useEffect, useState } from "react";
import { useInView } from "react-intersection-observer";
import { useTRPC } from "@/trpc/client";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { useActivePet } from "@/context/ActivePetContext";
import { PostCard } from "@/components/feed/PostCard";
import { AdCard } from "@/components/feed/AdCard";
import { SkeletonCard } from "@/components/feed/SkeletonCard";
import { EmptyFeed } from "@/components/feed/EmptyFeed";
import { WelcomeCard } from "@/components/feed/WelcomeCard";
import { Sparkles, Clock } from "lucide-react";
const AD_INTERVAL = 7;
type FeedMode = "chrono" | "algo";
@@ -62,8 +64,26 @@ export function FeedList() {
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
const { data: activeAds } = useQuery(trpc.admin.getActiveAds.queryOptions());
const ads = activeAds ?? [];
const allPosts = data?.pages.flatMap((page) => page.posts) ?? [];
// Build interleaved feed: inject one ad every AD_INTERVAL posts (cycle through ads)
type Post = (typeof allPosts)[number];
type Ad = (typeof ads)[number];
type FeedItem = { kind: "post"; post: Post } | { kind: "ad"; ad: Ad; key: string };
const feedItems: FeedItem[] = [];
let adIndex = 0;
allPosts.forEach((post, i) => {
feedItems.push({ kind: "post", post });
if (ads.length > 0 && (i + 1) % AD_INTERVAL === 0) {
feedItems.push({ kind: "ad", ad: ads[adIndex % ads.length]!, key: `ad-${i}-${adIndex}` });
adIndex++;
}
});
if (isLoading) {
return (
<div className="flex flex-col gap-8 py-4">
@@ -109,11 +129,15 @@ export function FeedList() {
{/* New-user welcome card */}
{activePetId && <WelcomeCard petId={activePetId} />}
{/* Feed posts */}
{/* Feed posts + injected ads */}
<div className="flex flex-col gap-8">
{allPosts.map((post) => (
<PostCard key={post.id} post={{ ...post, createdAt: post.createdAt }} />
))}
{feedItems.map((item) =>
item.kind === "post" ? (
<PostCard key={item.post.id} post={{ ...item.post, createdAt: item.post.createdAt }} />
) : (
<AdCard key={item.key} ad={item.ad} />
)
)}
</div>
<div ref={sentinelRef} aria-hidden="true" className="h-4" />
+32
View File
@@ -0,0 +1,32 @@
"use client"
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "default",
...props
}: SwitchPrimitive.Root.Props & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
/>
</SwitchPrimitive.Root>
)
}
export { Switch }