feat(ui): initials-color avatars and collapsible desktop sidebar
Adds a PetAvatar wrapper (hash(name) mod 360 -> HSL background) and migrates the four most-visible avatar call sites (sidebar, mobile nav, post card, post detail incl. comments) to use it instead of duplicated inline initials logic. Desktop sidebar can now collapse to an icon-only 76px rail, persisted in localStorage (same client-init pattern as ActivePetContext). All nav rows, the new-post button, and the owner row adapt; secondary sections (language switcher, legal footer) hide when collapsed. Gitea #12, #14
This commit is contained in:
1 parent
e39b3f1f6e
commit
5fbf39593d
9 files changed
+207
-127
No files matched your search
+9
-2
@@ -10,7 +10,9 @@
|
||||
"newPost": "Beitrag erstellen",
|
||||
"addPet": "Tier hinzufügen",
|
||||
"account": "Konto",
|
||||
"profile": "Profil"
|
||||
"profile": "Profil",
|
||||
"collapseSidebar": "Sidebar einklappen",
|
||||
"expandSidebar": "Sidebar ausklappen"
|
||||
},
|
||||
"PostType": {
|
||||
"title": "Erstellen",
|
||||
@@ -102,7 +104,12 @@
|
||||
"photoUpdated": "Profilfoto aktualisiert",
|
||||
"fileTooLarge": "Das Foto muss kleiner als 10 MB sein.",
|
||||
"invalidType": "Lade ein JPG-, PNG- oder WebP-Bild hoch.",
|
||||
"uploading": "Wird hochgeladen…"
|
||||
"uploading": "Wird hochgeladen…",
|
||||
"cropTitle": "Foto zuschneiden",
|
||||
"zoom": "Zoom",
|
||||
"cropCancel": "Abbrechen",
|
||||
"cropSave": "Übernehmen",
|
||||
"cropSaving": "Wird verarbeitet…"
|
||||
},
|
||||
"Onboarding": {
|
||||
"addFirstPet": "Füge dein erstes Tier hinzu",
|
||||
|
||||
+9
-2
@@ -10,7 +10,9 @@
|
||||
"newPost": "New Post",
|
||||
"addPet": "Add Pet",
|
||||
"account": "Account",
|
||||
"profile": "Profile"
|
||||
"profile": "Profile",
|
||||
"collapseSidebar": "Collapse sidebar",
|
||||
"expandSidebar": "Expand sidebar"
|
||||
},
|
||||
"PostType": {
|
||||
"title": "Create",
|
||||
@@ -102,7 +104,12 @@
|
||||
"photoUpdated": "Profile photo updated",
|
||||
"fileTooLarge": "Photo must be under 10 MB.",
|
||||
"invalidType": "Upload a JPG, PNG, or WebP image.",
|
||||
"uploading": "Uploading…"
|
||||
"uploading": "Uploading…",
|
||||
"cropTitle": "Crop photo",
|
||||
"zoom": "Zoom",
|
||||
"cropCancel": "Cancel",
|
||||
"cropSave": "Apply",
|
||||
"cropSaving": "Processing…"
|
||||
},
|
||||
"Onboarding": {
|
||||
"addFirstPet": "Add your first pet",
|
||||
|
||||
@@ -6,7 +6,7 @@ import { toast } from "sonner";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
import { useActivePet } from "@/context/ActivePetContext";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { PetAvatar } from "@/components/ui/pet-avatar";
|
||||
import { BlurImage } from "@/components/ui/blur-image";
|
||||
import {
|
||||
Carousel,
|
||||
@@ -36,7 +36,6 @@ import {
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import { getAvatarUrl } from "@/lib/avatar-url";
|
||||
import { getMediaUrl } from "@/lib/media-url";
|
||||
import { parseCaption } from "@/lib/parse-caption";
|
||||
import { formatRelativeVerbose } from "@/lib/format-relative-time";
|
||||
@@ -158,7 +157,6 @@ function PostCardImpl({ post, onDeleted, variant = "feed" }: PostCardProps) {
|
||||
// Ownership: active pet's own post vs another pet's post
|
||||
const isOwnPost = activePetId === pet.id;
|
||||
|
||||
const avatarUrl = getAvatarUrl(pet.avatarKey);
|
||||
const isCarousel = images.length > 1;
|
||||
|
||||
// Carousel: track current slide index. Subscribing to the embla API and
|
||||
@@ -237,14 +235,7 @@ function PostCardImpl({ post, onDeleted, variant = "feed" }: PostCardProps) {
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<PawRing petId={pet.id} size={32}>
|
||||
<Avatar className="w-8 h-8">
|
||||
{avatarUrl && (
|
||||
<AvatarImage src={avatarUrl} alt={pet.name} />
|
||||
)}
|
||||
<AvatarFallback className="text-xs bg-muted">
|
||||
{pet.name[0]?.toUpperCase() ?? "?"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} className="w-8 h-8" />
|
||||
</PawRing>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
|
||||
@@ -7,7 +7,8 @@ import { toast } from "sonner";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
import { useActivePet } from "@/context/ActivePetContext";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { PetAvatar } from "@/components/ui/pet-avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MentionTextarea } from "@/components/ui/mention-textarea";
|
||||
import {
|
||||
@@ -36,7 +37,6 @@ import {
|
||||
type CarouselApi,
|
||||
} from "@/components/ui/carousel";
|
||||
import Link from "next/link";
|
||||
import { getAvatarUrl } from "@/lib/avatar-url";
|
||||
import { getMediaUrl } from "@/lib/media-url";
|
||||
import { BlurImage } from "@/components/ui/blur-image";
|
||||
import { parseCaption } from "@/lib/parse-caption";
|
||||
@@ -178,7 +178,6 @@ export function PostDetailDialog({ post, open, onClose, onDeleted }: PostDetailD
|
||||
const { pet, images } = post;
|
||||
const isCarousel = images.length > 1;
|
||||
const isOwnPost = activePetId === pet.id;
|
||||
const avatarUrl = getAvatarUrl(pet.avatarKey);
|
||||
const comments = commentsData?.comments ?? [];
|
||||
|
||||
// Extracted so it renders identically in the mobile bar and the desktop right-panel
|
||||
@@ -190,12 +189,7 @@ export function PostDetailDialog({ post, open, onClose, onDeleted }: PostDetailD
|
||||
onClick={onClose}
|
||||
>
|
||||
<PawRing petId={pet.id} size={32}>
|
||||
<Avatar className="w-8 h-8">
|
||||
{avatarUrl && <AvatarImage src={avatarUrl} alt={pet.name} />}
|
||||
<AvatarFallback className="text-xs bg-muted">
|
||||
{pet.name[0]?.toUpperCase() ?? "?"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} className="w-8 h-8" />
|
||||
</PawRing>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-foreground leading-tight truncate">{pet.name}</p>
|
||||
@@ -407,16 +401,14 @@ export function PostDetailDialog({ post, open, onClose, onDeleted }: PostDetailD
|
||||
<p className="text-sm text-muted-foreground text-center py-3">{tc("empty")}</p>
|
||||
)}
|
||||
{comments.map((comment) => {
|
||||
const commentAvatarUrl = getAvatarUrl(comment.pet.avatarKey);
|
||||
const isOwnComment = comment.pet.id === activePetId;
|
||||
return (
|
||||
<div key={comment.id} className="flex gap-2 group">
|
||||
<Avatar className="h-7 w-7 shrink-0 mt-0.5">
|
||||
{commentAvatarUrl && <AvatarImage src={commentAvatarUrl} alt={comment.pet.name} />}
|
||||
<AvatarFallback className="text-xs bg-muted">
|
||||
{comment.pet.name[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<PetAvatar
|
||||
name={comment.pet.name}
|
||||
avatarKey={comment.pet.avatarKey}
|
||||
className="h-7 w-7 shrink-0 mt-0.5"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<Link
|
||||
|
||||
@@ -22,10 +22,9 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { PetAvatar } from "@/components/ui/pet-avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useActivePet } from "@/context/ActivePetContext";
|
||||
import { getAvatarUrl } from "@/lib/avatar-url";
|
||||
|
||||
type Pet = {
|
||||
id: string;
|
||||
@@ -94,17 +93,11 @@ export function MobileNav({ pets }: MobileNavProps) {
|
||||
className="flex items-center gap-2 text-sm cursor-pointer"
|
||||
aria-label="Switch active pet"
|
||||
>
|
||||
<Avatar className="h-6 w-6 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>
|
||||
<PetAvatar
|
||||
name={activePet.name}
|
||||
avatarKey={activePet.avatarKey}
|
||||
className="h-6 w-6 shrink-0"
|
||||
/>
|
||||
<span className="font-medium text-foreground truncate max-w-[120px] flex items-center gap-1">
|
||||
{activePet.name}
|
||||
{activePet.isVerified && (
|
||||
@@ -120,17 +113,11 @@ export function MobileNav({ pets }: MobileNavProps) {
|
||||
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>
|
||||
<PetAvatar
|
||||
name={pet.name}
|
||||
avatarKey={pet.avatarKey}
|
||||
className="h-6 w-6 shrink-0"
|
||||
/>
|
||||
<span className="flex-1 text-sm truncate flex items-center gap-1">
|
||||
{pet.name}
|
||||
{pet.isVerified && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import {
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
MessageCircle,
|
||||
PawPrint,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
Ticket,
|
||||
BadgeCheck,
|
||||
@@ -28,10 +30,9 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { PetAvatar } from "@/components/ui/pet-avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useActivePet } from "@/context/ActivePetContext";
|
||||
import { getAvatarUrl } from "@/lib/avatar-url";
|
||||
|
||||
type Pet = {
|
||||
id: string;
|
||||
@@ -72,6 +73,8 @@ const navLinks = [
|
||||
* DropdownMenuTrigger or DropdownMenuItem. Using onClick+useRouter for navigation.
|
||||
* - Clerk 7 UserButton no longer accepts afterSignOutUrl (removed from UserButtonProps).
|
||||
*/
|
||||
const SIDEBAR_COLLAPSED_KEY = "sidebar_collapsed";
|
||||
|
||||
export function Sidebar({ pets }: SidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
@@ -80,16 +83,49 @@ export function Sidebar({ pets }: SidebarProps) {
|
||||
|
||||
const activePet = pets.find((p) => p.id === activePetId) ?? pets[0];
|
||||
const [postTypeSheetOpen, setPostTypeSheetOpen] = useState(false);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
// First-load fallback is handled once, centrally, by ActivePetInitializer
|
||||
// (mounted alongside Sidebar/MobileNav in the app layout) — don't duplicate it here.
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize from localStorage on client mount (avoids SSR mismatch —
|
||||
// same accepted pattern as ActivePetContext).
|
||||
const stored = localStorage.getItem(SIDEBAR_COLLAPSED_KEY);
|
||||
if (stored === "true") setCollapsed(true);
|
||||
}, []);
|
||||
|
||||
const toggleCollapsed = () => {
|
||||
setCollapsed((prev) => {
|
||||
const next = !prev;
|
||||
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(next));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="hidden lg:flex flex-col w-[240px] h-screen sticky top-0 bg-card border-r border shrink-0 overflow-y-auto">
|
||||
<nav
|
||||
className={`hidden lg:flex flex-col h-screen sticky top-0 bg-card border-r border shrink-0 overflow-y-auto transition-[width] duration-200 relative ${
|
||||
collapsed ? "w-[76px]" : "w-[240px]"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleCollapsed}
|
||||
aria-label={collapsed ? t("expandSidebar") : t("collapseSidebar")}
|
||||
className="absolute -right-3 top-6 z-10 flex h-6 w-6 items-center justify-center rounded-full border border-border bg-card text-muted-foreground shadow-sm hover:text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
) : (
|
||||
<ChevronLeft className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 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 className={`flex items-center gap-2 px-4 py-5 ${collapsed ? "justify-center px-0" : ""}`}>
|
||||
<PawPrint className="h-5 w-5 text-orange-500 shrink-0" aria-hidden="true" />
|
||||
{!collapsed && <span className="text-xl font-semibold text-foreground">PawFeed</span>}
|
||||
</div>
|
||||
|
||||
{/* Pet switcher */}
|
||||
@@ -97,27 +133,28 @@ export function Sidebar({ pets }: SidebarProps) {
|
||||
<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"
|
||||
className={`flex items-center gap-2 w-full px-2 py-2 rounded-md hover:bg-muted transition-colors text-left cursor-pointer ${
|
||||
collapsed ? "justify-center px-0" : ""
|
||||
}`}
|
||||
aria-label="Switch active pet"
|
||||
title={collapsed ? activePet.name : undefined}
|
||||
>
|
||||
<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 flex items-center gap-1">
|
||||
{activePet.name}
|
||||
{activePet.isVerified && (
|
||||
<BadgeCheck className="h-3.5 w-3.5 shrink-0 fill-blue-500 text-background" />
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<PetAvatar
|
||||
name={activePet.name}
|
||||
avatarKey={activePet.avatarKey}
|
||||
className="h-8 w-8 shrink-0"
|
||||
/>
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span className="text-sm font-medium text-foreground flex-1 truncate flex items-center gap-1">
|
||||
{activePet.name}
|
||||
{activePet.isVerified && (
|
||||
<BadgeCheck className="h-3.5 w-3.5 shrink-0 fill-blue-500 text-background" />
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-[200px]">
|
||||
{pets.map((pet) => (
|
||||
@@ -126,17 +163,11 @@ export function Sidebar({ pets }: SidebarProps) {
|
||||
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>
|
||||
<PetAvatar
|
||||
name={pet.name}
|
||||
avatarKey={pet.avatarKey}
|
||||
className="h-6 w-6 shrink-0"
|
||||
/>
|
||||
<span className="flex-1 text-sm truncate flex items-center gap-1">
|
||||
{pet.name}
|
||||
{pet.isVerified && (
|
||||
@@ -179,7 +210,12 @@ export function Sidebar({ pets }: SidebarProps) {
|
||||
// Notifications: delegate to NotificationBell for live badge
|
||||
if (special) {
|
||||
return (
|
||||
<NotificationBell key={href} variant="sidebar" isActive={isCurrentPage} />
|
||||
<NotificationBell
|
||||
key={href}
|
||||
variant="sidebar"
|
||||
isActive={isCurrentPage}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -188,10 +224,13 @@ export function Sidebar({ pets }: SidebarProps) {
|
||||
<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"
|
||||
title={collapsed ? label : undefined}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-md text-sm text-muted-foreground/50 cursor-not-allowed select-none ${
|
||||
collapsed ? "justify-center px-0" : ""
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<span>{label}</span>
|
||||
{!collapsed && <span>{label}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -200,14 +239,19 @@ export function Sidebar({ pets }: SidebarProps) {
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
title={collapsed ? label : undefined}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors ${
|
||||
collapsed ? "justify-center px-0" : ""
|
||||
} ${
|
||||
isCurrentPage
|
||||
? "border-l-2 border-orange-500 text-foreground font-medium bg-muted pl-[10px]"
|
||||
? collapsed
|
||||
? "text-foreground font-medium bg-muted"
|
||||
: "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>
|
||||
{!collapsed && <span>{label}</span>}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
@@ -218,43 +262,54 @@ export function Sidebar({ pets }: SidebarProps) {
|
||||
<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]"
|
||||
title={collapsed ? t("newPost") : undefined}
|
||||
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] ${
|
||||
collapsed ? "justify-center px-0" : ""
|
||||
}`}
|
||||
aria-label="Create post"
|
||||
>
|
||||
<Plus className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<span>{t("newPost")}</span>
|
||||
{!collapsed && <span>{t("newPost")}</span>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Language switcher */}
|
||||
<div className="px-1 pb-1">
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<>
|
||||
{/* Language switcher */}
|
||||
<div className="px-1 pb-1">
|
||||
<LanguageSwitcher />
|
||||
</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>
|
||||
<span aria-hidden="true">·</span>
|
||||
<Link href="/nutzungsbedingungen" className="hover:text-muted-foreground transition-colors">
|
||||
AGB
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground/40">
|
||||
PawFeed · Alpha-Test · © {new Date().getFullYear()}
|
||||
</p>
|
||||
</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>
|
||||
<span aria-hidden="true">·</span>
|
||||
<Link href="/nutzungsbedingungen" className="hover:text-muted-foreground transition-colors">
|
||||
AGB
|
||||
</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">
|
||||
<div
|
||||
className={`px-4 py-4 border-t border flex items-center gap-2 ${
|
||||
collapsed ? "justify-center px-2" : ""
|
||||
}`}
|
||||
>
|
||||
<UserButton />
|
||||
<span className="text-xs text-muted-foreground">{t("account")}</span>
|
||||
{!collapsed && <span className="text-xs text-muted-foreground">{t("account")}</span>}
|
||||
</div>
|
||||
|
||||
{/* PostTypeSheet — D-01: bottom Sheet with Photo/Story/Milestone choices */}
|
||||
|
||||
@@ -11,9 +11,11 @@ interface NotificationBellProps {
|
||||
/** "sidebar" = full row with label; "mobile" = icon + label stacked */
|
||||
variant: "sidebar" | "mobile";
|
||||
isActive?: boolean;
|
||||
/** Sidebar variant only: icon-only row when the sidebar is collapsed */
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
export function NotificationBell({ variant, isActive }: NotificationBellProps) {
|
||||
export function NotificationBell({ variant, isActive, collapsed }: NotificationBellProps) {
|
||||
const trpc = useTRPC();
|
||||
const { activePetId } = useActivePet();
|
||||
const t = useTranslations("Nav");
|
||||
@@ -32,9 +34,14 @@ export function NotificationBell({ variant, isActive }: NotificationBellProps) {
|
||||
return (
|
||||
<Link
|
||||
href="/notifications"
|
||||
title={collapsed ? t("notifications") : undefined}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors ${
|
||||
collapsed ? "justify-center px-0" : ""
|
||||
} ${
|
||||
isActive
|
||||
? "border-l-2 border-orange-500 text-foreground font-medium bg-muted pl-[10px]"
|
||||
? collapsed
|
||||
? "text-foreground font-medium bg-muted"
|
||||
: "border-l-2 border-orange-500 text-foreground font-medium bg-muted pl-[10px]"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
@@ -46,7 +53,7 @@ export function NotificationBell({ variant, isActive }: NotificationBellProps) {
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span>{t("notifications")}</span>
|
||||
{!collapsed && <span>{t("notifications")}</span>}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { getAvatarUrl } from "@/lib/avatar-url";
|
||||
import { getInitialsColor } from "@/lib/avatar-color";
|
||||
|
||||
interface PetAvatarProps {
|
||||
name: string;
|
||||
avatarKey?: string | null;
|
||||
size?: "default" | "sm" | "lg";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PetAvatar({ name, avatarKey, size = "default", className }: PetAvatarProps) {
|
||||
return (
|
||||
<Avatar size={size} className={className}>
|
||||
{avatarKey && <AvatarImage src={getAvatarUrl(avatarKey)!} alt={name} />}
|
||||
<AvatarFallback
|
||||
className="text-white"
|
||||
style={{ backgroundColor: getInitialsColor(name) }}
|
||||
>
|
||||
{name.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Deterministic background color for initials-fallback avatars.
|
||||
* Sums the char codes of the name (not Math.random — must stay stable
|
||||
* across reloads/re-renders for the same name), mods into a hue, HSL out.
|
||||
*/
|
||||
export function getInitialsColor(name: string): string {
|
||||
const sum = Array.from(name).reduce((total, char) => total + char.charCodeAt(0), 0);
|
||||
const hue = sum % 360;
|
||||
return `hsl(${hue}, 65%, 55%)`;
|
||||
}
|
||||
Reference in new issue
Block a user