managarten/packages/feedback/src/avatar.ts
Till JS ee5bb2871c feat(community): Phase 3.C — Identität (Avatar + Klarname-Toggle + Karma + Eulen-Profil)
Macht aus den Pseudonymen echte Charaktere ohne Klarnamen-Zwang.

Pixel-Identicon-Avatar (3.C.2):
- generateAvatarSvg(displayHash) — pure-function, deterministisch.
  5×5 left-mirrored Identicon mit HSL-Foreground/Background aus dem
  Hash. Inline-SVG, kein Storage, kein img-load-Flicker.
- <EulenAvatar> Component im Package, in ItemCard neben dem Pseudonym.

Klarname-Toggle (3.C.1):
- auth.users + community_show_real_name boolean (default off, opt-in).
- PATCH /api/v1/me/profile akzeptiert communityShowRealName.
- mana-analytics LEFT JOINs auth.users → bei opt-in liefert auth-
  required /public + /me/reacted Endpoints zusätzlich realName.
- Anonymous /api/v1/public/feedback/* zeigt realName NIE — auch nicht
  wenn opted-in. Public-Mirror bleibt für SEO + Privacy safe.
- Migration 008_community_identity.sql lokal + prod eingespielt.

Karma-System (3.C.3):
- auth.users + community_karma int. toggleReaction increment/decrement
  am Author-User (Self-Reactions zählen nicht — kein Self-Farming).
- KARMA_THRESHOLDS + tierFromKarma() im Package: Bronze (0-9) /
  Silver (10-49) / Gold (50-199) / Platin (200+).
- ItemCard zeigt Tier-Dot neben dem Pseudonym, Title-Tooltip mit
  Karma-Zahl. Floor-clamped at 0.

Eulen-Profil (3.C.4):
- GET /api/v1/public/feedback/eule/{hash} — alle public-Posts dieser
  Eule + aggregiertes Karma. SHA256-Format-Validation.
- /community/eule/[hash] Public-SSR-Route mit Avatar-Hero, Tier-Badge,
  Karma-Counter, Post-Liste. Author-Klick im ItemCard navigiert hin.
- publicFeedbackService.getEulenProfile() im Package.

PublicFeedbackItem erweitert um displayHash (public Pseudonym-ID,
SHA256 ist one-way → safe to expose) + karma + optional realName.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:15:16 +02:00

97 lines
3.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Pixel-Identicon avatar generator — pure function, no storage.
*
* Derives a deterministic 5×5 left-mirrored pixel grid plus an HSL
* color from a `display_hash`, returns a self-contained SVG string.
* Same hash → same avatar, every time, on every device.
*
* Hash byte layout (we use the first 18 bytes / 36 hex chars):
* bytes[0] → hue (0360°)
* bytes[1] → saturation (5090%)
* bytes[2] → lightness (3560%)
* bytes[3..17] → 15 cell on/off bits (3 cols × 5 rows, mirrored)
*
* The 5×5 grid is generated by computing the left 3 columns from the
* hash bits, then mirroring columns 0+1 onto 4+3 so the silhouette is
* always symmetric — the trick that makes Identicons feel like real
* faces/creatures even though they're random pixels.
*/
interface AvatarRendering {
cells: boolean[][]; // 5 rows × 5 cols
fg: string;
bg: string;
}
const GRID = 5;
function hexToBytes(hex: string): number[] {
const cleaned = hex.replace(/[^0-9a-f]/gi, '');
const bytes: number[] = [];
for (let i = 0; i < cleaned.length; i += 2) {
bytes.push(parseInt(cleaned.slice(i, i + 2), 16) || 0);
}
return bytes;
}
function rendering(displayHash: string): AvatarRendering {
const bytes = hexToBytes(displayHash);
if (bytes.length < 18) {
// Pad short hashes — shouldn't happen with SHA256 (32 bytes), but
// defensive so the function never throws on a malformed input.
while (bytes.length < 18) bytes.push(0);
}
const hue = (bytes[0] / 255) * 360;
const sat = 50 + (bytes[1] / 255) * 40;
const light = 35 + (bytes[2] / 255) * 25;
const fg = `hsl(${hue.toFixed(0)}, ${sat.toFixed(0)}%, ${light.toFixed(0)}%)`;
const bg = `hsl(${hue.toFixed(0)}, ${sat.toFixed(0)}%, 92%)`;
const cells: boolean[][] = Array.from({ length: GRID }, () =>
Array.from({ length: GRID }, () => false)
);
let bitIdx = 0;
for (let row = 0; row < GRID; row++) {
for (let col = 0; col < 3; col++) {
const byteIdx = 3 + Math.floor(bitIdx / 8);
const bit = (bytes[byteIdx] >> (bitIdx % 8)) & 1;
const on = bit === 1;
cells[row][col] = on;
// Mirror cols 0+1 onto 4+3. Col 2 is the spine.
if (col < 2) cells[row][GRID - 1 - col] = on;
bitIdx++;
}
}
return { cells, fg, bg };
}
/**
* Render the avatar as an inline SVG string. Default 64×64 viewport.
* Width/height attributes are intentionally omitted so callers can
* size via CSS (`.avatar { width: 32px; height: 32px }` etc.).
*/
export function generateAvatarSvg(displayHash: string): string {
const { cells, fg, bg } = rendering(displayHash);
const rectSize = 100 / GRID;
let pixels = '';
for (let row = 0; row < GRID; row++) {
for (let col = 0; col < GRID; col++) {
if (!cells[row][col]) continue;
const x = (col * rectSize).toFixed(2);
const y = (row * rectSize).toFixed(2);
pixels += `<rect x="${x}" y="${y}" width="${rectSize.toFixed(2)}" height="${rectSize.toFixed(2)}"/>`;
}
}
return (
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" role="img" aria-label="Avatar">` +
`<rect width="100" height="100" rx="14" fill="${bg}"/>` +
`<g fill="${fg}">${pixels}</g>` +
`</svg>`
);
}
/** Exported for tests. */
export const __TEST__ = { rendering, hexToBytes, GRID };