05rgfly6g: Add HexAvatar and use it for every existing avatar

This commit is contained in:
2026-10-19 21:50:40 -06:00
parent dfc0cac95a
commit 3306a24d33
10 changed files with 134 additions and 33 deletions
+2 -7
View File
@@ -1,11 +1,11 @@
"use client";
import { HexAvatar } from "@/components/HexAvatar";
import { useEffect, useRef, useState } from "react";
import type { DiscoverCategory } from "@/lib/discover";
import { fetchDiscover } from "@/lib/discover-client";
import type { ExpertResult } from "@/lib/experts-search";
import { fetchExperts } from "@/lib/experts-search-client";
import { initials } from "@/lib/feed-model";
export type SearchStatus = "idle" | "loading" | "ready" | "error";
@@ -61,12 +61,7 @@ export function ExpertSearchView(p: ExpertSearchViewProps) {
<ul className="rail-list">
{p.results.map((e) => (
<li key={String(e.id)} className="rail-card expert-card" data-testid="search-result">
{e.pictureUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img className="avatar" src={e.pictureUrl} alt="" width={40} height={40} />
) : (
<span className="avatar avatar-initials" aria-hidden="true">{initials(e.displayName)}</span>
)}
<HexAvatar name={e.displayName} pictureUrl={e.pictureUrl} size="md" />
<div className="rail-text">
<strong className="rail-name">{e.displayName || "Expert"}</strong>
{e.byline ? <span className="rail-byline">{e.byline}</span> : null}
+2 -7
View File
@@ -1,5 +1,5 @@
import { HexAvatar } from "@/components/HexAvatar";
import type { DiscoverExpert } from "@/lib/discover";
import { initials } from "@/lib/feed-model";
function Rating({ rating, reviews }: { rating: number | null; reviews: number | null }) {
if (rating === null && reviews === null) return null;
@@ -19,12 +19,7 @@ export function FeaturedExpertsRail({ experts }: { experts: DiscoverExpert[] })
<ul className="rail-list">
{experts.map((e) => (
<li key={String(e.id)} className="rail-card expert-card" data-testid="expert-card">
{e.pictureUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img className="avatar" src={e.pictureUrl} alt="" width={40} height={40} />
) : (
<span className="avatar avatar-initials" aria-hidden="true">{initials(e.displayName)}</span>
)}
<HexAvatar name={e.displayName} pictureUrl={e.pictureUrl} size="md" />
<div className="rail-text">
<strong className="rail-name">{e.displayName || "Expert"}</strong>
{e.byline ? <span className="rail-byline">{e.byline}</span> : null}
+41
View File
@@ -0,0 +1,41 @@
import { initials } from "@/lib/feed-model";
export type HexAvatarSize = "lg" | "md" | "sm";
/** Width by height in px for each spec size (ratio 0.866). */
export const HEX_SIZES: Record<HexAvatarSize, { width: number; height: number }> = {
lg: { width: 46, height: 53 },
md: { width: 38, height: 44 },
sm: { width: 30, height: 35 },
};
export const HEX_TONES = ["oxblood", "navy", "forest", "slate", "plum", "graphite"] as const;
export type HexTone = (typeof HEX_TONES)[number];
/** Same name always gives the same tone. */
export function toneFor(name: string): HexTone {
let sum = 0;
for (const ch of name.trim().toLowerCase()) sum = (sum * 31 + ch.codePointAt(0)!) >>> 0;
return HEX_TONES[sum % HEX_TONES.length];
}
export interface HexAvatarProps {
name: string;
pictureUrl?: string | null;
size?: HexAvatarSize;
}
export function HexAvatar({ name, pictureUrl, size = "md" }: HexAvatarProps) {
const { width, height } = HEX_SIZES[size];
if (pictureUrl) {
return (
// eslint-disable-next-line @next/next/no-img-element
<img className={`hex-avatar hex-avatar-${size}`} src={pictureUrl} alt="" width={width} height={height} />
);
}
return (
<span className={`hex-avatar hex-avatar-${size} hex-avatar-initials hex-avatar-${toneFor(name)}`} aria-hidden="true">
{initials(name)}
</span>
);
}
+3 -9
View File
@@ -1,9 +1,10 @@
"use client";
import { useState } from "react";
import { HexAvatar } from "@/components/HexAvatar";
import { CommentsSection } from "@/components/CommentsSection";
import { VideoPlayer } from "@/components/VideoPlayer";
import { initials, relativeTime, type CardPost } from "@/lib/feed-model";
import { relativeTime, type CardPost } from "@/lib/feed-model";
export interface PostCardProps {
post: CardPost;
@@ -36,14 +37,7 @@ export function PostCard({ post, now, onToggleLike, likeBusy, likeError, onSigne
return (
<article className="post" data-testid="post-card">
<header className="post-head">
{post.authorPictureUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img className="avatar" src={post.authorPictureUrl} alt="" width={40} height={40} />
) : (
<span className="avatar avatar-initials" aria-hidden="true">
{initials(post.authorName)}
</span>
)}
<HexAvatar name={post.authorName} pictureUrl={post.authorPictureUrl} size="lg" />
<div className="post-who">
<strong>{post.authorName}</strong>
<time dateTime={post.publishedAt ?? undefined}>{relativeTime(post.publishedAt, now)}</time>
+2 -7
View File
@@ -1,6 +1,6 @@
import { HexAvatar } from "@/components/HexAvatar";
import type { DiscoverMinnect } from "@/lib/discover";
import { MinnectMedia } from "./MinnectMedia";
import { initials } from "@/lib/feed-model";
export function TrendingMinnectsRail({ minnects }: { minnects: DiscoverMinnect[] }) {
if (minnects.length === 0) return null;
@@ -13,12 +13,7 @@ export function TrendingMinnectsRail({ minnects }: { minnects: DiscoverMinnect[]
return (
<li key={String(m.id)} className="rail-card minnect-card" data-testid="minnect-card">
<div className="minnect-who">
{m.expert.pictureUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img className="avatar avatar-sm" src={m.expert.pictureUrl} alt="" width={28} height={28} />
) : (
<span className="avatar avatar-sm avatar-initials" aria-hidden="true">{initials(who)}</span>
)}
<HexAvatar name={who} pictureUrl={m.expert.pictureUrl} size="sm" />
<strong className="rail-name">{who}</strong>
</div>
<MinnectMedia media={m.media} thumbnailUrl={m.thumbnailUrl} question={m.question} who={who} />