mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:41:08 +02:00
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>
This commit is contained in:
parent
42e4d58c8c
commit
ee5bb2871c
16 changed files with 760 additions and 23 deletions
|
|
@ -6,8 +6,11 @@
|
|||
<script lang="ts">
|
||||
import {
|
||||
ReactionBar,
|
||||
EulenAvatar,
|
||||
FEEDBACK_CATEGORY_LABELS,
|
||||
FEEDBACK_STATUS_CONFIG,
|
||||
KARMA_TIER_CONFIG,
|
||||
tierFromKarma,
|
||||
type PublicFeedbackItem,
|
||||
type ReactionEmoji,
|
||||
} from '@mana/feedback';
|
||||
|
|
@ -24,6 +27,8 @@
|
|||
|
||||
let statusConfig = $derived(FEEDBACK_STATUS_CONFIG[item.status]);
|
||||
let categoryLabel = $derived(FEEDBACK_CATEGORY_LABELS[item.category] ?? item.category);
|
||||
let tier = $derived(tierFromKarma(item.karma ?? 0));
|
||||
let tierCfg = $derived(KARMA_TIER_CONFIG[tier]);
|
||||
|
||||
function handleClick() {
|
||||
if (onClick) onClick(item.id);
|
||||
|
|
@ -82,7 +87,19 @@
|
|||
{/if}
|
||||
|
||||
<footer class="item-footer">
|
||||
<span class="author">{item.displayName}</span>
|
||||
<a
|
||||
class="author-link"
|
||||
href={`/community/eule/${item.displayHash}`}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
title={`Profil von ${item.displayName} öffnen · ${tierCfg.label}-Eule (${item.karma ?? 0} Karma)`}
|
||||
>
|
||||
<EulenAvatar displayHash={item.displayHash} size={24} title={item.displayName} />
|
||||
<span class="author">
|
||||
<span class="tier-dot" style:background-color={tierCfg.color} aria-hidden="true"></span>
|
||||
{item.displayName}
|
||||
{#if item.realName}<span class="real-name">· {item.realName}</span>{/if}
|
||||
</span>
|
||||
</a>
|
||||
<ReactionBar
|
||||
reactions={item.reactions}
|
||||
myReactions={item.myReactions ?? []}
|
||||
|
|
@ -209,10 +226,41 @@
|
|||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.author-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
padding: 0.125rem 0.25rem;
|
||||
margin: -0.125rem -0.25rem;
|
||||
border-radius: 0.375rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.author-link:hover {
|
||||
background: hsl(var(--color-muted) / 0.4);
|
||||
}
|
||||
|
||||
.author {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.real-name {
|
||||
font-style: normal;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.tier-dot {
|
||||
display: inline-block;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
margin-right: 0.25rem;
|
||||
box-shadow: 0 0 0 1px hsl(0 0% 0% / 0.08);
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
import { error } from '@sveltejs/kit';
|
||||
import { getManaAnalyticsUrl } from '$lib/api/config';
|
||||
import type { EulenProfileResponse } from '@mana/feedback';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, fetch, setHeaders }) => {
|
||||
const { hash } = params;
|
||||
if (!/^[0-9a-f]{32,64}$/i.test(hash)) error(404, 'Eulen-Profil nicht gefunden');
|
||||
|
||||
const res = await fetch(`${getManaAnalyticsUrl()}/api/v1/public/feedback/eule/${hash}`);
|
||||
if (res.status === 400 || res.status === 404) error(404, 'Eulen-Profil nicht gefunden');
|
||||
if (!res.ok) error(502, 'Fehler beim Laden');
|
||||
|
||||
const data = (await res.json()) as EulenProfileResponse;
|
||||
|
||||
setHeaders({
|
||||
'cache-control': 'public, max-age=60, s-maxage=120, stale-while-revalidate=600',
|
||||
});
|
||||
|
||||
return data;
|
||||
};
|
||||
261
apps/mana/apps/web/src/routes/community/eule/[hash]/+page.svelte
Normal file
261
apps/mana/apps/web/src/routes/community/eule/[hash]/+page.svelte
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import {
|
||||
EulenAvatar,
|
||||
KARMA_TIER_CONFIG,
|
||||
tierFromKarma,
|
||||
FEEDBACK_STATUS_CONFIG,
|
||||
type FeedbackStatus,
|
||||
} from '@mana/feedback';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let tier = $derived(tierFromKarma(data.karma));
|
||||
let tierCfg = $derived(KARMA_TIER_CONFIG[tier]);
|
||||
|
||||
function statusOf(s: string) {
|
||||
return FEEDBACK_STATUS_CONFIG[s as FeedbackStatus] ?? null;
|
||||
}
|
||||
|
||||
function fmtDate(s: string): string {
|
||||
try {
|
||||
return new Date(s).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return s.slice(0, 10);
|
||||
}
|
||||
}
|
||||
|
||||
function goToItem(id: string) {
|
||||
void goto(`/community/${id}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.displayName ?? 'Eule'} — Mana Community</title>
|
||||
<meta
|
||||
name="description"
|
||||
content={`Profil von ${data.displayName ?? 'einer Eule'} mit ${data.items.length} Wünschen und ${data.karma} Karma.`}
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<div class="profile">
|
||||
<a href="/community" class="back-link">← Zurück zum Feed</a>
|
||||
|
||||
<header class="hero">
|
||||
<EulenAvatar displayHash={data.displayHash} size={96} title={data.displayName ?? ''} />
|
||||
<div class="hero-meta">
|
||||
<h1>{data.displayName ?? 'Anonym'}</h1>
|
||||
<div class="badges">
|
||||
<span class="tier" style:background-color="{tierCfg.color}22" style:color={tierCfg.color}>
|
||||
<span class="tier-dot" style:background-color={tierCfg.color}></span>
|
||||
{tierCfg.label}-Eule
|
||||
</span>
|
||||
<span class="karma">{data.karma} Karma</span>
|
||||
<span class="post-count">{data.items.length} Wünsche</span>
|
||||
</div>
|
||||
<p class="lead">
|
||||
Alle öffentlichen Beiträge dieser Eule. Anonym, aber konsistent — derselbe Mensch dahinter,
|
||||
derselbe Avatar.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if data.items.length === 0}
|
||||
<div class="state">Diese Eule hat noch nichts gepostet.</div>
|
||||
{:else}
|
||||
<div class="grid">
|
||||
{#each data.items as item (item.id)}
|
||||
{@const cfg = statusOf(item.status)}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_to_interactive_role -->
|
||||
<article class="row" role="button" tabindex="0" onclick={() => goToItem(item.id)}>
|
||||
<div class="row-meta">
|
||||
{#if cfg}
|
||||
<span class="status-pill" style:color={cfg.color} style:border-color={cfg.color}>
|
||||
{cfg.label}
|
||||
</span>
|
||||
{/if}
|
||||
{#if item.moduleContext}
|
||||
<span class="badge module">{item.moduleContext}</span>
|
||||
{/if}
|
||||
<span class="muted">{fmtDate(item.createdAt)}</span>
|
||||
</div>
|
||||
{#if item.title}
|
||||
<h3 class="row-title">{item.title}</h3>
|
||||
{/if}
|
||||
<p class="row-text">{item.feedbackText}</p>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.profile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
text-decoration: none;
|
||||
font-size: 0.8125rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
padding: 1rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.hero-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.hero-meta h1 {
|
||||
margin: 0;
|
||||
font-size: 1.75rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.tier {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 999px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tier-dot {
|
||||
display: inline-block;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.karma,
|
||||
.post-count {
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--color-muted) / 0.4);
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.lead {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
line-height: 1.5;
|
||||
max-width: 60ch;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.875rem 1rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
background: hsl(var(--color-card));
|
||||
border-radius: 0.875rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
transform 0.15s;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
border-color: hsl(var(--color-primary) / 0.4);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.row-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
padding: 0.125rem 0.4375rem;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 999px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0.125rem 0.4375rem;
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--color-muted) / 0.4);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.badge.module {
|
||||
background: hsl(var(--color-primary) / 0.12);
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.row-title {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.row-text {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.state {
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue