feat: ad engagement, invite gate, sidebar footer, docker setup
Ad interactions (reactions/comments/reposts on ads): - Schema: AdReaction, AdComment, AdRepost models with RLS enabled - tRPC: new ads router (getFeedData, toggleReaction, addComment, listComments, toggleRepost) - AdCard: fully interactive card with paw, comment sheet, repost — mirrors PostCard style - getActiveAds includes _count for advertiser engagement metrics Invite system enforcement: - proxy.ts: /sign-up blocked without pf_invite cookie - INVITE_REQUIRED=false env var disables gate for post-beta live launch - Full invite flow: /join → cookie → /sign-up → consume-invite → 3 codes issued to new user - Admin: listInviteCodes, createRootInvite, revokeInvite procedures Sidebar mini-footer: - Impressum + Datenschutz links always visible at sidebar bottom - PawFeed Alpha-Test copyright line Docker self-hosting: - docker/Dockerfile: 3-stage build using Next.js standalone output - docker/docker-compose.yml: env_file + build-args for NEXT_PUBLIC_ vars - .dockerignore at project root - next.config.ts: output standalone for minimal image - .env.example: documented INVITE_REQUIRED
This commit is contained in:
1 parent
a9793a5ad2
commit
5800e06cd9
24 files changed
+1162
-108
No files matched your search
@@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
scaffold-tmp
|
||||
.next
|
||||
.git
|
||||
.gitignore
|
||||
*.log
|
||||
.env*.local
|
||||
.env.development
|
||||
npm-debug.log*
|
||||
.DS_Store
|
||||
.planning
|
||||
@@ -29,3 +29,8 @@ SUPABASE_SERVICE_ROLE_KEY="eyJxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
ADMIN_SECRET="your-random-uuid-here"
|
||||
# ADMIN_OWNER_ID: Your Clerk user ID (Clerk dashboard). Bootstraps the super-admin.
|
||||
ADMIN_OWNER_ID="user_xxxxxxxxxxxxxxxxxxxx"
|
||||
|
||||
# ── Invite Gate ───────────────────────────────────────────────
|
||||
# Set to "false" to disable the invite requirement after the Alpha/Beta phase.
|
||||
# Default: "true" (invite required — enforced in proxy.ts)
|
||||
INVITE_REQUIRED="true"
|
||||
@@ -0,0 +1,54 @@
|
||||
# ──────────────────────────────────────────────
|
||||
# Stage 1: install dependencies
|
||||
# ──────────────────────────────────────────────
|
||||
FROM node:22-alpine AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Stage 2: build
|
||||
# ──────────────────────────────────────────────
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Public env vars must be present at build time (baked into client bundle)
|
||||
ARG NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
|
||||
ARG NEXT_PUBLIC_SUPABASE_URL
|
||||
ENV NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
|
||||
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN npx prisma generate
|
||||
RUN npm run build
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Stage 3: production image
|
||||
# ──────────────────────────────────────────────
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs \
|
||||
&& adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy the standalone output (includes node_modules tree-shaken to minimum)
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,19 @@
|
||||
services:
|
||||
pawfeed:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
args:
|
||||
# These two are baked into the client bundle at build time.
|
||||
# All other vars are injected at runtime via env_file below.
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY}
|
||||
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL}
|
||||
image: pawfeed:latest
|
||||
container_name: pawfeed
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
env_file:
|
||||
- ../.env.local # copy .env.example → .env.local and fill in your values
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
@@ -9,6 +9,7 @@ function getSupabaseHostname(): string {
|
||||
}
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
allowedDevOrigins: ["192.168.1.92"],
|
||||
images: {
|
||||
remotePatterns: [
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "AdReaction" ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "AdComment" ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "AdRepost" ENABLE ROW LEVEL SECURITY;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "InviteCode" ENABLE ROW LEVEL SECURITY;
|
||||
+69
-8
@@ -7,11 +7,27 @@ datasource db {
|
||||
}
|
||||
|
||||
model Owner {
|
||||
id String @id // = Clerk userId (e.g. "user_xxx")
|
||||
createdAt DateTime @default(now())
|
||||
pets Pet[]
|
||||
adminRole AdminRole?
|
||||
ban UserBan?
|
||||
id String @id // = Clerk userId (e.g. "user_xxx")
|
||||
createdAt DateTime @default(now())
|
||||
invitedById String? // Clerk userId of the person who invited this owner
|
||||
pets Pet[]
|
||||
adminRole AdminRole?
|
||||
ban UserBan?
|
||||
issuedInvites InviteCode[] @relation("IssuedBy")
|
||||
}
|
||||
|
||||
model InviteCode {
|
||||
id String @id @default(cuid())
|
||||
code String @unique
|
||||
issuedBy Owner? @relation("IssuedBy", fields: [issuedById], references: [id])
|
||||
issuedById String? // null = admin-generated root invite
|
||||
usedById String? // Clerk userId who redeemed this code (not a FK — owner may not exist yet)
|
||||
usedAt DateTime?
|
||||
revoked Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([issuedById])
|
||||
@@index([code])
|
||||
}
|
||||
|
||||
model Species {
|
||||
@@ -80,6 +96,11 @@ model Pet {
|
||||
healthCard HealthCard?
|
||||
emergencyVet EmergencyVet?
|
||||
|
||||
// Ad interaction back-relations
|
||||
adReactions AdReaction[] @relation("AdReactions")
|
||||
adComments AdComment[] @relation("AdComments")
|
||||
adReposts AdRepost[] @relation("AdReposts")
|
||||
|
||||
@@index([ownerId])
|
||||
}
|
||||
|
||||
@@ -478,19 +499,59 @@ model Advertisement {
|
||||
id String @id @default(cuid())
|
||||
title String @db.VarChar(100)
|
||||
caption String? @db.VarChar(500)
|
||||
imageKey String? // Supabase Storage key
|
||||
imageKey String?
|
||||
targetUrl String? @db.VarChar(500)
|
||||
isActive Boolean @default(true)
|
||||
species String[] // [] = all species; ["Dog"] = dogs only (future targeting)
|
||||
species String[]
|
||||
startDate DateTime?
|
||||
endDate DateTime?
|
||||
createdBy String // ownerId of admin who created it
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
reactions AdReaction[]
|
||||
comments AdComment[]
|
||||
reposts AdRepost[]
|
||||
|
||||
@@index([isActive, startDate, endDate])
|
||||
}
|
||||
|
||||
model AdReaction {
|
||||
id String @id @default(cuid())
|
||||
ad Advertisement @relation(fields: [adId], references: [id], onDelete: Cascade)
|
||||
adId String
|
||||
pet Pet @relation("AdReactions", fields: [petId], references: [id], onDelete: Cascade)
|
||||
petId String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([adId, petId])
|
||||
@@index([adId])
|
||||
}
|
||||
|
||||
model AdComment {
|
||||
id String @id @default(cuid())
|
||||
ad Advertisement @relation(fields: [adId], references: [id], onDelete: Cascade)
|
||||
adId String
|
||||
pet Pet @relation("AdComments", fields: [petId], references: [id], onDelete: Cascade)
|
||||
petId String
|
||||
body String @db.VarChar(500)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([adId])
|
||||
}
|
||||
|
||||
model AdRepost {
|
||||
id String @id @default(cuid())
|
||||
ad Advertisement @relation(fields: [adId], references: [id], onDelete: Cascade)
|
||||
adId String
|
||||
pet Pet @relation("AdReposts", fields: [petId], references: [id], onDelete: Cascade)
|
||||
petId String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([adId, petId])
|
||||
@@index([adId])
|
||||
}
|
||||
|
||||
model ModerationLog {
|
||||
id String @id @default(cuid())
|
||||
action String @db.VarChar(50) // DELETE_POST | DELETE_COMMENT | BAN_USER | UNBAN_USER | DELETE_IMAGE
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
import { useState } from "react";
|
||||
import { Check, Copy, Loader2, UserCheck, Clock } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function InvitePage() {
|
||||
const trpc = useTRPC();
|
||||
const { data: codes = [], isLoading } = useQuery(trpc.invites.myInvites.queryOptions());
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
|
||||
const available = codes.filter((c) => !c.usedById && !c.revoked);
|
||||
const used = codes.filter((c) => c.usedById || c.revoked);
|
||||
|
||||
function copyLink(code: string, id: string) {
|
||||
const url = `${window.location.origin}/join?code=${code}`;
|
||||
void navigator.clipboard.writeText(url).then(() => {
|
||||
setCopiedId(id);
|
||||
toast.success("Link kopiert!");
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
});
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-24">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-lg mx-auto px-4 py-10 space-y-8">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Freunde einladen</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Du hast {available.length} von {codes.length} Einladungen verfügbar.
|
||||
Teile einen Link — jeder Code kann nur einmal verwendet werden.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{codes.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed p-8 text-center text-muted-foreground text-sm">
|
||||
Du hast noch keine Einladungen. Melde dich mit einem Einladungscode an, um 3 eigene zu erhalten.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{codes.map((code) => {
|
||||
const isUsed = !!code.usedById || code.revoked;
|
||||
return (
|
||||
<div
|
||||
key={code.id}
|
||||
className={`flex items-center gap-3 rounded-xl border px-4 py-3 transition-colors ${
|
||||
isUsed
|
||||
? "border-border/50 bg-muted/30 opacity-60"
|
||||
: "border-border bg-card hover:bg-accent/30"
|
||||
}`}
|
||||
>
|
||||
{/* Status icon */}
|
||||
<div className={`shrink-0 ${isUsed ? "text-muted-foreground" : "text-green-500"}`}>
|
||||
{isUsed ? (
|
||||
<UserCheck className="h-4 w-4" />
|
||||
) : (
|
||||
<Clock className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Code */}
|
||||
<span className={`font-mono text-sm font-medium flex-1 ${isUsed ? "line-through text-muted-foreground" : ""}`}>
|
||||
{code.code}
|
||||
</span>
|
||||
|
||||
{/* Status label */}
|
||||
{isUsed ? (
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{code.revoked ? "Widerrufen" : "Verwendet"}
|
||||
{code.usedAt && !code.revoked
|
||||
? ` · ${new Date(code.usedAt).toLocaleDateString("de-DE")}`
|
||||
: ""}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => copyLink(code.code, code.id)}
|
||||
className="shrink-0 flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{copiedId === code.id ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{copiedId === code.id ? "Kopiert" : "Link kopieren"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{used.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{used.length} von {codes.length} Codes bereits verwendet.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { cookies } from "next/headers";
|
||||
import { createTRPCCaller } from "@/trpc/server";
|
||||
import { Sidebar } from "@/components/layout/Sidebar";
|
||||
import { MobileNav } from "@/components/layout/MobileNav";
|
||||
@@ -28,6 +29,12 @@ export default async function AppLayout({
|
||||
redirect("/log-in");
|
||||
}
|
||||
|
||||
// Consume invite code on first login — cookie set by /join page before sign-up
|
||||
const cookieStore = await cookies();
|
||||
if (cookieStore.get("pf_invite")?.value) {
|
||||
redirect("/api/auth/consume-invite");
|
||||
}
|
||||
|
||||
// Fetch the owner's pets via tRPC server caller (scoped by userId)
|
||||
const caller = await createTRPCCaller();
|
||||
const [pets, ban] = await Promise.all([
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { generateInviteCode, INVITE_CODES_PER_USER } from "@/lib/invite-codes";
|
||||
|
||||
export async function GET() {
|
||||
const { userId } = await auth();
|
||||
if (!userId) redirect("/log-in");
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const inviteCode = cookieStore.get("pf_invite")?.value;
|
||||
|
||||
if (inviteCode) {
|
||||
await consumeInviteCode(userId, inviteCode);
|
||||
cookieStore.delete("pf_invite");
|
||||
}
|
||||
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
async function consumeInviteCode(userId: string, code: string) {
|
||||
const invite = await prisma.inviteCode.findUnique({ where: { code } });
|
||||
if (!invite || invite.usedById || invite.revoked) return;
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.inviteCode.update({
|
||||
where: { code },
|
||||
data: { usedById: userId, usedAt: new Date() },
|
||||
});
|
||||
|
||||
await tx.owner.upsert({
|
||||
where: { id: userId },
|
||||
create: {
|
||||
id: userId,
|
||||
invitedById: invite.issuedById ?? null,
|
||||
},
|
||||
update: {
|
||||
invitedById: invite.issuedById ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const existingCodes = await tx.inviteCode.count({
|
||||
where: { issuedById: userId },
|
||||
});
|
||||
|
||||
if (existingCodes === 0) {
|
||||
await tx.inviteCode.createMany({
|
||||
data: Array.from({ length: INVITE_CODES_PER_USER }, () => ({
|
||||
code: generateInviteCode(),
|
||||
issuedById: userId,
|
||||
})),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
"use server";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
interface JoinPageProps {
|
||||
searchParams: Promise<{ code?: string; error?: string }>;
|
||||
}
|
||||
|
||||
async function submitCode(formData: FormData) {
|
||||
"use server";
|
||||
const code = (formData.get("code") as string | null)?.trim().toUpperCase();
|
||||
if (!code) redirect("/join?error=missing");
|
||||
|
||||
const invite = await prisma.inviteCode.findUnique({ where: { code } });
|
||||
if (!invite || invite.usedById || invite.revoked) {
|
||||
redirect(`/join?error=invalid`);
|
||||
}
|
||||
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set("pf_invite", code, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
maxAge: 60 * 60, // 1 hour
|
||||
path: "/",
|
||||
});
|
||||
redirect("/sign-up");
|
||||
}
|
||||
|
||||
export default async function JoinPage({ searchParams }: JoinPageProps) {
|
||||
const { code: codeParam, error } = await searchParams;
|
||||
|
||||
// If a code is in the URL, pre-validate and set cookie immediately
|
||||
if (codeParam) {
|
||||
const code = codeParam.trim().toUpperCase();
|
||||
const invite = await prisma.inviteCode.findUnique({ where: { code } });
|
||||
if (invite && !invite.usedById && !invite.revoked) {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set("pf_invite", code, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
maxAge: 60 * 60,
|
||||
path: "/",
|
||||
});
|
||||
redirect("/sign-up");
|
||||
}
|
||||
// invalid code in URL — fall through and show form with error
|
||||
}
|
||||
|
||||
const errorMessage =
|
||||
error === "invalid" ? "Ungültiger oder bereits verwendeter Einladungscode."
|
||||
: error === "missing" ? "Bitte gib einen Einladungscode ein."
|
||||
: codeParam ? "Dieser Einladungscode ist ungültig oder wurde bereits verwendet."
|
||||
: null;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center bg-zinc-950 px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
{/* Logo / brand */}
|
||||
<div className="mb-8 text-center">
|
||||
<p className="text-2xl font-bold text-zinc-100 tracking-tight">PawFeed</p>
|
||||
<p className="text-sm text-zinc-500 mt-1">Alpha · Nur auf Einladung</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-zinc-800 bg-zinc-900 p-6 shadow-xl">
|
||||
<h1 className="text-lg font-semibold text-zinc-100 mb-1">
|
||||
Einladungscode eingeben
|
||||
</h1>
|
||||
<p className="text-sm text-zinc-500 mb-5">
|
||||
PawFeed ist aktuell nur per Einladung zugänglich. Du brauchst einen Code von einem Mitglied.
|
||||
</p>
|
||||
|
||||
<form action={submitCode} className="space-y-4">
|
||||
<div>
|
||||
<input
|
||||
name="code"
|
||||
type="text"
|
||||
placeholder="z.B. PAWS-X7K2"
|
||||
autoComplete="off"
|
||||
autoCapitalize="characters"
|
||||
spellCheck={false}
|
||||
className="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-4 py-2.5 text-sm font-mono text-zinc-100 placeholder:text-zinc-600 focus:outline-none focus:ring-2 focus:ring-orange-500 transition-shadow uppercase"
|
||||
/>
|
||||
{errorMessage && (
|
||||
<p className="mt-2 text-xs text-red-400">{errorMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded-lg bg-orange-500 hover:bg-orange-600 active:bg-orange-700 px-4 py-2.5 text-sm font-semibold text-white transition-colors"
|
||||
>
|
||||
Weiter
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-center text-xs text-zinc-600">
|
||||
Noch kein Code? Bitte einen Freund, der PawFeed-Mitglied ist.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
import { Plus, Loader2, Copy, Check, Ban, UserCheck, Clock } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function AdminInvitesPage() {
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
|
||||
const { data: codes = [], isLoading } = useQuery(
|
||||
trpc.admin.listInviteCodes.queryOptions({ limit: 200 })
|
||||
);
|
||||
|
||||
const invalidate = () =>
|
||||
void qc.invalidateQueries({ queryKey: trpc.admin.listInviteCodes.queryKey() });
|
||||
|
||||
const createRoot = useMutation(
|
||||
trpc.admin.createRootInvite.mutationOptions({
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
toast.success("Root-Einladung erstellt");
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const revoke = useMutation(
|
||||
trpc.admin.revokeInvite.mutationOptions({
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
toast.success("Code widerrufen");
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
function copyLink(code: string, id: string) {
|
||||
const url = `${window.location.origin}/join?code=${code}`;
|
||||
void navigator.clipboard.writeText(url).then(() => {
|
||||
setCopiedId(id);
|
||||
toast.success("Link kopiert");
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
});
|
||||
}
|
||||
|
||||
const available = codes.filter((c) => !c.usedById && !c.revoked);
|
||||
const used = codes.filter((c) => c.usedById && !c.revoked);
|
||||
const revoked = codes.filter((c) => c.revoked);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-zinc-100">Invite Codes</h1>
|
||||
<p className="text-sm text-zinc-500 mt-0.5">
|
||||
{available.length} verfügbar · {used.length} verwendet · {revoked.length} widerrufen
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => createRoot.mutate()}
|
||||
disabled={createRoot.isPending}
|
||||
className="bg-orange-500 hover:bg-orange-600 text-white"
|
||||
>
|
||||
{createRoot.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-1.5" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4 mr-1.5" />
|
||||
)}
|
||||
Root-Code erstellen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-zinc-500" />
|
||||
</div>
|
||||
) : codes.length === 0 ? (
|
||||
<p className="text-sm text-zinc-500 text-center py-12">
|
||||
Noch keine Codes — oben einen Root-Code erstellen.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{codes.map((code) => {
|
||||
const isUsed = !!code.usedById;
|
||||
const isRevoked = code.revoked;
|
||||
const issuerName = code.issuedBy?.pets?.[0]?.name ?? (code.issuedById ? code.issuedById.slice(0, 12) : null);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={code.id}
|
||||
className={`flex items-center gap-3 rounded-xl border px-4 py-3 ${
|
||||
isRevoked
|
||||
? "border-zinc-800 bg-zinc-900/50 opacity-50"
|
||||
: isUsed
|
||||
? "border-zinc-800 bg-zinc-900"
|
||||
: "border-zinc-700 bg-zinc-900"
|
||||
}`}
|
||||
>
|
||||
{/* Status icon */}
|
||||
<div className={`shrink-0 ${isRevoked ? "text-red-500" : isUsed ? "text-zinc-500" : "text-green-500"}`}>
|
||||
{isRevoked ? (
|
||||
<Ban className="h-4 w-4" />
|
||||
) : isUsed ? (
|
||||
<UserCheck className="h-4 w-4" />
|
||||
) : (
|
||||
<Clock className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Code */}
|
||||
<span className={`font-mono text-sm font-medium flex-1 ${isRevoked ? "line-through text-zinc-600" : "text-zinc-100"}`}>
|
||||
{code.code}
|
||||
</span>
|
||||
|
||||
{/* Issuer */}
|
||||
<span className="text-xs text-zinc-600 shrink-0">
|
||||
{issuerName ? `von ${issuerName}` : "Root"}
|
||||
</span>
|
||||
|
||||
{/* Status badge */}
|
||||
{isRevoked ? (
|
||||
<Badge variant="secondary" className="text-[10px] shrink-0">Widerrufen</Badge>
|
||||
) : isUsed ? (
|
||||
<Badge variant="secondary" className="text-[10px] shrink-0">
|
||||
Verwendet · {code.usedAt ? new Date(code.usedAt).toLocaleDateString("de-DE") : ""}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-green-800 text-green-200 text-[10px] shrink-0">Verfügbar</Badge>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-1 shrink-0">
|
||||
{!isUsed && !isRevoked && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0 text-zinc-400 hover:text-zinc-100"
|
||||
title="Link kopieren"
|
||||
onClick={() => copyLink(code.code, code.id)}
|
||||
>
|
||||
{copiedId === code.id ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-400" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{!isRevoked && !isUsed && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0 text-red-400 hover:text-red-300"
|
||||
title="Code widerrufen"
|
||||
onClick={() => revoke.mutate({ id: code.id })}
|
||||
disabled={revoke.isPending}
|
||||
>
|
||||
<Ban className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { notFound, redirect } from "next/navigation";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import Link from "next/link";
|
||||
import { createTRPCCaller } from "@/trpc/server";
|
||||
import { LayoutDashboard, Users, Megaphone, ShieldCheck, FileText, Flag } from "lucide-react";
|
||||
import { LayoutDashboard, Users, Megaphone, ShieldCheck, FileText, Flag, Ticket } from "lucide-react";
|
||||
|
||||
interface AdminLayoutProps {
|
||||
children: React.ReactNode;
|
||||
@@ -42,6 +42,7 @@ export default async function AdminLayout({ children, params }: AdminLayoutProps
|
||||
{ href: `${base}/reports`, label: "Reports", Icon: Flag },
|
||||
{ href: `${base}/users`, label: "Users", Icon: Users },
|
||||
{ href: `${base}/ads`, label: "Ads", Icon: Megaphone },
|
||||
{ href: `${base}/invites`, label: "Invites", Icon: Ticket },
|
||||
{ href: `${base}/moderators`, label: "Moderators", Icon: ShieldCheck },
|
||||
];
|
||||
|
||||
|
||||
+356
-55
@@ -1,7 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { ExternalLink, PawPrint, MessageCircle, Repeat2, Loader2 } from "lucide-react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
import { getMediaUrl } from "@/lib/media-url";
|
||||
import { getAvatarUrl } from "@/lib/avatar-url";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
|
||||
type AdCardAd = {
|
||||
id: string;
|
||||
@@ -11,74 +25,361 @@ type AdCardAd = {
|
||||
imageKey: string | null;
|
||||
};
|
||||
|
||||
export function AdCard({ ad }: { ad: AdCardAd }) {
|
||||
function formatRelative(date: Date | string): string {
|
||||
const diffMs = Date.now() - new Date(date).getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
if (diffMins < 1) return "gerade eben";
|
||||
if (diffMins < 60) return `${diffMins}m`;
|
||||
if (diffHours < 24) return `${diffHours}h`;
|
||||
if (diffDays < 7) return `${diffDays}d`;
|
||||
return new Date(date).toLocaleDateString("de-DE");
|
||||
}
|
||||
|
||||
interface AdCardProps {
|
||||
ad: AdCardAd;
|
||||
activePetId: string;
|
||||
}
|
||||
|
||||
export function AdCard({ ad, activePetId }: AdCardProps) {
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
const imageUrl = getMediaUrl(ad.imageKey);
|
||||
|
||||
return (
|
||||
<article className="bg-card border border-border rounded-lg overflow-hidden">
|
||||
{/* Header — mirrors PostCard layout */}
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
{/* Sponsor "avatar" — orange circle with paw */}
|
||||
<div className="w-8 h-8 rounded-full bg-orange-500/15 border border-orange-400/30 flex items-center justify-center shrink-0">
|
||||
<span className="text-base leading-none select-none" aria-hidden="true">🐾</span>
|
||||
</div>
|
||||
// ── Interaction data ────────────────────────────────────────────────────────
|
||||
const { data: feedData } = useQuery(
|
||||
trpc.ads.getFeedData.queryOptions(
|
||||
{ adId: ad.id, petId: activePetId },
|
||||
{ staleTime: 30_000 }
|
||||
)
|
||||
);
|
||||
|
||||
{/* Name + badge row */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-base font-semibold text-foreground leading-tight truncate">
|
||||
{ad.title}
|
||||
</p>
|
||||
<span className="shrink-0 text-[10px] font-semibold uppercase tracking-widest text-orange-400 bg-orange-400/10 px-2 py-0.5 rounded-full">
|
||||
Sponsored
|
||||
</span>
|
||||
const [reacted, setReacted] = useState(feedData?.hasReacted ?? false);
|
||||
const [reactionCount, setReactionCount] = useState(feedData?.reactionCount ?? 0);
|
||||
const [reposted, setReposted] = useState(feedData?.hasReposted ?? false);
|
||||
const [repostCount, setRepostCount] = useState(feedData?.repostCount ?? 0);
|
||||
const [commentSheetOpen, setCommentSheetOpen] = useState(false);
|
||||
|
||||
// Sync state from server once data loads
|
||||
useEffect(() => {
|
||||
if (feedData) {
|
||||
setReacted(feedData.hasReacted);
|
||||
setReactionCount(feedData.reactionCount);
|
||||
setReposted(feedData.hasReposted);
|
||||
setRepostCount(feedData.repostCount);
|
||||
}
|
||||
}, [feedData]);
|
||||
|
||||
const invalidateFeedData = useCallback(() => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: trpc.ads.getFeedData.queryKey({ adId: ad.id, petId: activePetId }),
|
||||
});
|
||||
}, [queryClient, trpc, ad.id, activePetId]);
|
||||
|
||||
// ── Reaction ────────────────────────────────────────────────────────────────
|
||||
const reactionMutation = useMutation(
|
||||
trpc.ads.toggleReaction.mutationOptions({
|
||||
onMutate: () => {
|
||||
const next = !reacted;
|
||||
setReacted(next);
|
||||
setReactionCount((c) => (next ? c + 1 : Math.max(0, c - 1)));
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setReacted(data.reacted);
|
||||
setReactionCount(data.count);
|
||||
},
|
||||
onError: () => {
|
||||
setReacted(feedData?.hasReacted ?? false);
|
||||
setReactionCount(feedData?.reactionCount ?? 0);
|
||||
toast.error("Fehler beim Reagieren");
|
||||
},
|
||||
onSettled: invalidateFeedData,
|
||||
})
|
||||
);
|
||||
|
||||
// ── Repost ──────────────────────────────────────────────────────────────────
|
||||
const repostMutation = useMutation(
|
||||
trpc.ads.toggleRepost.mutationOptions({
|
||||
onMutate: () => {
|
||||
const next = !reposted;
|
||||
setReposted(next);
|
||||
setRepostCount((c) => (next ? c + 1 : Math.max(0, c - 1)));
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setReposted(data.reposted);
|
||||
setRepostCount(data.count);
|
||||
},
|
||||
onError: () => {
|
||||
setReposted(feedData?.hasReposted ?? false);
|
||||
setRepostCount(feedData?.repostCount ?? 0);
|
||||
toast.error("Fehler beim Reposten");
|
||||
},
|
||||
onSettled: invalidateFeedData,
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<article className="bg-card border border-border rounded-lg overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<div className="w-8 h-8 rounded-full bg-orange-500/15 border border-orange-400/30 flex items-center justify-center shrink-0">
|
||||
<span className="text-base leading-none select-none" aria-hidden="true">🐾</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-base font-semibold text-foreground leading-tight truncate">
|
||||
{ad.title}
|
||||
</p>
|
||||
<span className="shrink-0 text-[10px] font-semibold uppercase tracking-widest text-orange-400 bg-orange-400/10 px-2 py-0.5 rounded-full">
|
||||
Sponsored
|
||||
</span>
|
||||
</div>
|
||||
{ad.targetUrl && (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{ad.targetUrl.replace(/^https?:\/\//, "").replace(/\/$/, "")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{ad.targetUrl && (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{ad.targetUrl.replace(/^https?:\/\//, "").replace(/\/$/, "")}
|
||||
</p>
|
||||
<a
|
||||
href={ad.targetUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer sponsored"
|
||||
className="flex items-center justify-center w-11 h-11 rounded-full hover:bg-muted transition-colors"
|
||||
aria-label={`${ad.title} besuchen`}
|
||||
>
|
||||
<ExternalLink className="h-5 w-5 text-muted-foreground" aria-hidden="true" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* External link button — mirrors options button position */}
|
||||
{ad.targetUrl && (
|
||||
{/* Image */}
|
||||
{imageUrl && (
|
||||
<a
|
||||
href={ad.targetUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer sponsored"
|
||||
className="flex items-center justify-center w-11 h-11 rounded-full hover:bg-muted transition-colors"
|
||||
aria-label={`${ad.title} besuchen`}
|
||||
href={ad.targetUrl ?? undefined}
|
||||
target={ad.targetUrl ? "_blank" : undefined}
|
||||
rel={ad.targetUrl ? "noopener noreferrer sponsored" : undefined}
|
||||
className={ad.targetUrl ? "block" : undefined}
|
||||
tabIndex={ad.targetUrl ? 0 : -1}
|
||||
>
|
||||
<ExternalLink className="h-5 w-5 text-muted-foreground" aria-hidden="true" />
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={ad.title}
|
||||
className="w-full object-cover max-h-[560px]"
|
||||
/>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Image area — same dimensions as PostCard single image */}
|
||||
{imageUrl && (
|
||||
<a
|
||||
href={ad.targetUrl ?? undefined}
|
||||
target={ad.targetUrl ? "_blank" : undefined}
|
||||
rel={ad.targetUrl ? "noopener noreferrer sponsored" : undefined}
|
||||
className={ad.targetUrl ? "block" : undefined}
|
||||
tabIndex={ad.targetUrl ? 0 : -1}
|
||||
aria-label={ad.targetUrl ? `${ad.title} besuchen` : undefined}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={ad.title}
|
||||
className="w-full object-cover max-h-[560px]"
|
||||
/>
|
||||
</a>
|
||||
)}
|
||||
{/* Caption */}
|
||||
{ad.caption && (
|
||||
<div className="px-4 pt-3">
|
||||
<p className="text-sm text-foreground">{ad.caption}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer — caption only, mirrors PostCard footer */}
|
||||
{ad.caption && (
|
||||
<div className="px-4 py-3">
|
||||
<p className="text-sm text-foreground">{ad.caption}</p>
|
||||
{/* Action bar — mirrors PostCard */}
|
||||
<div className="flex items-center gap-4 px-4 py-3">
|
||||
{/* Paw / Reaction */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (reactionMutation.isPending) return;
|
||||
reactionMutation.mutate({ adId: ad.id, petId: activePetId });
|
||||
}}
|
||||
disabled={reactionMutation.isPending}
|
||||
aria-label={reacted ? "Reaktion entfernen" : "Auf Anzeige reagieren"}
|
||||
className="flex items-center gap-1.5 text-sm transition-colors disabled:opacity-60"
|
||||
>
|
||||
<PawPrint
|
||||
className={`h-4 w-4 transition-colors ${
|
||||
reacted
|
||||
? "fill-orange-500 text-orange-500"
|
||||
: "text-muted-foreground hover:text-orange-500"
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{reactionCount > 0 && (
|
||||
<span className={reacted ? "text-orange-500" : "text-muted-foreground"}>
|
||||
{reactionCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Comment */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCommentSheetOpen(true)}
|
||||
aria-label="Kommentare öffnen"
|
||||
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<MessageCircle className="h-4 w-4" aria-hidden="true" />
|
||||
{(feedData?.commentCount ?? 0) > 0 && (
|
||||
<span>{feedData?.commentCount}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Repost */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (repostMutation.isPending) return;
|
||||
repostMutation.mutate({ adId: ad.id, petId: activePetId });
|
||||
}}
|
||||
disabled={repostMutation.isPending}
|
||||
aria-label={reposted ? "Repost entfernen" : "Anzeige reposten"}
|
||||
className="flex items-center gap-1.5 text-sm transition-colors disabled:opacity-60"
|
||||
>
|
||||
<Repeat2
|
||||
className={`h-4 w-4 ${
|
||||
reposted
|
||||
? "text-green-500"
|
||||
: "text-muted-foreground hover:text-green-500"
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{repostCount > 0 && (
|
||||
<span className={reposted ? "text-green-500" : "text-muted-foreground"}>
|
||||
{repostCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
</article>
|
||||
|
||||
{/* Comment sheet */}
|
||||
<AdCommentSheet
|
||||
adId={ad.id}
|
||||
petId={activePetId}
|
||||
open={commentSheetOpen}
|
||||
onOpenChange={setCommentSheetOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Inline comment sheet for ads ─────────────────────────────────────────────
|
||||
|
||||
interface AdCommentSheetProps {
|
||||
adId: string;
|
||||
petId: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
function AdCommentSheet({ adId, petId, open, onOpenChange }: AdCommentSheetProps) {
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
const [body, setBody] = useState("");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const { data: comments = [], isLoading } = useQuery(
|
||||
trpc.ads.listComments.queryOptions({ adId }, { enabled: open })
|
||||
);
|
||||
|
||||
const addMutation = useMutation(
|
||||
trpc.ads.addComment.mutationOptions({
|
||||
onSuccess: () => {
|
||||
setBody("");
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: trpc.ads.listComments.queryKey({ adId }),
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: trpc.ads.getFeedData.queryKey({ adId, petId }),
|
||||
});
|
||||
},
|
||||
onError: () => toast.error("Kommentar konnte nicht gesendet werden"),
|
||||
})
|
||||
);
|
||||
|
||||
const handleSubmit = () => {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed || addMutation.isPending) return;
|
||||
addMutation.mutate({ adId, petId, body: trimmed });
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="bottom" className="h-[70vh] flex flex-col bg-background">
|
||||
<SheetHeader className="shrink-0">
|
||||
<SheetTitle>Kommentare</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
{/* Comment list */}
|
||||
<div className="flex-1 overflow-y-auto py-2 space-y-4 min-h-0">
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : comments.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
Noch keine Kommentare — sei der Erste!
|
||||
</p>
|
||||
) : (
|
||||
comments.map((c) => {
|
||||
const avatarUrl = getAvatarUrl(c.pet.avatarKey);
|
||||
return (
|
||||
<div key={c.id} className="flex gap-3 px-1">
|
||||
<Avatar className="h-8 w-8 shrink-0">
|
||||
{avatarUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={avatarUrl} alt={c.pet.name} className="h-8 w-8 rounded-full object-cover" />
|
||||
) : (
|
||||
<AvatarFallback className="text-xs">
|
||||
{c.pet.name.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
)}
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-sm font-semibold">{c.pet.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatRelative(c.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-foreground mt-0.5 break-words">{c.body}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Compose */}
|
||||
<div className="shrink-0 border-t pt-3 pb-safe flex gap-2">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
placeholder="Kommentar schreiben…"
|
||||
rows={2}
|
||||
maxLength={500}
|
||||
className="resize-none flex-1"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!body.trim() || addMutation.isPending}
|
||||
size="sm"
|
||||
className="self-end"
|
||||
>
|
||||
{addMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
"Senden"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -134,9 +134,9 @@ export function FeedList() {
|
||||
{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} />
|
||||
)
|
||||
) : activePetId ? (
|
||||
<AdCard key={item.key} ad={item.ad} activePetId={activePetId} />
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
PawPrint,
|
||||
ChevronDown,
|
||||
Plus,
|
||||
Ticket,
|
||||
} from "lucide-react";
|
||||
import { PostTypeSheet } from "@/components/post-creation/PostTypeSheet";
|
||||
import { NotificationBell } from "@/components/notifications/NotificationBell";
|
||||
@@ -47,6 +48,7 @@ const navLinks = [
|
||||
{ href: "/search", label: "Search", icon: Search, active: true, special: false },
|
||||
{ href: "/notifications", label: "Notifications", icon: Bell, active: true, special: true },
|
||||
{ href: "/messages", label: "Messages", icon: MessageCircle, active: true, special: false },
|
||||
{ href: "/invite", label: "Einladen", icon: Ticket, active: true, special: false },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -207,6 +209,22 @@ export function Sidebar({ pets }: SidebarProps) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Legal mini-footer */}
|
||||
<div className="px-4 pb-3 space-y-1">
|
||||
<div className="flex items-center gap-3 text-[11px] text-muted-foreground/60">
|
||||
<Link href="/impressum" className="hover:text-muted-foreground transition-colors">
|
||||
Impressum
|
||||
</Link>
|
||||
<span aria-hidden="true">·</span>
|
||||
<Link href="/datenschutz" className="hover:text-muted-foreground transition-colors">
|
||||
Datenschutz
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground/40">
|
||||
PawFeed · Alpha-Test · © {new Date().getFullYear()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Bottom: owner identity */}
|
||||
<div className="px-4 py-4 border-t border flex items-center gap-2">
|
||||
<UserButton />
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
const PREFIXES = ["PAWS", "BARK", "PURR", "WOOF", "MEOW", "TAIL", "PUPS", "FLUF"];
|
||||
const CHARS = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"; // no confusable chars (0, 1, I, O)
|
||||
|
||||
export function generateInviteCode(): string {
|
||||
const prefix = PREFIXES[Math.floor(Math.random() * PREFIXES.length)];
|
||||
const suffix = Array.from(
|
||||
{ length: 4 },
|
||||
() => CHARS[Math.floor(Math.random() * CHARS.length)]
|
||||
).join("");
|
||||
return `${prefix}-${suffix}`;
|
||||
}
|
||||
|
||||
export const INVITE_CODES_PER_USER = 3;
|
||||
@@ -1,41 +0,0 @@
|
||||
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const isPublicRoute = createRouteMatcher([
|
||||
"/log-in(.*)",
|
||||
"/sign-up(.*)",
|
||||
"/forgot-password(.*)",
|
||||
"/reset-password(.*)",
|
||||
"/verify-email(.*)",
|
||||
"/health-card/(.*)",
|
||||
"/impressum",
|
||||
"/datenschutz",
|
||||
"/api/webhooks/(.*)",
|
||||
]);
|
||||
|
||||
export default clerkMiddleware(async (auth, request) => {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
// Admin panel: validate secret path segment before anything else.
|
||||
// Returns 404 (not 403) — the panel's existence must not be revealed.
|
||||
if (pathname.startsWith("/p/")) {
|
||||
const segments = pathname.split("/");
|
||||
const providedSecret = segments[2] ?? "";
|
||||
const adminSecret = process.env.ADMIN_SECRET ?? "";
|
||||
|
||||
if (!adminSecret || providedSecret !== adminSecret) {
|
||||
return new NextResponse(null, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
if (!isPublicRoute(request)) {
|
||||
await auth.protect();
|
||||
}
|
||||
});
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
"/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
|
||||
"/(api|trpc)(.*)",
|
||||
],
|
||||
};
|
||||
@@ -10,12 +10,23 @@ const isPublicRoute = createRouteMatcher([
|
||||
"/health-card/(.*)",
|
||||
"/impressum",
|
||||
"/datenschutz",
|
||||
"/join(.*)",
|
||||
"/api/webhooks/(.*)",
|
||||
"/api/auth/(.*)",
|
||||
]);
|
||||
|
||||
export default clerkMiddleware(async (auth, request) => {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
// Invite gate: block /sign-up unless the visitor has a valid invite cookie.
|
||||
// Set INVITE_REQUIRED=false in env to disable for the live/post-beta launch.
|
||||
if (pathname.startsWith("/sign-up")) {
|
||||
const inviteRequired = process.env.INVITE_REQUIRED !== "false";
|
||||
if (inviteRequired && !request.cookies.has("pf_invite")) {
|
||||
return NextResponse.redirect(new URL("/join", request.url));
|
||||
}
|
||||
}
|
||||
|
||||
// Admin panel: validate secret path segment before anything else.
|
||||
// Returns 404 (not 403) — the panel's existence must not be revealed.
|
||||
if (pathname.startsWith("/p/")) {
|
||||
|
||||
@@ -18,6 +18,8 @@ import { videosRouter } from "@/trpc/routers/videos";
|
||||
import { conversationsRouter, messagesRouter } from "@/trpc/routers/messages";
|
||||
import { healthRouter } from "@/trpc/routers/health";
|
||||
import { adminRouter } from "@/trpc/routers/admin";
|
||||
import { invitesRouter } from "@/trpc/routers/invites";
|
||||
import { adsRouter } from "@/trpc/routers/ads";
|
||||
|
||||
export const appRouter = router({
|
||||
pets: petsRouter,
|
||||
@@ -40,6 +42,8 @@ export const appRouter = router({
|
||||
messages: messagesRouter,
|
||||
health: healthRouter,
|
||||
admin: adminRouter,
|
||||
invites: invitesRouter,
|
||||
ads: adsRouter,
|
||||
});
|
||||
|
||||
// Export the type for use in the client
|
||||
|
||||
@@ -389,6 +389,9 @@ export const adminRouter = router({
|
||||
OR: [{ startDate: null }, { startDate: { lte: now } }],
|
||||
AND: [{ OR: [{ endDate: null }, { endDate: { gte: now } }] }],
|
||||
},
|
||||
include: {
|
||||
_count: { select: { reactions: true, comments: true, reposts: true } },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
}),
|
||||
@@ -434,4 +437,37 @@ export const adminRouter = router({
|
||||
take: input.limit,
|
||||
});
|
||||
}),
|
||||
|
||||
// ─── Invite codes ─────────────────────────────────────────────────────────────
|
||||
listInviteCodes: protectedProcedure
|
||||
.input(z.object({ limit: z.number().min(1).max(200).default(100) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
await assertAdmin(ctx.prisma, ctx.userId);
|
||||
return ctx.prisma.inviteCode.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: input.limit,
|
||||
include: {
|
||||
issuedBy: { select: { id: true, pets: { select: { name: true }, take: 1 } } },
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
createRootInvite: protectedProcedure.mutation(async ({ ctx }) => {
|
||||
await assertAdmin(ctx.prisma, ctx.userId);
|
||||
const { generateInviteCode } = await import("@/lib/invite-codes");
|
||||
return ctx.prisma.inviteCode.create({
|
||||
data: { code: generateInviteCode(), issuedById: null },
|
||||
});
|
||||
}),
|
||||
|
||||
revokeInvite: protectedProcedure
|
||||
.input(z.object({ id: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await assertAdmin(ctx.prisma, ctx.userId);
|
||||
await ctx.prisma.inviteCode.update({
|
||||
where: { id: input.id },
|
||||
data: { revoked: true },
|
||||
});
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import "server-only";
|
||||
import { z } from "zod";
|
||||
import { router, protectedProcedure } from "@/trpc/init";
|
||||
|
||||
export const adsRouter = router({
|
||||
// Returns reaction/comment/repost counts + whether the given pet has interacted
|
||||
getFeedData: protectedProcedure
|
||||
.input(z.object({ adId: z.string().min(1), petId: z.string().min(1) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const [reactionCount, hasReacted, commentCount, repostCount, hasReposted] =
|
||||
await Promise.all([
|
||||
ctx.prisma.adReaction.count({ where: { adId: input.adId } }),
|
||||
ctx.prisma.adReaction.findUnique({
|
||||
where: { adId_petId: { adId: input.adId, petId: input.petId } },
|
||||
select: { id: true },
|
||||
}),
|
||||
ctx.prisma.adComment.count({ where: { adId: input.adId } }),
|
||||
ctx.prisma.adRepost.count({ where: { adId: input.adId } }),
|
||||
ctx.prisma.adRepost.findUnique({
|
||||
where: { adId_petId: { adId: input.adId, petId: input.petId } },
|
||||
select: { id: true },
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
reactionCount,
|
||||
hasReacted: !!hasReacted,
|
||||
commentCount,
|
||||
repostCount,
|
||||
hasReposted: !!hasReposted,
|
||||
};
|
||||
}),
|
||||
|
||||
toggleReaction: protectedProcedure
|
||||
.input(z.object({ adId: z.string().min(1), petId: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.prisma.adReaction.findUnique({
|
||||
where: { adId_petId: { adId: input.adId, petId: input.petId } },
|
||||
});
|
||||
if (existing) {
|
||||
await ctx.prisma.adReaction.delete({ where: { id: existing.id } });
|
||||
const count = await ctx.prisma.adReaction.count({ where: { adId: input.adId } });
|
||||
return { reacted: false, count };
|
||||
} else {
|
||||
await ctx.prisma.adReaction.create({ data: { adId: input.adId, petId: input.petId } });
|
||||
const count = await ctx.prisma.adReaction.count({ where: { adId: input.adId } });
|
||||
return { reacted: true, count };
|
||||
}
|
||||
}),
|
||||
|
||||
listComments: protectedProcedure
|
||||
.input(z.object({ adId: z.string().min(1) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.adComment.findMany({
|
||||
where: { adId: input.adId },
|
||||
include: {
|
||||
pet: { select: { id: true, name: true, avatarKey: true } },
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
take: 100,
|
||||
});
|
||||
}),
|
||||
|
||||
addComment: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
adId: z.string().min(1),
|
||||
petId: z.string().min(1),
|
||||
body: z.string().min(1).max(500),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return ctx.prisma.adComment.create({
|
||||
data: { adId: input.adId, petId: input.petId, body: input.body },
|
||||
include: {
|
||||
pet: { select: { id: true, name: true, avatarKey: true } },
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
toggleRepost: protectedProcedure
|
||||
.input(z.object({ adId: z.string().min(1), petId: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.prisma.adRepost.findUnique({
|
||||
where: { adId_petId: { adId: input.adId, petId: input.petId } },
|
||||
});
|
||||
if (existing) {
|
||||
await ctx.prisma.adRepost.delete({ where: { id: existing.id } });
|
||||
const count = await ctx.prisma.adRepost.count({ where: { adId: input.adId } });
|
||||
return { reposted: false, count };
|
||||
} else {
|
||||
await ctx.prisma.adRepost.create({ data: { adId: input.adId, petId: input.petId } });
|
||||
const count = await ctx.prisma.adRepost.count({ where: { adId: input.adId } });
|
||||
return { reposted: true, count };
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { router, protectedProcedure } from "@/trpc/init";
|
||||
|
||||
export const invitesRouter = router({
|
||||
myInvites: protectedProcedure.query(async ({ ctx }) => {
|
||||
const codes = await ctx.prisma.inviteCode.findMany({
|
||||
where: { issuedById: ctx.userId },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
return codes;
|
||||
}),
|
||||
});
|
||||
Reference in new issue
Block a user