mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
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:
parent
c9b122076a
commit
8804a20a7f
17 changed files with 1723 additions and 33 deletions
|
|
@ -22,6 +22,19 @@ export function getManaAuthUrl(): string {
|
||||||
return process.env.PUBLIC_MANA_AUTH_URL || 'http://localhost:3001';
|
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).
|
* Get the mana-events service URL (Phase 1b: public RSVP backend).
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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 { authStore } from '$lib/stores/auth.svelte';
|
||||||
import { getManaAuthUrl } from './config';
|
import { getManaAnalyticsUrl } from './config';
|
||||||
|
|
||||||
export const feedbackService = createFeedbackService({
|
export const feedbackService = createFeedbackService({
|
||||||
apiUrl: getManaAuthUrl(),
|
apiUrl: getManaAnalyticsUrl(),
|
||||||
appId: 'mana',
|
appId: 'mana',
|
||||||
getAuthToken: async () => authStore.getValidToken(),
|
getAuthToken: async () => authStore.getValidToken(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const publicFeedbackService = createPublicFeedbackService({
|
||||||
|
apiUrl: getManaAnalyticsUrl(),
|
||||||
|
appId: 'mana',
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,7 @@ import {
|
||||||
NotePencil,
|
NotePencil,
|
||||||
FilmStrip,
|
FilmStrip,
|
||||||
Hourglass,
|
Hourglass,
|
||||||
|
Megaphone,
|
||||||
} from '@mana/shared-icons';
|
} from '@mana/shared-icons';
|
||||||
|
|
||||||
// ── Apps with entity capabilities ───────────────────────────
|
// ── 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({
|
registerApp({
|
||||||
id: 'wardrobe',
|
id: 'wardrobe',
|
||||||
name: 'Kleiderschrank',
|
name: 'Kleiderschrank',
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -34,6 +34,7 @@
|
||||||
} from '@mana/shared-icons';
|
} from '@mana/shared-icons';
|
||||||
import type { Snippet, Component } from 'svelte';
|
import type { Snippet, Component } from 'svelte';
|
||||||
import { PAGE_WIDTH_PRESETS, nearestPresetIndex } from '../page-carousel/width-presets';
|
import { PAGE_WIDTH_PRESETS, nearestPresetIndex } from '../page-carousel/width-presets';
|
||||||
|
import FeedbackHook from '$lib/components/feedback/FeedbackHook.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
// Layout mode
|
// Layout mode
|
||||||
|
|
@ -66,6 +67,15 @@
|
||||||
helpOpen?: boolean;
|
helpOpen?: boolean;
|
||||||
onContextMenu?: (e: MouseEvent) => void;
|
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
|
// Snippets
|
||||||
header_left?: Snippet;
|
header_left?: Snippet;
|
||||||
badge?: Snippet;
|
badge?: Snippet;
|
||||||
|
|
@ -94,6 +104,8 @@
|
||||||
onHelp,
|
onHelp,
|
||||||
helpOpen = false,
|
helpOpen = false,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
|
moduleId,
|
||||||
|
hideFeedback = false,
|
||||||
header_left,
|
header_left,
|
||||||
badge,
|
badge,
|
||||||
actions,
|
actions,
|
||||||
|
|
@ -192,6 +204,9 @@
|
||||||
{#if actions}
|
{#if actions}
|
||||||
{@render actions()}
|
{@render actions()}
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if !hideFeedback}
|
||||||
|
<FeedbackHook {moduleId} />
|
||||||
|
{/if}
|
||||||
{#if onHelp}
|
{#if onHelp}
|
||||||
<button
|
<button
|
||||||
class="window-btn"
|
class="window-btn"
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,19 @@
|
||||||
<!--
|
<!--
|
||||||
Onboarding — Screen 4: Wish.
|
Onboarding — Screen 4: Wish.
|
||||||
Free-text "what do you want from Mana?" capture. Posts to the central
|
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
|
Submit is fail-soft: a network/server failure logs a warning and
|
||||||
still completes the flow — onboarding must never block on backend
|
still completes the flow — onboarding must never block on backend
|
||||||
latency.
|
latency.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
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 { onboardingFlow } from '$lib/stores/onboarding-flow.svelte';
|
||||||
import { onboardingStatus } from '$lib/stores/onboarding-status.svelte';
|
import { onboardingStatus } from '$lib/stores/onboarding-status.svelte';
|
||||||
import { feedbackService } from '$lib/api/feedback';
|
import { feedbackService } from '$lib/api/feedback';
|
||||||
|
|
@ -16,7 +21,9 @@
|
||||||
const MAX_LEN = 2000;
|
const MAX_LEN = 2000;
|
||||||
|
|
||||||
let wish = $state(onboardingFlow.pendingWish ?? '');
|
let wish = $state(onboardingFlow.pendingWish ?? '');
|
||||||
|
let isPublic = $state(true);
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
|
let submittedDisplayName = $state<string | null>(null);
|
||||||
|
|
||||||
let trimmed = $derived(wish.trim());
|
let trimmed = $derived(wish.trim());
|
||||||
let charsLeft = $derived(MAX_LEN - wish.length);
|
let charsLeft = $derived(MAX_LEN - wish.length);
|
||||||
|
|
@ -29,11 +36,15 @@
|
||||||
if (trimmed.length > 0) {
|
if (trimmed.length > 0) {
|
||||||
onboardingFlow.setPendingWish(trimmed);
|
onboardingFlow.setPendingWish(trimmed);
|
||||||
try {
|
try {
|
||||||
await feedbackService.createFeedback({
|
const res = await feedbackService.createFeedback({
|
||||||
feedbackText: trimmed,
|
feedbackText: trimmed,
|
||||||
category: 'onboarding-wish',
|
category: 'onboarding-wish',
|
||||||
isPublic: false,
|
isPublic,
|
||||||
});
|
});
|
||||||
|
submittedDisplayName =
|
||||||
|
(res as { displayName?: string }).displayName ??
|
||||||
|
(res as { feedback?: { displayName?: string } }).feedback?.displayName ??
|
||||||
|
null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[onboarding/wish] feedback submit failed:', err);
|
console.warn('[onboarding/wish] feedback submit failed:', err);
|
||||||
}
|
}
|
||||||
|
|
@ -81,6 +92,33 @@
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="actions">
|
||||||
<button type="button" class="btn-ghost" onclick={handleBack} disabled={saving}>
|
<button type="button" class="btn-ghost" onclick={handleBack} disabled={saving}>
|
||||||
<ArrowLeft size={16} weight="bold" />
|
<ArrowLeft size={16} weight="bold" />
|
||||||
|
|
@ -97,6 +135,12 @@
|
||||||
<Check size={16} weight="bold" />
|
<Check size={16} weight="bold" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if submittedDisplayName}
|
||||||
|
<aside class="preview" aria-live="polite">
|
||||||
|
Gesendet — sichtbar als <strong>{submittedDisplayName}</strong>
|
||||||
|
</aside>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
@ -166,6 +210,53 @@
|
||||||
color: hsl(var(--color-error, 0 84% 60%));
|
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 {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|
@ -226,4 +317,13 @@
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: not-allowed;
|
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>
|
</style>
|
||||||
|
|
|
||||||
136
apps/mana/apps/web/src/routes/community/+layout.svelte
Normal file
136
apps/mana/apps/web/src/routes/community/+layout.svelte
Normal 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>
|
||||||
31
apps/mana/apps/web/src/routes/community/+page.server.ts
Normal file
31
apps/mana/apps/web/src/routes/community/+page.server.ts
Normal 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 };
|
||||||
|
};
|
||||||
100
apps/mana/apps/web/src/routes/community/+page.svelte
Normal file
100
apps/mana/apps/web/src/routes/community/+page.svelte
Normal 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 & 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 & 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>
|
||||||
21
apps/mana/apps/web/src/routes/community/[id]/+page.server.ts
Normal file
21
apps/mana/apps/web/src/routes/community/[id]/+page.server.ts
Normal 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;
|
||||||
|
};
|
||||||
57
apps/mana/apps/web/src/routes/community/[id]/+page.svelte
Normal file
57
apps/mana/apps/web/src/routes/community/[id]/+page.svelte
Normal 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>
|
||||||
362
apps/mana/apps/web/src/routes/community/admin/+page.svelte
Normal file
362
apps/mana/apps/web/src/routes/community/admin/+page.svelte
Normal 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 & 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>
|
||||||
42
apps/mana/apps/web/src/routes/community/roadmap/+page.svelte
Normal file
42
apps/mana/apps/web/src/routes/community/roadmap/+page.svelte
Normal 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>
|
||||||
244
docs/plans/feedback-hub-public.md
Normal file
244
docs/plans/feedback-hub-public.md
Normal file
|
|
@ -0,0 +1,244 @@
|
||||||
|
---
|
||||||
|
status: draft
|
||||||
|
owner: till
|
||||||
|
created: 2026-04-26
|
||||||
|
parent: docs/plans/feedback-hub.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# Feedback-Hub — Public, Anonymous, Omnipresent
|
||||||
|
|
||||||
|
> Erweitert `docs/plans/feedback-hub.md` um die "alles öffentlich + anonym +
|
||||||
|
> überall einsammelbar"-Vision. Statt eines stillen Bug-Tracker-Moduls
|
||||||
|
> wird `@mana/feedback` zu einem sichtbaren Community-Layer, der überall
|
||||||
|
> in Mana präsent ist und nach außen hin als öffentliches Roadmap-Board
|
||||||
|
> wirkt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Leitprinzipien
|
||||||
|
|
||||||
|
1. **Privat by default ist tot.** Alles Feedback ist öffentlich anzeigbar, sortiert nach Votes, sichtbar ohne Login. Nur explizit als `private` markierte Sub-Categories (`churn-feedback`, `support-request`) bleiben zurückgehalten.
|
||||||
|
2. **Anonym, aber nicht entkoppelt.** Niemand sieht "Till Schäfer hat gewünscht…". Stattdessen ein deterministisches Pseudonym ("Wachsame Eule #4528"), das **konsistent über die Zeit** ist (selber User = selbe Eule), aber nicht zur Identität zurückführbar.
|
||||||
|
3. **Omnipresent.** Jedes Modul, jede Workbench-Page hat einen Feedback-Touchpoint. Nicht aufdringlich, aber 1-Klick erreichbar.
|
||||||
|
4. **Public Surface = Marketing-Asset.** Die `/community`-Page ist auch ohne Login lesbar, googelbar, embeddable. Sie zeigt das Produkt als "lebendiges System mit echten Stimmen".
|
||||||
|
5. **Read-cheap, write-thoughtful.** Lesen ist gratis (anonym, ohne Auth). Submit + Vote brauchen Login — gegen Spam und für Pseudonym-Konsistenz.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architektur-Entscheidungs-Punkte
|
||||||
|
|
||||||
|
Jede Entscheidung mit 2–3 Optionen, Empfehlung markiert.
|
||||||
|
|
||||||
|
### A. Anonymisierungs-Modell
|
||||||
|
|
||||||
|
| Option | Wie | Pro | Con |
|
||||||
|
|--------|-----|-----|-----|
|
||||||
|
| **A1. Wirklich anonym** | Server speichert userId nicht, nach Submit verloren | Maximum Trust, kein Doxing möglich | User kann eigenes Feedback nicht editieren/löschen, kein "Mein Feedback"-Tab |
|
||||||
|
| **A2. Pseudonym-Hash** | Server speichert `displayHash = SHA256(userId + serverSecret)`. UserId unsichtbar, aber Hash recomputable bei Login. | User sieht eigene Posts wieder, kann Edit/Delete. Cross-Post-Identifikation nicht möglich. | Mehr Komplexität. Server-Secret-Rotation = alle Hashes verlieren Verbindung. |
|
||||||
|
| **A3. Pseudonym + Display-Name** ⭐ | Wie A2, **plus** ein deterministisches Tier-Pseudonym ("Wachsame Eule #4528") aus Hash abgeleitet. Anzeige in UI. | Lesefreundlich ("ah die Eule wieder"), Nutzer-Wiedererkennung ohne Identitätspreisgabe. Konsistenz schafft Reputation-Layer ohne Real-Name. | Pseudonym ist deterministisch persistent — wer ein Posting eindeutig zuordnen kann (z.B. weil Sub-Bio enthält), könnte alle Posts dieser Eule traversieren. |
|
||||||
|
|
||||||
|
**Empfehlung: A3.** Reputation + Wiedererkennung ohne Klar-Identität ist das Sweet-Spot, das Communities lebendig macht (Reddit-Pattern).
|
||||||
|
|
||||||
|
**Open question**: Soll User Pseudonym selbst ändern können? Empfehlung **nein** — sonst kann man Sock-Puppet-mäßig agieren. Pseudonym ist serverside fest, einmal generiert.
|
||||||
|
|
||||||
|
### B. Storage-Erweiterungen am `user_feedback`-Table
|
||||||
|
|
||||||
|
Neue Spalten:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE feedback.user_feedback
|
||||||
|
ADD COLUMN display_hash text, -- SHA256(userId + secret)
|
||||||
|
ADD COLUMN display_name text, -- "Wachsame Eule #4528"
|
||||||
|
ADD COLUMN module_context text, -- 'todo' | 'notes' | … | NULL
|
||||||
|
ADD COLUMN parent_id uuid REFERENCES feedback.user_feedback(id), -- für Threading
|
||||||
|
ADD COLUMN published_to_public boolean DEFAULT true;
|
||||||
|
```
|
||||||
|
|
||||||
|
`userId` bleibt für Server-internen Lookup (Edit/Delete-Berechtigung); wird **nie** im Public-Endpoint ausgeliefert.
|
||||||
|
|
||||||
|
Tier-Namen-Generation: `services/mana-analytics/src/lib/pseudonym.ts` (Wortliste ~150 Adjektive × 80 Tieren = 12.000 Kombinationen + 4-stellige Suffix → ~120M unique). Deterministisch aus `display_hash`.
|
||||||
|
|
||||||
|
### C. Voting-Modell
|
||||||
|
|
||||||
|
| Option | Wie | Pro | Con |
|
||||||
|
|--------|-----|-----|-----|
|
||||||
|
| **C1. Anonym votable** | Vote per IP-Hash, rate-limited (1×/Item/Tag) | Maximum Reach — nicht-User können auch interagieren | Manipulationsanfällig (VPN, mehrere Geräte), keine Reputation |
|
||||||
|
| **C2. Auth-required Voting** ⭐ | Lesen ohne Login, Voten nur eingeloggt | Schutz gegen Brigading, sauberer Signalwert. Pattern: GitHub Discussions, Stack Overflow. | Nicht-User können nicht teilnehmen → Kalt-Start-Problem |
|
||||||
|
| **C3. Reactions statt Votes** | Slack-style Emojis (✋ "ich auch", ❤️ "love", 🤔 "?", 🚀 "ship it") | Reicheres Signal, weniger Hot-or-not. | Komplizierter zu sortieren; "Top-Voted" nicht mehr eindeutig |
|
||||||
|
|
||||||
|
**Empfehlung: C2 + C3 in Kombination** — auth-required, aber statt simpler `voteCount` ein `reactions: jsonb` mit Emoji→Count-Map. Sortier-Score = gewichtete Summe (👍 = 1, 🚀 = 2, 🤔 = 0).
|
||||||
|
|
||||||
|
### D. Inline-Feedback-Pattern (Module-Touchpoints)
|
||||||
|
|
||||||
|
| Option | Wie | Pro | Con |
|
||||||
|
|--------|-----|-----|-----|
|
||||||
|
| **D1. Globale Floating-Pille** | Ein "Idee?"-FAB rechts unten, immer da. Modal mit Auto-Context aus aktueller Route. | Modul-agnostisch, eine Stelle, einfach gepflegt. Kontext-Auto-Detection eliminiert Reibung. | Floating-Buttons werden tot-blickt ("Banner-Blindness"). Keine Modul-spezifische Triage. |
|
||||||
|
| **D2. ModuleShell-Footer-Slot** | Erweitere `ModuleShell` um optionalen `feedback_pill`-Snippet im Footer/Header. Module aktivieren explizit. | Modul-spezifischer Kontext, opt-in pro Modul. Konsistente Position. | Module müssen Code touchen. Bei 27 Modulen viel Boilerplate. |
|
||||||
|
| **D3. Auto-Inject in jede ModuleShell** ⭐ | ModuleShell rendert default einen kleinen `<FeedbackHook moduleId={appId} />` im Header rechts neben den Window-Actions. `appId` aus Context. Modul kann via prop opt-out (`hideFeedback={true}`). | Wirklich überall ohne Modul-Code. 100% Coverage. Konsistenter Touchpoint. | Header-Krempel — bei 7+ Action-Buttons schon eng. Mobile-Layout muss überlegt werden. |
|
||||||
|
| **D4. Slash-Command in QuickInput** | User tippt `/feedback ich finde…` in der globalen QuickInput-Bar | Power-User-Friendly, kein UI-Eingriff. | Hidden — normale User finden's nie ohne Onboarding. |
|
||||||
|
|
||||||
|
**Empfehlung: D3 als Baseline + D1 als Backup für Routes außerhalb Module-Shells** (z.B. Settings, Profile). D3 erreicht jeden Workbench-Touch automatisch; D1 fängt den Rest auf. D4 als nice-to-have on top für Power-User.
|
||||||
|
|
||||||
|
`<FeedbackHook moduleId>` rendert: kleines Icon-Button (Lightbulb / Megaphone), Click öffnet Modal mit:
|
||||||
|
- Vorausgefüllter Context: "Modul: Todo" Badge
|
||||||
|
- Category-Auto-Default: `feature` (oder via dropdown ändern)
|
||||||
|
- Free-text 2000 chars
|
||||||
|
- Submit → POST + Toast "Danke! Sichtbar als 'Wachsame Eule #4528'"
|
||||||
|
|
||||||
|
### E. Public-Display-Surface
|
||||||
|
|
||||||
|
| Option | Wie | Pro | Con |
|
||||||
|
|--------|-----|-----|-----|
|
||||||
|
| **E1. Eigenes Modul `/community`** ⭐ | Neues Modul `community/`. List/Detail-Views. Public-Route auch unter `/community` (kein AuthGate). | Konzeptuell sauber, eigene Workbench-Card, klare Trennung "mein Feedback" vs "alle". Eigene URL = Marketing-asset. | Mehr Code (Module-Pattern voll auszubauen). |
|
||||||
|
| **E2. Erweiterte `/feedback`-Page** | Bestehende Page um "Public"-Tab und Public-Mirror auf `/feedback` (auth-bypass) | Weniger Module-Mehrarbeit | Mischung "intern + extern" auf einer URL ist verwirrend. Ein `requiredTier=guest`-Modul lässt sich schlecht mit Auth-Bypass kombinieren. |
|
||||||
|
| **E3. Eigene Domain `feedback.mana.how`** | Standalone-Surface, eigenes Astro-Build | Maximum Brand-Trennung, Marketing-Standalone. | Sehr aufwendig, Aufwand:Nutzen-Ratio schlecht für jetzt. |
|
||||||
|
|
||||||
|
**Empfehlung: E1.** Neues Modul `community`, Route `(app)/community` für eingeloggte User (Workbench-Card-fähig), **plus** Mirror unter `/community` (außerhalb (app)/, ohne AuthGate) für Public-Access. Beide Routes rendern dieselben Daten, nur Voting/Submit ist auf der Auth-Variante aktiv.
|
||||||
|
|
||||||
|
### F. Workbench-Integration
|
||||||
|
|
||||||
|
`community`-Modul muss workbench-card-fähig sein. Ergibt:
|
||||||
|
|
||||||
|
- `lib/modules/community/module.config.ts` — `appId: 'community'`, **keine** Tabellen (server-only, kein Local-First)
|
||||||
|
- `lib/modules/community/queries.ts` — Fetch via `feedbackService.getPublicFeed()` (neuer Endpoint), in-Memory mit SWR-Pattern (kein liveQuery)
|
||||||
|
- `lib/modules/community/views/ListView.svelte` — Top-Voted-Liste, Filter nach Modul, Status, Kategorie
|
||||||
|
- `lib/modules/community/views/DetailView.svelte` — Single-Item mit Replies (Threading)
|
||||||
|
- `lib/modules/community/views/RoadmapView.svelte` — Items mit `status='planned'` oder `'in_progress'`, Kanban-Style
|
||||||
|
- `app-registry/apps.ts` — Eintrag mit Icon (Megaphone? Lightning?), Color (z.B. `#F59E0B`)
|
||||||
|
- `mana-apps.ts` — globale Registrierung mit `requiredTier: 'guest'` (Public-Modul!)
|
||||||
|
- Drag-Source für Workbench: dropp-able auf jede Scene
|
||||||
|
|
||||||
|
### G. Anonymisierungs-Schutz beim Submit
|
||||||
|
|
||||||
|
Wichtig: Wenn Onboarding-Wishes ab jetzt PUBLIC sind, muss der UI klar machen "**das ist öffentlich, anonym aber sichtbar**". Sonst Vertrauensbruch.
|
||||||
|
|
||||||
|
Onboarding-Wish-Screen Update:
|
||||||
|
> *Was wünschst du dir? Schreib einfach, wie's dir kommt. Wir zeigen das öffentlich auf unserer Community-Page als "{tier-name}", aber nicht mit deinem Namen.*
|
||||||
|
|
||||||
|
Plus: Preview-Step nach Submit: "Hier wirst du auftauchen → [Eule-Preview]". User kann zurück und edit/delete vor Submit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementierungs-Reihenfolge
|
||||||
|
|
||||||
|
### Phase 2.1 — Anonymisierungs-Foundation
|
||||||
|
- Neue Spalten `display_hash`, `display_name`, `module_context`, `parent_id`, `published_to_public` in `feedback.user_feedback`
|
||||||
|
- Pseudonym-Generator (Tier+Adjektiv+Number aus Hash)
|
||||||
|
- Server `createFeedback`: stamp `display_hash` + `display_name` automatisch
|
||||||
|
- `getPublicFeed`-Endpoint (neu, **kein Auth**, nur Public + isPublic-Filter, redacts userId)
|
||||||
|
- `feedbackService` um `getPublicFeed()` erweitern (kann ohne `getAuthToken`)
|
||||||
|
|
||||||
|
### Phase 2.2 — Voting + Reactions umbauen
|
||||||
|
- Spalte `reactions jsonb` ergänzt (Map emoji→count)
|
||||||
|
- Server-Endpoint `POST /api/v1/feedback/:id/react` (auth-required) toggelt Reaction für `userId`
|
||||||
|
- VoteButton erweitert zu ReactionBar (emoji-row mit Counts)
|
||||||
|
- Sortier-Score-Logik im Backend
|
||||||
|
|
||||||
|
### Phase 2.3 — Inline-Hook in ModuleShell
|
||||||
|
- `<FeedbackHook moduleId>` Component bauen
|
||||||
|
- ModuleShell um `<FeedbackHook>` im Header-Right erweitern, opt-out via `hideFeedback`
|
||||||
|
- Modal-Component (FeedbackQuickModal) mit Auto-Context, Category-Picker, Free-Text
|
||||||
|
- Toast-Bestätigung "Sichtbar als …"
|
||||||
|
|
||||||
|
### Phase 2.4 — Globale Floating-Pille
|
||||||
|
- Component `<GlobalFeedbackPill />` in `routes/(app)/+layout.svelte` mounten
|
||||||
|
- Auto-Detection des Module-Context aus URL/Active-Scene
|
||||||
|
|
||||||
|
### Phase 2.5 — Community-Modul
|
||||||
|
- Modul-Skeleton (`module.config.ts`, `queries.ts`, `views/`)
|
||||||
|
- ListView mit Top-Voted, Filter, Suchfeld
|
||||||
|
- DetailView mit Threading (Replies)
|
||||||
|
- RoadmapView (planned/in-progress als Kanban)
|
||||||
|
- App-Registry-Eintrag, mana-apps.ts-Registrierung mit `requiredTier: 'guest'`
|
||||||
|
|
||||||
|
### Phase 2.6 — Public-Mirror-Route
|
||||||
|
- `routes/community/+page.svelte` (außerhalb (app)/, kein AuthGate)
|
||||||
|
- SSR-Pre-Render via SvelteKit `+page.server.ts` für SEO
|
||||||
|
- Read-Only-Modus: Voting-Buttons disabled mit Tooltip "Login zum Mitmachen"
|
||||||
|
- robots.txt + sitemap.xml updaten
|
||||||
|
|
||||||
|
### Phase 2.7 — Onboarding-Wish öffentlich machen
|
||||||
|
- `onboarding/wish/+page.svelte`: Text-Update mit Public-Disclosure
|
||||||
|
- Preview-Step ("Hier wirst du auftauchen") vor Submit
|
||||||
|
- `isPublic: true` (statt aktuell `false`)
|
||||||
|
- Wishes erscheinen ab sofort im `/community`-Feed
|
||||||
|
|
||||||
|
### Phase 2.8 — Admin-Triage
|
||||||
|
- `/community/admin` (founder-tier-gated) für Status-Updates, Adminresponse, Reaktion auf Threads
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — Mächtiger machen *(Roadmap, separater Sprint)*
|
||||||
|
|
||||||
|
Diese Features kommen nach 2.x. Jeder Punkt ist 1–3 Tage.
|
||||||
|
|
||||||
|
### 3A. Threading / Replies
|
||||||
|
Feedback-Records können `parent_id` haben → User antworten auf Wishes. UI: Discord-style Reply-Indent. Reaktionen pro Reply.
|
||||||
|
|
||||||
|
### 3B. Status-Notifications
|
||||||
|
User reaktet auf Item → bekommt Notify wenn Status sich ändert. "Dein Like-Item ist jetzt 'planned'." Rendering: in Mana's `/inbox` oder als Email-Digest.
|
||||||
|
|
||||||
|
### 3C. Auto-Tagging via LLM
|
||||||
|
Beim Submit: mana-llm extrahiert 2–4 Tags ("ui", "performance", "ai", "mobile"). Speicherung in `tags text[]`. Filter im UI nach Tag.
|
||||||
|
|
||||||
|
### 3D. Roadmap-Page
|
||||||
|
View: Kanban-Spalten "Submitted | Planned | In Progress | Shipped". Items mit Vote-Count + Module-Badge. Public-View ohne Login.
|
||||||
|
|
||||||
|
### 3E. Companion-Awareness
|
||||||
|
AI-Companion liest Feedback-Records des Users (über mana-mcp-Tool) und referenziert: "Du hattest dir vor 3 Wochen X gewünscht — wir haben das jetzt gebaut, schau hier." Pro-Active-Notification beim Login.
|
||||||
|
|
||||||
|
### 3F. Cross-Modul-Verknüpfung
|
||||||
|
Wenn User schreibt "ich will dass meine Notiz X…", kann Feedback-Item auf konkrete Records linken (`relatedRecordIds: text[]`). UI zeigt Modul-Badge + Link.
|
||||||
|
|
||||||
|
### 3G. Sentiment-Cluster
|
||||||
|
Monatlicher LLM-Job: clustert alle Submissions nach Sentiment (positiv/negativ/neutral) und Topic. Admin-Dashboard zeigt Trend-Lines. Founder kriegt einen "Mood of the Community"-Pulse.
|
||||||
|
|
||||||
|
### 3H. Embeddable Public-Roadmap
|
||||||
|
`<iframe src="https://mana.how/community/embed?status=planned" />` für Landing-Page. Wir können auf der Marketing-Site die "lebendige Roadmap" einblenden.
|
||||||
|
|
||||||
|
### 3I. Newsletter-Aggregation
|
||||||
|
Monatlich auto-Newsletter an alle Voter: "Diese Wünsche wurden im April umgesetzt:". Gewinnt das Doom-Loop von "wo ist mein Feature?".
|
||||||
|
|
||||||
|
### 3J. Reputation-System
|
||||||
|
`Wachsame Eule #4528` sammelt Karma (eigene Reactions + Replies anderer). Im Profil sichtbar. Gamification ohne Identitätspreisgabe.
|
||||||
|
|
||||||
|
### 3K. Voting auf Inline-Hook-Submission
|
||||||
|
Beim Submit über Inline-Hook: zeige sofort 3 ähnliche existierende Wishes ("Du wolltest schreiben — wurde so was schon gewünscht?"). Reduce Duplicates, encourage Voting.
|
||||||
|
|
||||||
|
### 3L. Cross-Server Feedback-Aggregation
|
||||||
|
Wenn Mana mal mehrere Workspaces hat (Spaces ÷ Server): Feedback ist global, aber Filter "nur mein Space" verfügbar.
|
||||||
|
|
||||||
|
### 3M. Privater Sub-Channel pro Space
|
||||||
|
Spaces können einen eigenen `space_feedback`-Stream haben — Member-only. Trennung Community-Public ↔ Team-Private.
|
||||||
|
|
||||||
|
### 3N. AI-generated Reply Suggestions für Founder
|
||||||
|
Wenn jemand fragt "warum X?", gibt's einen LLM-Suggestion-Button für die Antwort, gefüttert mit allen vorigen Posts + Code-Status. Founder-Speed-Boost für Triage.
|
||||||
|
|
||||||
|
### 3O. Voting-Decay
|
||||||
|
Votes haben Halbwertszeit (z.B. 90 Tage), damit alte Wishes nicht ewig die Top dominieren. Frische gewinnt.
|
||||||
|
|
||||||
|
### 3P. "Was würdest du als nächstes wollen?"-Quiz
|
||||||
|
Periodischer Pop-Up: "Schau dir unsere Roadmap an, vote was du als nächstes willst." Aktivierung des Long-Tails.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bekannte Risiken / Gegen-Argumente
|
||||||
|
|
||||||
|
- **Spam-Risk**: Anonyme Posts bei steigendem Traffic. Mitigation: Auth-required für Submit, IP-Rate-Limit ~10/Tag, LLM-Spam-Detection.
|
||||||
|
- **Toxic-Content-Risk**: Anon-Plattformen ziehen Trolle. Mitigation: Pre-Submit-Profanity-Filter (LLM), Founder-Mod-Tools, "Report this Post"-Button.
|
||||||
|
- **Doxing via Pseudonym-Konsistenz**: Eule schreibt persönliche Details → über mehrere Posts identifizierbar. Mitigation: Onboarding-Disclosure ("schreib nichts persönlich Identifizierendes").
|
||||||
|
- **Privacy-Reset-Wunsch**: User will eigenes altes Feedback komplett löschen. Mitigation: "Account-Reset" → alle Records mit seinem `display_hash` werden auf "anonym gelöscht" gesetzt (Soft-Delete, kein DELETE).
|
||||||
|
- **Onboarding-Wish öffentlich = scary**: User schreibt im Onboarding ehrlich, will aber nicht öffentlich auftauchen. Mitigation: Toggle "Auch öffentlich anzeigen?" mit Default-on aber sichtbar/abwählbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Empfohlener "Phase 2.0 Minimal" für ersten Launch
|
||||||
|
|
||||||
|
Wenn du nicht alles am Stück bauen willst, ist das die kleinste sinnvolle Version:
|
||||||
|
|
||||||
|
1. Phase 2.1 (Anonymisierung) — DB + Pseudonym-Generator
|
||||||
|
2. Phase 2.5 (`community`-Modul) ohne Threading, ohne Roadmap-View
|
||||||
|
3. Phase 2.6 (Public-Mirror-Route) read-only
|
||||||
|
4. Phase 2.7 (Onboarding-Wish öffentlich) mit Disclosure
|
||||||
|
|
||||||
|
Drei Tage Arbeit, schon ist die Community-Surface live. Phase 2.2/2.3/2.4 + Phase 3.x staffeln wir danach.
|
||||||
|
|
@ -158,35 +158,24 @@ Im Package:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 2 — Hub-Charakter ausbauen *(separater Sprint)*
|
## Phase 2 — Public Community-Hub *(großer Sprint, eigener Plan)*
|
||||||
|
|
||||||
Drei kleine Erweiterungen, die `@mana/feedback` zur "echten" Zentrale
|
Phase 2 wurde komplett neu geschnitten: nicht mehr nur Admin-Triage und
|
||||||
machen. Können einzeln geshippt werden.
|
Buttons, sondern eine **vollständige Public-Community-Surface** mit
|
||||||
|
Pseudonym-System, Anonymisierung, omnipresenten Inline-Hooks und einem
|
||||||
|
eigenen `community`-Modul.
|
||||||
|
|
||||||
### 2a. Globaler Feedback-Button
|
→ Detailplan mit Architektur-Optionen für jede Sub-Entscheidung:
|
||||||
|
**[`docs/plans/feedback-hub-public.md`](feedback-hub-public.md)**.
|
||||||
|
|
||||||
Eintrag im Account-Menü oder PillNav ("Feedback / Idee teilen") öffnet
|
Kurzform der Architektur-Empfehlungen (Detail siehe Sub-Plan):
|
||||||
einen Modal mit der bestehenden `FeedbackForm`. Eliminiert das Risiko,
|
- **Anonymisierung**: Pseudonym-Hash + Tier-Display-Name ("Wachsame Eule #4528")
|
||||||
dass jemand pro Modul eigene Feedback-Buttons baut.
|
- **Voting**: Auth-required, aber Reactions statt simpler Votes (👍 ❤️ 🚀 🤔)
|
||||||
|
- **Inline-Hook**: Auto-Inject in `ModuleShell`-Header (opt-out per Modul) + Floating-Pille als Backup
|
||||||
|
- **Public-Surface**: Eigenes `community`-Modul + Mirror-Route außerhalb (app)/
|
||||||
|
- **Onboarding-Wish ab jetzt PUBLIC** mit Disclosure-Step
|
||||||
|
|
||||||
### 2b. Inline-Hook pro Modul
|
Alter Phase-2-Inhalt (Admin-Hub etc.) ist in den Sub-Plan migriert.
|
||||||
|
|
||||||
Komponente `<ModuleFeedbackHook module="todo" />` für Module-Help-Panels,
|
|
||||||
vorausgefüllt mit `appId`, Default-Category `'feature' | 'improvement'`.
|
|
||||||
Kontextspezifische Wünsche.
|
|
||||||
|
|
||||||
### 2c. Admin-Triage-Hub `/feedback/admin`
|
|
||||||
|
|
||||||
Founder-Tier-gated. Features:
|
|
||||||
- Filter: Kategorie, Status, Datum, App
|
|
||||||
- Bulk-Status-Updates
|
|
||||||
- `adminResponse` schreiben
|
|
||||||
- Aggregations-Card für `onboarding-wish`: alle Antworten gelistet,
|
|
||||||
optional via LLM nach Themen geclustert (was wollen neue Nutzer
|
|
||||||
am häufigsten?)
|
|
||||||
- Neue Backend-Endpoints:
|
|
||||||
- `PATCH /api/v1/feedback/admin/:id` (status, adminResponse, isPublic)
|
|
||||||
- `GET /api/v1/feedback/admin?category=...&status=...` (alle inkl. private)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue