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
242 lines
9.0 KiB
TypeScript
242 lines
9.0 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import Link from "next/link";
|
|
import { usePathname, useRouter } from "next/navigation";
|
|
import {
|
|
Home,
|
|
Heart,
|
|
Compass,
|
|
Search,
|
|
Bell,
|
|
MessageCircle,
|
|
PawPrint,
|
|
ChevronDown,
|
|
Plus,
|
|
Ticket,
|
|
} from "lucide-react";
|
|
import { PostTypeSheet } from "@/components/post-creation/PostTypeSheet";
|
|
import { NotificationBell } from "@/components/notifications/NotificationBell";
|
|
import { UserButton } from "@clerk/nextjs";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { useActivePet } from "@/context/ActivePetContext";
|
|
import { getAvatarUrl } from "@/lib/avatar-url";
|
|
|
|
type Pet = {
|
|
id: string;
|
|
name: string;
|
|
avatarKey: string | null;
|
|
species: { name: string };
|
|
};
|
|
|
|
type SidebarProps = {
|
|
pets: Pet[];
|
|
};
|
|
|
|
const navLinks = [
|
|
{ href: "/feed", label: "Home", icon: Home, active: true, special: false },
|
|
{ href: "/pets", label: "My Pets", icon: Heart, active: true, special: false },
|
|
{ href: "/explore", label: "Explore", icon: Compass, active: true, special: false },
|
|
{ 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 },
|
|
];
|
|
|
|
/**
|
|
* Desktop sidebar — 240px fixed left nav visible at lg: breakpoint.
|
|
*
|
|
* UI-SPEC: Navigation (App Shell) — Desktop sidebar
|
|
* - OnlyPets wordmark + paw icon (orange-500)
|
|
* - Active pet switcher using useActivePet() context
|
|
* - Nav links: Home, My Pets (active in Phase 1); Explore/Notifications/Messages (placeholders)
|
|
* - Bottom: Clerk UserButton
|
|
*
|
|
* Security (T-2-03): receives pets already scoped by pets.list (ownership-scoped) — no
|
|
* cross-owner data is possible because the server layout enforces ownership via ctx.userId.
|
|
*
|
|
* Deviation notes:
|
|
* - shadcn base-nova uses @base-ui/react/menu which does not support asChild on
|
|
* DropdownMenuTrigger or DropdownMenuItem. Using onClick+useRouter for navigation.
|
|
* - Clerk 7 UserButton no longer accepts afterSignOutUrl (removed from UserButtonProps).
|
|
*/
|
|
export function Sidebar({ pets }: SidebarProps) {
|
|
const pathname = usePathname();
|
|
const router = useRouter();
|
|
const { activePetId, setActivePet } = useActivePet();
|
|
|
|
const activePet = pets.find((p) => p.id === activePetId) ?? pets[0];
|
|
const [postTypeSheetOpen, setPostTypeSheetOpen] = useState(false);
|
|
|
|
return (
|
|
<nav className="hidden lg:flex flex-col w-[240px] min-h-screen bg-card border-r border shrink-0">
|
|
{/* Wordmark */}
|
|
<div className="flex items-center gap-2 px-4 py-5">
|
|
<PawPrint className="h-5 w-5 text-orange-500" aria-hidden="true" />
|
|
<span className="text-xl font-semibold text-foreground">PawFeed</span>
|
|
</div>
|
|
|
|
{/* Pet switcher */}
|
|
{activePet && (
|
|
<div className="px-3 pb-2">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
className="flex items-center gap-2 w-full px-2 py-2 rounded-md hover:bg-muted transition-colors text-left cursor-pointer"
|
|
aria-label="Switch active pet"
|
|
>
|
|
<Avatar className="h-8 w-8 shrink-0">
|
|
{activePet.avatarKey && (
|
|
<AvatarImage
|
|
src={getAvatarUrl(activePet.avatarKey)!}
|
|
alt={activePet.name}
|
|
/>
|
|
)}
|
|
<AvatarFallback className="text-xs bg-muted">
|
|
{activePet.name.slice(0, 2).toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<span className="text-sm font-medium text-foreground flex-1 truncate">
|
|
{activePet.name}
|
|
</span>
|
|
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start" className="w-[200px]">
|
|
{pets.map((pet) => (
|
|
<DropdownMenuItem
|
|
key={pet.id}
|
|
onClick={() => setActivePet(pet.id)}
|
|
className="flex items-center gap-2 cursor-pointer"
|
|
>
|
|
<Avatar className="h-6 w-6 shrink-0">
|
|
{pet.avatarKey && (
|
|
<AvatarImage
|
|
src={getAvatarUrl(pet.avatarKey)!}
|
|
alt={pet.name}
|
|
/>
|
|
)}
|
|
<AvatarFallback className="text-xs bg-muted">
|
|
{pet.name.slice(0, 2).toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<span className="flex-1 text-sm truncate">{pet.name}</span>
|
|
<Badge variant="secondary" className="text-xs shrink-0">
|
|
{pet.species.name}
|
|
</Badge>
|
|
{pet.id === activePetId && (
|
|
<span
|
|
className="w-2 h-2 rounded-full bg-orange-500 shrink-0"
|
|
aria-label="Active pet"
|
|
/>
|
|
)}
|
|
</DropdownMenuItem>
|
|
))}
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
onClick={() => router.push("/onboarding/pet")}
|
|
className="flex items-center gap-2 cursor-pointer"
|
|
>
|
|
<span className="text-orange-500">+</span>
|
|
<span>Add Pet</span>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
)}
|
|
|
|
{/* Nav links */}
|
|
<div className="flex-1 px-3 py-2 space-y-1">
|
|
{navLinks.map(({ href, label, icon: Icon, active, special }) => {
|
|
const isCurrentPage =
|
|
href === "/feed"
|
|
? pathname === "/feed" || pathname === "/"
|
|
: pathname.startsWith(href);
|
|
|
|
// Notifications: delegate to NotificationBell for live badge
|
|
if (special) {
|
|
return (
|
|
<NotificationBell key={href} variant="sidebar" isActive={isCurrentPage} />
|
|
);
|
|
}
|
|
|
|
if (!active) {
|
|
return (
|
|
<span
|
|
key={href}
|
|
aria-disabled="true"
|
|
className="flex items-center gap-3 px-3 py-2 rounded-md text-sm text-muted-foreground/50 cursor-not-allowed select-none"
|
|
>
|
|
<Icon className="h-4 w-4 shrink-0" aria-hidden="true" />
|
|
<span>{label}</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Link
|
|
key={href}
|
|
href={href}
|
|
className={`flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors ${
|
|
isCurrentPage
|
|
? "border-l-2 border-orange-500 text-foreground font-medium bg-muted pl-[10px]"
|
|
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
|
}`}
|
|
>
|
|
<Icon className="h-4 w-4 shrink-0" aria-hidden="true" />
|
|
<span>{label}</span>
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* New Post button — D-01: opens PostTypeSheet */}
|
|
<div className="px-3 pb-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => setPostTypeSheetOpen(true)}
|
|
className="flex items-center gap-3 w-full px-3 py-2 rounded-md text-sm font-medium bg-orange-500 text-white hover:bg-orange-600 transition-colors min-h-[44px]"
|
|
aria-label="Create post"
|
|
>
|
|
<Plus className="h-4 w-4 shrink-0" aria-hidden="true" />
|
|
<span>New Post</span>
|
|
</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 />
|
|
<span className="text-xs text-muted-foreground">Account</span>
|
|
</div>
|
|
|
|
{/* PostTypeSheet — D-01: bottom Sheet with Photo/Story/Milestone choices */}
|
|
<PostTypeSheet
|
|
open={postTypeSheetOpen}
|
|
onOpenChange={setPostTypeSheetOpen}
|
|
/>
|
|
</nav>
|
|
);
|
|
}
|