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:
admin committed 2026-08-12 12:52:21 +02:00
1 parent e39b3f1f6e
commit 5fbf39593d
9 files changed
+207 -127

No files matched your search

+9 -2
View File
@@ -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
View File
@@ -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",
+2 -11
View File
@@ -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">
+8 -16
View File
@@ -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
+11 -24
View File
@@ -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 && (
+124 -69
View File
@@ -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>
);
}
+24
View File
@@ -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>
);
}
+10
View File
@@ -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%)`;
}