feat(community): public anon hub — module + inline + admin + onboarding

Macht @mana/feedback omnipräsent + öffentlich. Phase 2 vom
Public-Community-Hub-Plan (docs/plans/feedback-hub-public.md).

Inline-Touchpoints:
- FeedbackHook: Lightbulb-Button, opens FeedbackQuickModal vorausgefüllt
  mit module-context. Auto-injected in jeder ModuleShell-Header
  (window-actions row), opt-out via hideFeedback prop.
- GlobalFeedbackPill: Floating "Idee?"-Pill bottom-right, self-hides
  auf /onboarding, /feedback, /community, und für Gäste. Auto-detected
  module-context aus URL bzw. ?app=-Param.
- FeedbackQuickModal: 3-Klick-Submit mit Category-Dropdown, Public-
  Toggle, "Sichtbar als {Pseudonym}"-Confirm-State.

Community-Modul (eigenes Modul, in Workbench drop-bar):
- module.config.ts (server-only, keine Sync-Tabellen)
- queries.ts: useCommunityFeed + useCommunityItem mit auth-aware Switch
  zwischen public + auth-enriched Endpoints
- ListView/DetailView/RoadmapView mit ItemCard-Component
- App-Registry-Eintrag (Megaphone-Icon, #F59E0B)

Public-Mirror-Routes (kein AuthGate):
- /community            — Feed mit SSR-Pre-Render via Public-Endpoint
- /community/[id]       — Single item + replies, SSR
- /community/roadmap    — Kanban Submitted/Planned/InProgress/Completed
- /community/admin      — Founder-only Triage (Status, AdminResponse,
                         visibility-Toggle); Client-side role-gate
                         redirect → /community.
SEO: <svelte:head> mit title/description, <noscript>-Fallback,
Cache-Headers stale-while-revalidate.

API:
- web's lib/api/feedback.ts pointed an die echte mana-analytics-URL
  (3064 dev) statt mana-auth. Neuer publicFeedbackService für
  unauthenticated SSR.
- getManaAnalyticsUrl() in lib/api/config.ts.

Onboarding-Wish public-by-default:
- Disclosure-Text: "Erscheint in Community-Page als Tier-Pseudonym".
- Toggle "Öffentlich teilen" / "Nur für Admins" mit Default on.
- Submitted-Confirm zeigt das generierte Display-Name.

Plan-Doc-Updates:
- feedback-hub.md Phase 2 abgespeckt → Verweis auf feedback-hub-public.md
- feedback-hub-public.md komplett: Architektur-Optionen A-E, Phase 2.x,
  Phase 3 Roadmap (16 Future-Features), Risiken.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-27 00:02:25 +02:00
parent c9b122076a
commit 8804a20a7f
17 changed files with 1723 additions and 33 deletions

View file

@ -22,6 +22,19 @@ export function getManaAuthUrl(): string {
return process.env.PUBLIC_MANA_AUTH_URL || 'http://localhost:3001';
}
/**
* Get the mana-analytics service URL (port 3064 in dev).
* Hosts the public-community feedback hub.
*/
export function getManaAnalyticsUrl(): string {
if (browser && typeof window !== 'undefined') {
const injected = (window as unknown as { __PUBLIC_MANA_ANALYTICS_URL__?: string })
.__PUBLIC_MANA_ANALYTICS_URL__;
return injected || 'http://localhost:3064';
}
return process.env.PUBLIC_MANA_ANALYTICS_URL || 'http://localhost:3064';
}
/**
* Get the mana-events service URL (Phase 1b: public RSVP backend).
*/

View file

@ -1,13 +1,22 @@
/**
* Feedback Service Instance for Mana Web App
* Feedback Service Instance for Mana Web App.
*
* Talks to mana-analytics (port 3064 in dev). Two factories:
* - feedbackService: auth-required for submit/react/admin
* - publicFeedbackService: anonymous read-only for SSR + non-logged-in
*/
import { createFeedbackService } from '@mana/feedback';
import { createFeedbackService, createPublicFeedbackService } from '@mana/feedback';
import { authStore } from '$lib/stores/auth.svelte';
import { getManaAuthUrl } from './config';
import { getManaAnalyticsUrl } from './config';
export const feedbackService = createFeedbackService({
apiUrl: getManaAuthUrl(),
apiUrl: getManaAnalyticsUrl(),
appId: 'mana',
getAuthToken: async () => authStore.getValidToken(),
});
export const publicFeedbackService = createPublicFeedbackService({
apiUrl: getManaAnalyticsUrl(),
appId: 'mana',
});

View file

@ -83,6 +83,7 @@ import {
NotePencil,
FilmStrip,
Hourglass,
Megaphone,
} from '@mana/shared-icons';
// ── Apps with entity capabilities ───────────────────────────
@ -1332,6 +1333,16 @@ registerApp({
},
});
registerApp({
id: 'community',
name: 'Community',
color: '#F59E0B',
icon: Megaphone,
views: {
list: { load: () => import('$lib/modules/community/ListView.svelte') },
},
});
registerApp({
id: 'wardrobe',
name: 'Kleiderschrank',

View file

@ -0,0 +1,65 @@
<!--
FeedbackHook — Inline icon-button that opens the FeedbackQuickModal
pre-filled with the calling module's id. Drops into ModuleShell's
window-actions row by default; can be placed anywhere by callers.
ModuleShell auto-injects this in its header (right next to the
window-controls). Modules opt out via `hideFeedback={true}`.
-->
<script lang="ts">
import { Lightbulb } from '@mana/shared-icons';
import { authStore } from '$lib/stores/auth.svelte';
import FeedbackQuickModal from './FeedbackQuickModal.svelte';
interface Props {
moduleId?: string;
size?: number;
}
let { moduleId, size = 22 }: Props = $props();
let open = $state(false);
function handleClick(e: MouseEvent) {
e.stopPropagation();
// Submit requires login. Guests would just see an auth-error toast,
// so silently skip the modal — global pill catches them elsewhere.
if (!authStore.user) return;
open = true;
}
</script>
{#if authStore.user}
<button
type="button"
class="feedback-hook-btn"
onclick={handleClick}
title="Idee oder Feedback?"
aria-label="Feedback geben"
>
<Lightbulb {size} weight="bold" />
</button>
<FeedbackQuickModal {open} moduleContext={moduleId} onClose={() => (open = false)} />
{/if}
<style>
.feedback-hook-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
border: none;
background: transparent;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
transition: all 0.15s;
}
.feedback-hook-btn:hover {
background: hsl(var(--color-surface-hover, var(--color-muted)));
color: hsl(var(--color-primary));
}
</style>

View file

@ -0,0 +1,395 @@
<!--
FeedbackQuickModal — Lightweight feedback-submission modal opened from
any FeedbackHook button or the global floating pill.
Pre-fills moduleContext from the caller, exposes a category dropdown,
and posts via the shared feedbackService. Submit flips into a "Danke"
state that shows the public pseudonym so the user knows how their
post will appear in the community.
-->
<script lang="ts">
import { X, PaperPlaneTilt } from '@mana/shared-icons';
import { FEEDBACK_CATEGORY_LABELS, type FeedbackCategory } from '@mana/feedback';
import { feedbackService } from '$lib/api/feedback';
interface Props {
open: boolean;
onClose: () => void;
/** Module that owns this hook — pre-filled into the submission. */
moduleContext?: string;
/** Pre-selected category. Defaults to 'feature'. */
defaultCategory?: FeedbackCategory;
/** Optional headline override. */
title?: string;
}
let {
open,
onClose,
moduleContext,
defaultCategory = 'feature',
title = 'Idee oder Feedback?',
}: Props = $props();
let text = $state('');
let category = $state<FeedbackCategory>(defaultCategory);
let isPublic = $state(true);
let saving = $state(false);
let error = $state<string | null>(null);
let submittedDisplayName = $state<string | null>(null);
const MAX_LEN = 2000;
// Categories the user can pick — onboarding-wish/praise/other are
// possible too, but the inline form is geared at feature/bug/improvement.
const SELECTABLE: FeedbackCategory[] = ['feature', 'improvement', 'bug', 'praise', 'question'];
async function handleSubmit() {
const trimmed = text.trim();
if (!trimmed || saving) return;
saving = true;
error = null;
try {
const res = await feedbackService.createFeedback({
feedbackText: trimmed,
category,
isPublic,
moduleContext,
});
submittedDisplayName =
(res as { displayName?: string }).displayName ??
(res as { feedback?: { displayName?: string } }).feedback?.displayName ??
null;
} catch (err) {
console.error('[FeedbackQuickModal] submit failed:', err);
error = err instanceof Error ? err.message : 'Senden fehlgeschlagen.';
} finally {
saving = false;
}
}
function handleClose() {
text = '';
category = defaultCategory;
isPublic = true;
error = null;
submittedDisplayName = null;
onClose();
}
function onBackdropKey(e: KeyboardEvent) {
if (e.key === 'Escape') handleClose();
}
</script>
{#if open}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="backdrop"
role="dialog"
aria-modal="true"
onclick={handleClose}
onkeydown={onBackdropKey}
tabindex="-1"
>
<div class="modal" role="document" onclick={(e) => e.stopPropagation()}>
<header class="modal-header">
<h2>{submittedDisplayName ? 'Danke!' : title}</h2>
<button class="close-btn" onclick={handleClose} aria-label="Schließen">
<X size={18} weight="bold" />
</button>
</header>
{#if submittedDisplayName}
<div class="success">
<p>
Dein Feedback ist eingegangen — sichtbar als
<strong>{submittedDisplayName}</strong>.
</p>
{#if isPublic}
<p class="muted">Es taucht in der Community-Page auf, sobald wir es freigeben.</p>
{:else}
<p class="muted">Es bleibt privat und ist nur für dich + Admins sichtbar.</p>
{/if}
<button class="btn-primary" onclick={handleClose}>Schließen</button>
</div>
{:else}
<div class="body">
{#if moduleContext}
<div class="context-badge">Modul: {moduleContext}</div>
{/if}
<label class="field">
<span class="label">Kategorie</span>
<select bind:value={category}>
{#each SELECTABLE as cat (cat)}
<option value={cat}>{FEEDBACK_CATEGORY_LABELS[cat]}</option>
{/each}
</select>
</label>
<label class="field">
<span class="label">Was ist los?</span>
<!-- svelte-ignore a11y_autofocus -->
<textarea
bind:value={text}
placeholder="Beschreib's so genau du willst…"
maxlength={MAX_LEN}
rows="5"
autofocus
></textarea>
<span class="counter">{MAX_LEN - text.length} Zeichen übrig</span>
</label>
<label class="checkbox">
<input type="checkbox" bind:checked={isPublic} />
<span>
In Community-Feed öffentlich anzeigen (anonym als Eulen-Pseudonym).
{#if !isPublic}<small>— bleibt privat, nur für Admins sichtbar.</small>{/if}
</span>
</label>
{#if error}
<p class="error" role="alert">{error}</p>
{/if}
<div class="actions">
<button class="btn-ghost" onclick={handleClose} disabled={saving}>Abbrechen</button>
<button
class="btn-primary"
onclick={handleSubmit}
disabled={saving || text.trim().length < 3}
>
<span>{saving ? 'Sende…' : 'Senden'}</span>
<PaperPlaneTilt size={16} weight="bold" />
</button>
</div>
</div>
{/if}
</div>
</div>
{/if}
<style>
.backdrop {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: hsl(0 0% 0% / 0.5);
backdrop-filter: blur(2px);
}
.modal {
width: 100%;
max-width: 520px;
max-height: calc(100dvh - 2rem);
display: flex;
flex-direction: column;
background: hsl(var(--color-card));
color: hsl(var(--color-foreground));
border: 2px solid hsl(0 0% 0% / 0.12);
border-radius: 1rem;
box-shadow:
0 16px 32px hsl(0 0% 0% / 0.18),
0 6px 12px hsl(0 0% 0% / 0.1);
overflow: hidden;
}
:global(.dark) .modal {
border-color: hsl(0 0% 0% / 0.28);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.875rem 1rem 0.5rem;
}
.modal-header h2 {
margin: 0;
font-size: 1.125rem;
font-weight: 700;
letter-spacing: -0.01em;
}
.close-btn {
width: 28px;
height: 28px;
border: none;
background: transparent;
color: hsl(var(--color-muted-foreground));
border-radius: 50%;
cursor: pointer;
}
.close-btn:hover {
background: hsl(var(--color-surface-hover, var(--color-muted)));
color: hsl(var(--color-foreground));
}
.body {
padding: 0.5rem 1rem 1rem;
display: flex;
flex-direction: column;
gap: 0.875rem;
overflow-y: auto;
}
.success {
padding: 1rem 1rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.success p {
margin: 0;
font-size: 0.9375rem;
}
.success .muted {
color: hsl(var(--color-muted-foreground));
font-size: 0.875rem;
}
.success .btn-primary {
align-self: flex-end;
margin-top: 0.5rem;
}
.context-badge {
align-self: flex-start;
padding: 0.25rem 0.5rem;
border-radius: 999px;
background: hsl(var(--color-primary) / 0.12);
color: hsl(var(--color-primary));
font-size: 0.75rem;
font-weight: 600;
}
.field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: hsl(var(--color-muted-foreground));
}
.field select,
.field textarea {
width: 100%;
padding: 0.625rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-surface, var(--color-background)));
color: hsl(var(--color-foreground));
font: inherit;
}
.field textarea {
resize: vertical;
min-height: 6rem;
}
.field select:focus,
.field textarea:focus {
outline: none;
border-color: hsl(var(--color-primary));
box-shadow: 0 0 0 3px hsl(var(--color-primary) / 0.15);
}
.counter {
align-self: flex-end;
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
font-variant-numeric: tabular-nums;
}
.checkbox {
display: flex;
gap: 0.5rem;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
line-height: 1.4;
}
.checkbox input {
margin-top: 0.2rem;
}
.checkbox small {
display: block;
font-size: 0.75rem;
}
.error {
margin: 0;
font-size: 0.8125rem;
color: hsl(var(--color-error, 0 84% 60%));
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.btn-ghost {
padding: 0.5rem 0.875rem;
border: none;
background: transparent;
color: hsl(var(--color-muted-foreground));
font-size: 0.875rem;
border-radius: 0.5rem;
cursor: pointer;
}
.btn-ghost:hover:not(:disabled) {
background: hsl(var(--color-muted) / 0.4);
color: hsl(var(--color-foreground));
}
.btn-ghost:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
border: none;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground, 0 0% 100%));
font-size: 0.875rem;
font-weight: 600;
border-radius: 0.5rem;
cursor: pointer;
transition:
transform 0.15s ease,
box-shadow 0.15s ease;
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px hsl(var(--color-primary) / 0.3);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>

View file

@ -0,0 +1,100 @@
<!--
GlobalFeedbackPill — fallback feedback affordance for routes outside
ModuleShell (settings, profile, dashboards). Sits bottom-right, tucked
above the bottom-stack chrome.
Auto-detects module-context from the URL (e.g. `/todo` → `todo`,
`/?app=notes` → `notes`); otherwise leaves moduleContext undefined.
Hides itself on /onboarding and on /feedback + /community pages where
the affordance would be redundant.
-->
<script lang="ts">
import { page } from '$app/stores';
import { Lightbulb } from '@mana/shared-icons';
import { authStore } from '$lib/stores/auth.svelte';
import FeedbackQuickModal from './FeedbackQuickModal.svelte';
let open = $state(false);
let path = $derived($page.url.pathname);
let activeAppParam = $derived($page.url.searchParams.get('app'));
let hidden = $derived(
path.startsWith('/onboarding') ||
path.startsWith('/feedback') ||
path.startsWith('/community') ||
!authStore.user
);
let moduleContext = $derived.by(() => {
// Path-based detection: /todo, /notes, /picture, …
const seg = path.split('/').filter(Boolean)[0];
const fromPath = seg && !seg.startsWith('(') ? seg : null;
// Workbench `?app=` param wins (homepage scene mode).
return activeAppParam ?? fromPath ?? undefined;
});
function handleClick() {
open = true;
}
</script>
{#if !hidden}
<button
type="button"
class="pill"
onclick={handleClick}
title="Idee oder Feedback?"
aria-label="Feedback geben"
>
<Lightbulb size={18} weight="bold" />
<span class="label">Idee?</span>
</button>
<FeedbackQuickModal {open} {moduleContext} onClose={() => (open = false)} />
{/if}
<style>
.pill {
position: fixed;
right: 1rem;
bottom: calc(var(--bottom-chrome-height, 5rem) + 1rem);
z-index: 50;
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-card));
color: hsl(var(--color-foreground));
border-radius: 999px;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
box-shadow:
0 6px 16px hsl(0 0% 0% / 0.12),
0 2px 6px hsl(0 0% 0% / 0.08);
transition:
transform 0.15s ease,
box-shadow 0.15s ease,
border-color 0.15s ease;
}
.pill:hover {
transform: translateY(-1px);
border-color: hsl(var(--color-primary) / 0.5);
box-shadow:
0 10px 22px hsl(0 0% 0% / 0.16),
0 3px 8px hsl(0 0% 0% / 0.1);
color: hsl(var(--color-primary));
}
@media (max-width: 480px) {
.pill .label {
display: none;
}
.pill {
padding: 0.5rem;
}
}
</style>

View file

@ -34,6 +34,7 @@
} from '@mana/shared-icons';
import type { Snippet, Component } from 'svelte';
import { PAGE_WIDTH_PRESETS, nearestPresetIndex } from '../page-carousel/width-presets';
import FeedbackHook from '$lib/components/feedback/FeedbackHook.svelte';
interface Props {
// Layout mode
@ -66,6 +67,15 @@
helpOpen?: boolean;
onContextMenu?: (e: MouseEvent) => void;
// Inline feedback hook — renders a small Lightbulb button in the
// window-actions row. Submitted feedback is tagged with `moduleId`
// so the public community feed can group/filter by module.
/** Module identifier passed to the inline FeedbackHook. */
moduleId?: string;
/** Suppress the auto-injected FeedbackHook (e.g. on the
* /community-/feedback-pages where it's redundant). */
hideFeedback?: boolean;
// Snippets
header_left?: Snippet;
badge?: Snippet;
@ -94,6 +104,8 @@
onHelp,
helpOpen = false,
onContextMenu,
moduleId,
hideFeedback = false,
header_left,
badge,
actions,
@ -192,6 +204,9 @@
{#if actions}
{@render actions()}
{/if}
{#if !hideFeedback}
<FeedbackHook {moduleId} />
{/if}
{#if onHelp}
<button
class="window-btn"

View file

@ -1,14 +1,19 @@
<!--
Onboarding — Screen 4: Wish.
Free-text "what do you want from Mana?" capture. Posts to the central
@mana/feedback hub as category='onboarding-wish', isPublic=false.
@mana/feedback hub as category='onboarding-wish'.
Public by default — appears in the /community feed under a Tier-
pseudonym ("Wachsame Eule #4528"). Users can opt out via the visibility
toggle, in which case the wish stays private (admin-only).
Submit is fail-soft: a network/server failure logs a warning and
still completes the flow — onboarding must never block on backend
latency.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { ArrowLeft, Check } from '@mana/shared-icons';
import { ArrowLeft, Check, Globe, Lock } from '@mana/shared-icons';
import { onboardingFlow } from '$lib/stores/onboarding-flow.svelte';
import { onboardingStatus } from '$lib/stores/onboarding-status.svelte';
import { feedbackService } from '$lib/api/feedback';
@ -16,7 +21,9 @@
const MAX_LEN = 2000;
let wish = $state(onboardingFlow.pendingWish ?? '');
let isPublic = $state(true);
let saving = $state(false);
let submittedDisplayName = $state<string | null>(null);
let trimmed = $derived(wish.trim());
let charsLeft = $derived(MAX_LEN - wish.length);
@ -29,11 +36,15 @@
if (trimmed.length > 0) {
onboardingFlow.setPendingWish(trimmed);
try {
await feedbackService.createFeedback({
const res = await feedbackService.createFeedback({
feedbackText: trimmed,
category: 'onboarding-wish',
isPublic: false,
isPublic,
});
submittedDisplayName =
(res as { displayName?: string }).displayName ??
(res as { feedback?: { displayName?: string } }).feedback?.displayName ??
null;
} catch (err) {
console.warn('[onboarding/wish] feedback submit failed:', err);
}
@ -81,6 +92,33 @@
</div>
</div>
<!-- Public-Disclosure mit Toggle -->
<div class="visibility">
<button
type="button"
class="vis-toggle"
class:active={isPublic}
onclick={() => (isPublic = !isPublic)}
aria-pressed={isPublic}
>
<span class="vis-icon">
{#if isPublic}<Globe size={16} weight="bold" />{:else}<Lock size={16} weight="bold" />{/if}
</span>
<span class="vis-label">
{isPublic ? 'Öffentlich teilen' : 'Nur für Admins'}
</span>
</button>
<p class="vis-hint">
{#if isPublic}
Erscheint in unserer Community-Page als <strong>Tier-Pseudonym</strong> (z.B. "Wachsame Eule
#4528"). Dein Name wird <em>nicht</em> gezeigt.
{:else}
Bleibt privat — nur du und das Mana-Team können das lesen. Du kannst es später öffentlich
stellen.
{/if}
</p>
</div>
<div class="actions">
<button type="button" class="btn-ghost" onclick={handleBack} disabled={saving}>
<ArrowLeft size={16} weight="bold" />
@ -97,6 +135,12 @@
<Check size={16} weight="bold" />
</button>
</div>
{#if submittedDisplayName}
<aside class="preview" aria-live="polite">
Gesendet — sichtbar als <strong>{submittedDisplayName}</strong>
</aside>
{/if}
</div>
<style>
@ -166,6 +210,53 @@
color: hsl(var(--color-error, 0 84% 60%));
}
.visibility {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem 0.875rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.75rem;
background: hsl(var(--color-muted) / 0.2);
}
.vis-toggle {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-card));
color: hsl(var(--color-foreground));
border-radius: 999px;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
}
.vis-toggle.active {
border-color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
}
.vis-toggle:hover {
transform: translateY(-1px);
}
.vis-icon {
display: inline-flex;
}
.vis-hint {
margin: 0;
font-size: 0.8125rem;
line-height: 1.4;
color: hsl(var(--color-muted-foreground));
}
.actions {
display: flex;
justify-content: space-between;
@ -226,4 +317,13 @@
opacity: 0.6;
cursor: not-allowed;
}
.preview {
padding: 0.625rem 0.875rem;
border-radius: 0.625rem;
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
font-size: 0.8125rem;
text-align: center;
}
</style>

View file

@ -0,0 +1,136 @@
<!--
Public community route — outside (app)/ on purpose so AuthGate doesn't
bounce non-logged-in visitors. Renders a thin shell with brand header
and Login CTA so the surface stands on its own as a marketing-asset.
-->
<script lang="ts">
import { authStore } from '$lib/stores/auth.svelte';
import { Megaphone } from '@mana/shared-icons';
let { children } = $props();
</script>
<div class="public-shell">
<header class="public-header">
<a class="brand" href="/">
<Megaphone size={20} weight="bold" />
<span class="brand-text">
<strong>Mana Community</strong>
<small>Stimmen aus der Mana-Welt</small>
</span>
</a>
<nav class="nav-links">
<a href="/community">Feed</a>
<a href="/community/roadmap">Roadmap</a>
{#if authStore.user}
<a class="cta" href="/?app=community">In Mana öffnen</a>
{:else}
<a class="cta" href="/login">Login</a>
{/if}
</nav>
</header>
<main class="public-main">
{@render children()}
</main>
<footer class="public-footer">
<p>
Anonym, aber konsistent — jeder Beitrag kommt von einer "Eule", "Otter" oder einem anderen
Tier-Pseudonym, das pro Person stabil bleibt. Reagieren und antworten geht nur eingeloggt.
</p>
</footer>
</div>
<style>
.public-shell {
min-height: 100dvh;
display: flex;
flex-direction: column;
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
}
.public-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.875rem 1.25rem;
border-bottom: 1px solid hsl(var(--color-border));
background: hsl(var(--color-card));
position: sticky;
top: 0;
z-index: 10;
}
.brand {
display: inline-flex;
align-items: center;
gap: 0.625rem;
text-decoration: none;
color: hsl(var(--color-foreground));
}
.brand-text {
display: flex;
flex-direction: column;
line-height: 1.2;
}
.brand-text strong {
font-size: 0.9375rem;
letter-spacing: -0.01em;
}
.brand-text small {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.nav-links {
display: flex;
align-items: center;
gap: 0.875rem;
font-size: 0.875rem;
}
.nav-links a {
color: hsl(var(--color-muted-foreground));
text-decoration: none;
}
.nav-links a:hover {
color: hsl(var(--color-foreground));
}
.cta {
padding: 0.375rem 0.75rem;
border-radius: 999px;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground, 0 0% 100%)) !important;
font-weight: 600;
}
.public-main {
flex: 1;
max-width: 960px;
width: 100%;
margin: 0 auto;
padding: 1rem 1rem 2rem;
}
.public-footer {
padding: 1rem 1.25rem;
border-top: 1px solid hsl(var(--color-border));
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
max-width: 960px;
width: 100%;
margin: 0 auto;
}
.public-footer p {
margin: 0;
line-height: 1.5;
}
</style>

View file

@ -0,0 +1,31 @@
/**
* SSR loader for the public community feed. Fetches from
* /api/v1/public/feedback/feed (anonymous) so even cold-cache
* unauthenticated visitors get pre-rendered content for SEO.
*/
import { getManaAnalyticsUrl } from '$lib/api/config';
import type { PageServerLoad } from './$types';
import type { PublicFeedListResponse } from '@mana/feedback';
export const load: PageServerLoad = async ({ fetch, setHeaders }) => {
const url = `${getManaAnalyticsUrl()}/api/v1/public/feedback/feed?appId=mana&limit=50`;
let items: PublicFeedListResponse['items'] = [];
let error: string | null = null;
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = (await res.json()) as PublicFeedListResponse;
items = body.items;
} catch (err) {
console.warn('[community] SSR fetch failed:', err);
error = err instanceof Error ? err.message : 'Laden fehlgeschlagen';
}
setHeaders({
'cache-control': 'public, max-age=60, s-maxage=120, stale-while-revalidate=600',
});
return { items, error };
};

View file

@ -0,0 +1,100 @@
<!--
/community — Public community feed.
SSR pre-renders the items from the anonymous endpoint; once mounted,
the client-side ListView (auth-enriched if logged in) takes over with
filters + reactions.
-->
<script lang="ts">
import ListView from '$lib/modules/community/views/ListView.svelte';
// data is from +page.server.ts — used as initial paint for SEO/non-JS,
// but the ListView re-fetches on mount (client-side, possibly authenticated).
let { data } = $props();
</script>
<svelte:head>
<title>Mana Community — Feedback &amp; Wünsche</title>
<meta
name="description"
content="Was Nutzer sich von Mana wünschen — anonym, aber persistent als Tier-Pseudonym. Lies alles, mach mit, sobald du eingeloggt bist."
/>
<meta property="og:title" content="Mana Community — Feedback &amp; Wünsche" />
<meta property="og:type" content="website" />
</svelte:head>
<div class="community-public">
<header class="hero">
<h1>Was Mana-Nutzer sich wünschen</h1>
<p class="lead">
Echte Stimmen, anonym aber konsistent. Lies, was andere bewegt — wenn du Lust hast, mach mit.
</p>
</header>
{#if data.error && data.items.length === 0}
<div class="state error">Konnte den Feed gerade nicht laden — versuch's gleich nochmal.</div>
{/if}
<!-- ListView fetches client-side; SSR data is the initial bundle for SEO. -->
<ListView />
{#if data.items.length > 0}
<noscript>
<div class="ssr-fallback">
<h2>Aktuelle Wünsche</h2>
<ul>
{#each data.items as item (item.id)}
<li>
<strong>{item.title ?? item.feedbackText.slice(0, 80)}</strong>
<small>{item.displayName}</small>
</li>
{/each}
</ul>
</div>
</noscript>
{/if}
</div>
<style>
.community-public {
display: flex;
flex-direction: column;
gap: 1rem;
}
.hero {
padding: 1rem 0 0.5rem;
}
.hero h1 {
margin: 0 0 0.5rem 0;
font-size: 1.75rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.lead {
margin: 0;
font-size: 0.9375rem;
color: hsl(var(--color-muted-foreground));
max-width: 60ch;
line-height: 1.5;
}
.state {
padding: 1rem;
border-radius: 0.625rem;
border: 1px solid hsl(var(--color-border));
font-size: 0.875rem;
}
.state.error {
border-color: hsl(var(--color-error, 0 84% 60%) / 0.4);
color: hsl(var(--color-error, 0 84% 60%));
}
.ssr-fallback {
padding: 1rem;
border: 1px dashed hsl(var(--color-border));
border-radius: 0.625rem;
}
</style>

View file

@ -0,0 +1,21 @@
import { error } from '@sveltejs/kit';
import { getManaAnalyticsUrl } from '$lib/api/config';
import type { PageServerLoad } from './$types';
import type { PublicItemResponse } from '@mana/feedback';
export const load: PageServerLoad = async ({ params, fetch, setHeaders }) => {
const { id } = params;
if (!id || !/^[0-9a-f-]{36}$/i.test(id)) error(404, 'Eintrag nicht gefunden');
const res = await fetch(`${getManaAnalyticsUrl()}/api/v1/public/feedback/${id}`);
if (res.status === 404) error(404, 'Eintrag nicht gefunden');
if (!res.ok) error(502, 'Fehler beim Laden');
const data = (await res.json()) as PublicItemResponse;
setHeaders({
'cache-control': 'public, max-age=60, s-maxage=120, stale-while-revalidate=600',
});
return data;
};

View file

@ -0,0 +1,57 @@
<script lang="ts">
import { page } from '$app/stores';
import DetailView from '$lib/modules/community/views/DetailView.svelte';
let { data } = $props();
let id = $derived($page.params.id ?? data.item.id);
</script>
<svelte:head>
<title>{data.item.title ?? 'Community-Beitrag'} — Mana Community</title>
<meta name="description" content={data.item.feedbackText.slice(0, 160)} />
</svelte:head>
<div class="detail-public">
<a href="/community" class="back-link">← Zurück zum Feed</a>
<DetailView {id} />
<noscript>
<article>
<h1>{data.item.title ?? data.item.feedbackText.slice(0, 80)}</h1>
<p>{data.item.feedbackText}</p>
<p><em>{data.item.displayName}</em></p>
{#if data.replies.length > 0}
<h2>Antworten ({data.replies.length})</h2>
{#each data.replies as r (r.id)}
<blockquote>
<p>{r.feedbackText}</p>
<p><em>{r.displayName}</em></p>
</blockquote>
{/each}
{/if}
</article>
</noscript>
</div>
<style>
.detail-public {
display: flex;
flex-direction: column;
gap: 1rem;
}
.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));
}
</style>

View file

@ -0,0 +1,362 @@
<!--
/community/admin — Founder/Admin triage hub.
Lives in the public /community route tree, but client-side gates on
authStore.user.role === 'admin'. Lets the founder filter all feedback
(public + private), update status, write admin responses, and toggle
visibility.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { feedbackService } from '$lib/api/feedback';
import {
FEEDBACK_CATEGORY_LABELS,
FEEDBACK_STATUS_CONFIG,
type Feedback,
type FeedbackCategory,
type FeedbackStatus,
} from '@mana/feedback';
let isAdmin = $derived(authStore.user?.role === 'admin');
$effect(() => {
if (authStore.initialized && !authStore.loading && !isAdmin) {
goto('/community');
}
});
let items = $state<Feedback[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
let filterCategory = $state<FeedbackCategory | ''>('');
let filterStatus = $state<FeedbackStatus | ''>('');
let filterModule = $state('');
async function reload() {
if (!isAdmin) return;
loading = true;
error = null;
try {
const res = await feedbackService.adminListAll({
category: filterCategory || undefined,
status: filterStatus || undefined,
moduleContext: filterModule || undefined,
limit: 200,
});
items = (res.items as Feedback[]) ?? [];
} catch (err) {
error = err instanceof Error ? err.message : 'Laden fehlgeschlagen';
} finally {
loading = false;
}
}
$effect(() => {
void [filterCategory, filterStatus, filterModule, isAdmin];
if (isAdmin) reload();
});
async function patchItem(id: string, patch: Partial<Feedback>) {
try {
await feedbackService.adminPatch(id, patch as never);
await reload();
} catch (err) {
console.error('[community/admin] patch failed:', err);
}
}
const STATUS_OPTIONS = Object.entries(FEEDBACK_STATUS_CONFIG).map(([k, v]) => ({
value: k as FeedbackStatus,
label: v.label,
}));
function formatDate(s: string): string {
try {
return new Date(s).toLocaleString('de-DE');
} catch {
return s;
}
}
</script>
{#if !isAdmin}
<div class="gate">
<div class="gate-icon" aria-hidden="true">🔒</div>
<h3>Nur für Admins</h3>
<p>Diese Seite ist nicht für dich.</p>
</div>
{:else}
<div class="admin">
<header class="admin-header">
<h1>Community-Admin</h1>
<p class="muted">
Alle Feedback-Einträge (öffentlich + privat). Status &amp; Antworten direkt setzen.
</p>
</header>
<div class="filters">
<select bind:value={filterCategory}>
<option value="">Alle Kategorien</option>
{#each Object.entries(FEEDBACK_CATEGORY_LABELS) as [val, lbl] (val)}
<option value={val}>{lbl}</option>
{/each}
</select>
<select bind:value={filterStatus}>
<option value="">Alle Status</option>
{#each STATUS_OPTIONS as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
<input type="text" bind:value={filterModule} placeholder="Modul (z.B. todo)" />
</div>
{#if loading && items.length === 0}
<div class="state">Lade…</div>
{:else if error}
<div class="state error">{error}</div>
{:else if items.length === 0}
<div class="state">Keine Einträge passen.</div>
{:else}
<div class="grid">
{#each items as item (item.id)}
<article class="row" class:not-public={!item.isPublic}>
<div class="row-meta">
<span class="badge">{FEEDBACK_CATEGORY_LABELS[item.category]}</span>
{#if item.moduleContext}
<span class="badge module">{item.moduleContext}</span>
{/if}
{#if !item.isPublic}
<span class="badge private">privat</span>
{/if}
<span class="muted">{formatDate(item.createdAt)}</span>
<span class="muted">{item.displayName ?? item.userId}</span>
</div>
{#if item.title}
<h3 class="row-title">{item.title}</h3>
{/if}
<p class="row-text">{item.feedbackText}</p>
<div class="row-controls">
<label class="ctl">
<span>Status</span>
<select
value={item.status}
onchange={(e) =>
patchItem(item.id, {
status: (e.currentTarget as HTMLSelectElement).value as FeedbackStatus,
})}
>
{#each STATUS_OPTIONS as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</label>
<label class="ctl checkbox">
<input
type="checkbox"
checked={item.isPublic}
onchange={(e) =>
patchItem(item.id, {
isPublic: (e.currentTarget as HTMLInputElement).checked,
})}
/>
<span>öffentlich</span>
</label>
</div>
<details class="response-block">
<summary>
Antwort {item.adminResponse
? `(${item.adminResponse.slice(0, 30)}…)`
: '(noch keine)'}
</summary>
<textarea
rows="3"
placeholder="Antwort vom Team…"
value={item.adminResponse ?? ''}
onblur={(e) => {
const next = (e.currentTarget as HTMLTextAreaElement).value;
if (next !== (item.adminResponse ?? '')) {
patchItem(item.id, { adminResponse: next });
}
}}
></textarea>
</details>
</article>
{/each}
</div>
{/if}
</div>
{/if}
<style>
.gate {
padding: 4rem 1rem;
text-align: center;
color: hsl(var(--color-muted-foreground));
}
.gate-icon {
font-size: 2.5rem;
}
.gate h3 {
margin: 0.5rem 0;
}
.admin {
display: flex;
flex-direction: column;
gap: 1rem;
}
.admin-header h1 {
margin: 0 0 0.25rem 0;
font-size: 1.5rem;
}
.muted {
color: hsl(var(--color-muted-foreground));
font-size: 0.8125rem;
}
.filters {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.filters select,
.filters input {
padding: 0.375rem 0.625rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-surface, var(--color-background)));
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
}
.grid {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.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;
}
.row.not-public {
border-color: hsl(var(--color-border));
background: hsl(var(--color-muted) / 0.25);
}
.row-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.375rem;
font-size: 0.6875rem;
}
.badge {
display: inline-flex;
align-items: center;
padding: 0.125rem 0.4375rem;
border-radius: 999px;
background: hsl(var(--color-muted) / 0.45);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.badge.module {
background: hsl(var(--color-primary) / 0.12);
color: hsl(var(--color-primary));
}
.badge.private {
background: hsl(var(--color-error, 0 84% 60%) / 0.12);
color: hsl(var(--color-error, 0 84% 60%));
}
.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;
}
.row-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.875rem;
font-size: 0.8125rem;
}
.ctl {
display: inline-flex;
align-items: center;
gap: 0.375rem;
}
.ctl select {
padding: 0.25rem 0.5rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
background: hsl(var(--color-surface, var(--color-background)));
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
}
.ctl.checkbox {
gap: 0.25rem;
}
.response-block summary {
cursor: pointer;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.response-block textarea {
width: 100%;
margin-top: 0.375rem;
padding: 0.5rem 0.625rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-surface, var(--color-background)));
color: hsl(var(--color-foreground));
font: inherit;
resize: vertical;
}
.state {
padding: 2rem 1rem;
text-align: center;
color: hsl(var(--color-muted-foreground));
}
.state.error {
color: hsl(var(--color-error, 0 84% 60%));
}
</style>

View file

@ -0,0 +1,42 @@
<script lang="ts">
import RoadmapView from '$lib/modules/community/views/RoadmapView.svelte';
</script>
<svelte:head>
<title>Mana Roadmap — Was wir bauen</title>
<meta
name="description"
content="Welche Wünsche aus der Community wir geplant, in Arbeit oder bereits geliefert haben — sortiert nach Status."
/>
</svelte:head>
<header class="hero">
<h1>Roadmap</h1>
<p class="lead">
Was wir geplant, in Arbeit oder bereits geliefert haben. Sortiert nach Status, sichtbar für
alle.
</p>
</header>
<RoadmapView />
<style>
.hero {
padding: 1rem 0 0.5rem;
}
.hero h1 {
margin: 0 0 0.5rem 0;
font-size: 1.75rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.lead {
margin: 0;
font-size: 0.9375rem;
color: hsl(var(--color-muted-foreground));
max-width: 60ch;
line-height: 1.5;
}
</style>