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:
1 parent
e407064866
commit
b5f02fe7a4
18 files changed
+1815
-6
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 }
|
||||
Reference in new issue
Block a user