05rgfly6g: Add HexAvatar and use it for every existing avatar
This commit is contained in:
@@ -43,6 +43,15 @@
|
||||
--accent-lo: color-mix(in srgb, var(--accent), black 30%);
|
||||
--accent-soft: color-mix(in srgb, var(--accent), transparent 88%);
|
||||
|
||||
/* HexAvatar initials: dark muted gradients, same in both themes. */
|
||||
--hex-oxblood: linear-gradient(145deg, #6e1a22, #3a0d12);
|
||||
--hex-navy: linear-gradient(145deg, #243a63, #121d36);
|
||||
--hex-forest: linear-gradient(145deg, #25573f, #112a1f);
|
||||
--hex-slate: linear-gradient(145deg, #4a5663, #262d35);
|
||||
--hex-plum: linear-gradient(145deg, #55294f, #2a1328);
|
||||
--hex-graphite: linear-gradient(145deg, #3d3d42, #1c1c20);
|
||||
--avatar-ink: #F4F2EE;
|
||||
|
||||
/* Shape and motion. */
|
||||
--r-panel: 18px;
|
||||
--r-card: 16px;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Cormorant_Garamond, Manrope } from "next/font/google";
|
||||
import { DEFAULT_THEME, THEME_SCRIPT } from "@/lib/theme";
|
||||
import "./globals.css";
|
||||
import "../styles/type.css";
|
||||
import "../styles/avatar.css";
|
||||
import "../styles/legacy.css";
|
||||
|
||||
const manrope = Manrope({
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/* HexAvatar (ADR 0001, section 6). Pointy-top hexagon, width to height 0.866. */
|
||||
.hex-avatar {
|
||||
display: block;
|
||||
flex: none;
|
||||
object-fit: cover;
|
||||
background: var(--tile-3);
|
||||
clip-path: polygon(50% 0, 100% 25%, 100% 75%, 50% 100%, 0 75%, 0 25%);
|
||||
}
|
||||
.hex-avatar-lg { width: 46px; height: 53px; }
|
||||
.hex-avatar-md { width: 38px; height: 44px; }
|
||||
.hex-avatar-sm { width: 30px; height: 35px; }
|
||||
|
||||
.hex-avatar-initials {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--avatar-ink);
|
||||
font-weight: 600;
|
||||
letter-spacing: .02em;
|
||||
}
|
||||
.hex-avatar-lg.hex-avatar-initials { font-size: 16px; }
|
||||
.hex-avatar-md.hex-avatar-initials { font-size: 14px; }
|
||||
.hex-avatar-sm.hex-avatar-initials { font-size: 11px; }
|
||||
.hex-avatar-oxblood { background: var(--hex-oxblood); }
|
||||
.hex-avatar-navy { background: var(--hex-navy); }
|
||||
.hex-avatar-forest { background: var(--hex-forest); }
|
||||
.hex-avatar-slate { background: var(--hex-slate); }
|
||||
.hex-avatar-plum { background: var(--hex-plum); }
|
||||
.hex-avatar-graphite { background: var(--hex-graphite); }
|
||||
@@ -20,8 +20,6 @@ button:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.feed { max-width: 640px; margin: 0 auto; padding: 16px; display: flex; flex-direction: column; gap: 16px; }
|
||||
.post { background: var(--tile); border: 1px solid var(--line); border-radius: 8px; padding: 16px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.post-head { display: flex; align-items: center; gap: 12px; }
|
||||
.avatar { width: 40px; height: 40px; border-radius: 8px; object-fit: cover; background: var(--line); flex: none; }
|
||||
.avatar-initials { display: grid; place-items: center; background: var(--ink); color: var(--bg); font-weight: 500; font-size: 14px; }
|
||||
.post-who { display: flex; flex-direction: column; font-size: 14px; min-width: 0; }
|
||||
.post-who time { color: var(--ink-3); font-size: 13px; }
|
||||
.post-text { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.5; }
|
||||
@@ -101,7 +99,6 @@ button:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.rail-byline { color: var(--ink-3); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rail-meta { color: var(--ink-3); font-size: 13px; }
|
||||
.minnect-who { display: flex; align-items: center; gap: 8px; }
|
||||
.avatar-sm { width: 28px; height: 28px; font-size: 11px; }
|
||||
.minnect-thumb { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; border-radius: 8px; background: var(--line); }
|
||||
.minnect-question { margin: 0; font-size: 14px; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.minnect-video { background: black; border-radius: 0; }
|
||||
|
||||
Reference in New Issue
Block a user