feat(01-04): wire CDN avatar into profile, edit page, sidebar, and mobile nav
- pets/[petId]/page.tsx: replace /api/avatar stub with NEXT_PUBLIC_R2_PUBLIC_URL CDN URL - pets/[petId]/edit/page.tsx: pass initialAvatarKey to PetForm so AvatarUpload shows current photo - Sidebar.tsx: add avatarKey to Pet type; show AvatarImage from CDN for active pet and dropdown list - MobileNav.tsx: same CDN avatar pattern for mobile pet switcher - next.config.ts: add images.remotePatterns for R2 CDN hostname (T-4-04 safeguard)
This commit is contained in:
1 parent
1bd1a89c68
commit
fb8bb5c8fe
5 files changed
+58
-5
No files matched your search
+27
-1
@@ -1,7 +1,33 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
/**
|
||||
* Derive the R2 CDN hostname for Next.js Image remote patterns.
|
||||
* Falls back to '*.r2.cloudflarestorage.com' when env var is not set (CI/build).
|
||||
* The NEXT_PUBLIC_R2_PUBLIC_URL is the only R2 env var accessible client-side — it is
|
||||
* the CDN domain only (not credentials).
|
||||
*/
|
||||
function getR2Hostname(): string {
|
||||
const publicUrl = process.env.NEXT_PUBLIC_R2_PUBLIC_URL;
|
||||
if (publicUrl) {
|
||||
try {
|
||||
return new URL(publicUrl).hostname;
|
||||
} catch {
|
||||
// Invalid URL — fall through to default
|
||||
}
|
||||
}
|
||||
return "*.r2.cloudflarestorage.com";
|
||||
}
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
allowedDevOrigins: ['192.168.1.92'],
|
||||
allowedDevOrigins: ["192.168.1.92"],
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: getR2Hostname(),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -90,6 +90,7 @@ export default function EditPetPage() {
|
||||
<PetForm
|
||||
mode="edit"
|
||||
petId={pet.id}
|
||||
initialAvatarKey={pet.avatarKey ?? null}
|
||||
initialValues={{
|
||||
name: pet.name,
|
||||
speciesId: pet.speciesId,
|
||||
|
||||
@@ -53,11 +53,11 @@ export default async function PetProfilePage({ params }: PageProps) {
|
||||
<div className="container max-w-[640px] mx-auto px-6 py-8">
|
||||
{/* Pet identity — pet-first, owner-subordinate */}
|
||||
<div className="flex flex-col items-center gap-3 mb-8">
|
||||
{/* Avatar */}
|
||||
{/* Avatar — served from Cloudflare CDN via NEXT_PUBLIC_R2_PUBLIC_URL */}
|
||||
<Avatar className="w-32 h-32 text-2xl">
|
||||
{pet.avatarKey && (
|
||||
<AvatarImage
|
||||
src={`/api/avatar/${pet.avatarKey}`}
|
||||
src={`${process.env.NEXT_PUBLIC_R2_PUBLIC_URL}/${pet.avatarKey}`}
|
||||
alt={pet.name}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -17,13 +17,14 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useActivePet } from "@/context/ActivePetContext";
|
||||
|
||||
type Pet = {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarKey: string | null;
|
||||
species: { name: string };
|
||||
};
|
||||
|
||||
@@ -67,6 +68,12 @@ export function MobileNav({ pets }: MobileNavProps) {
|
||||
aria-label="Switch active pet"
|
||||
>
|
||||
<Avatar className="h-6 w-6 shrink-0">
|
||||
{activePet.avatarKey && (
|
||||
<AvatarImage
|
||||
src={`${process.env.NEXT_PUBLIC_R2_PUBLIC_URL}/${activePet.avatarKey}`}
|
||||
alt={activePet.name}
|
||||
/>
|
||||
)}
|
||||
<AvatarFallback className="text-xs bg-muted">
|
||||
{activePet.name.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
@@ -84,6 +91,12 @@ export function MobileNav({ pets }: MobileNavProps) {
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<Avatar className="h-6 w-6 shrink-0">
|
||||
{pet.avatarKey && (
|
||||
<AvatarImage
|
||||
src={`${process.env.NEXT_PUBLIC_R2_PUBLIC_URL}/${pet.avatarKey}`}
|
||||
alt={pet.name}
|
||||
/>
|
||||
)}
|
||||
<AvatarFallback className="text-xs bg-muted">
|
||||
{pet.name.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
|
||||
@@ -19,13 +19,14 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useActivePet } from "@/context/ActivePetContext";
|
||||
|
||||
type Pet = {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarKey: string | null;
|
||||
species: { name: string };
|
||||
};
|
||||
|
||||
@@ -82,6 +83,12 @@ export function Sidebar({ pets }: SidebarProps) {
|
||||
aria-label="Switch active pet"
|
||||
>
|
||||
<Avatar className="h-8 w-8 shrink-0">
|
||||
{activePet.avatarKey && (
|
||||
<AvatarImage
|
||||
src={`${process.env.NEXT_PUBLIC_R2_PUBLIC_URL}/${activePet.avatarKey}`}
|
||||
alt={activePet.name}
|
||||
/>
|
||||
)}
|
||||
<AvatarFallback className="text-xs bg-muted">
|
||||
{activePet.name.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
@@ -99,6 +106,12 @@ export function Sidebar({ pets }: SidebarProps) {
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<Avatar className="h-6 w-6 shrink-0">
|
||||
{pet.avatarKey && (
|
||||
<AvatarImage
|
||||
src={`${process.env.NEXT_PUBLIC_R2_PUBLIC_URL}/${pet.avatarKey}`}
|
||||
alt={pet.name}
|
||||
/>
|
||||
)}
|
||||
<AvatarFallback className="text-xs bg-muted">
|
||||
{pet.name.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
|
||||
Reference in new issue
Block a user