mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 16:41:08 +02:00
feat(feedback): heart-half als globales Feedback-Icon + inline-Form in der Workbench
Drei Probleme adressiert: 1. **Icon-Vereinheitlichung**: alle Feedback-Affordances tragen jetzt das phosphor `heart-half`-Icon (statt vorher Lightbulb/Mix). Geändert in PillNav-Usermenü, ModuleShell-Header (FeedbackHook), Phosphor-Icon- Map. Eine Stelle, ein Icon — Wiedererkennung steigt. 2. **Inline statt Modal in Workbench-Cards**: AppPage.svelte rendert das Feedback-Formular jetzt im selben Slot wie die Hilfe-Seite — Klick auf das Heart-Half-Icon togglet den Inline-Panel statt einen Modal-Backdrop über die ganze Workbench zu legen. Hilfe und Feedback sind mutually-exclusive (eines geht zu, sobald das andere aufgeht). 3. **Form-Body extrahiert**: FeedbackForm.svelte enthält jetzt das Formular ohne jegliches Chrome. FeedbackQuickModal nutzt es im Modal- Mode (Standalone-Routen, PillNav), AppPage im Inline-Mode. Eine Quelle, beide Surfaces bleiben in sync. ModuleShell schluckt zusätzlich `onFeedback`/`feedbackOpen`-Props: wenn gesetzt, ruft die FeedbackHook-Komponente onClick statt das eigene Modal zu öffnen — der Host (AppPage) übernimmt das Rendering. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f39e72340c
commit
15ab24bda8
8 changed files with 441 additions and 341 deletions
|
|
@ -0,0 +1,344 @@
|
|||
<!--
|
||||
FeedbackForm — Pure feedback-submission form (no chrome).
|
||||
|
||||
Used by FeedbackQuickModal (modal-mode, fallback for non-workbench
|
||||
contexts) and the inline panel inside AppPage (workbench cards). Keeps
|
||||
the two render modes in sync — change the form once, both surfaces
|
||||
pick it up.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { PaperPlaneTilt } from '@mana/shared-icons';
|
||||
import { FEEDBACK_CATEGORY_LABELS, type FeedbackCategory } from '@mana/feedback';
|
||||
import { feedbackService } from '$lib/api/feedback';
|
||||
|
||||
interface Props {
|
||||
moduleContext?: string;
|
||||
defaultCategory?: FeedbackCategory;
|
||||
onCancel?: () => void;
|
||||
onSubmitted?: (displayName: string | null) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
moduleContext,
|
||||
defaultCategory: defaultCategoryProp,
|
||||
onCancel,
|
||||
onSubmitted,
|
||||
}: Props = $props();
|
||||
|
||||
let defaultCategory = $derived<FeedbackCategory>(defaultCategoryProp ?? 'feature');
|
||||
|
||||
let text = $state('');
|
||||
// svelte-ignore state_referenced_locally
|
||||
let category = $state<FeedbackCategory>(defaultCategoryProp ?? 'feature');
|
||||
let isPublic = $state(true);
|
||||
let saving = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let submittedDisplayName = $state<string | null>(null);
|
||||
|
||||
const MAX_LEN = 2000;
|
||||
const SELECTABLE: FeedbackCategory[] = ['feature', 'improvement', 'bug', 'praise', 'question'];
|
||||
|
||||
export function reset() {
|
||||
text = '';
|
||||
category = defaultCategory;
|
||||
isPublic = true;
|
||||
error = null;
|
||||
submittedDisplayName = null;
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
const name =
|
||||
(res as { displayName?: string }).displayName ??
|
||||
(res as { feedback?: { displayName?: string } }).feedback?.displayName ??
|
||||
null;
|
||||
submittedDisplayName = name;
|
||||
onSubmitted?.(name);
|
||||
} catch (err) {
|
||||
console.error('[FeedbackForm] submit failed:', err);
|
||||
error = err instanceof Error ? err.message : 'Senden fehlgeschlagen.';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if submittedDisplayName}
|
||||
<div class="success">
|
||||
<p>
|
||||
Dein Feedback ist eingegangen — sichtbar als <strong>{submittedDisplayName}</strong>.
|
||||
</p>
|
||||
<div class="reward-chip" aria-live="polite">
|
||||
<span class="reward-amount">+5</span>
|
||||
<span class="reward-label">Mana Credits</span>
|
||||
</div>
|
||||
{#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}
|
||||
{#if onCancel}
|
||||
<button class="btn-primary" onclick={() => onCancel()}>Schließen</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="form">
|
||||
{#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>
|
||||
<textarea
|
||||
bind:value={text}
|
||||
placeholder="Beschreib's so genau du willst…"
|
||||
maxlength={MAX_LEN}
|
||||
rows="5"
|
||||
></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">
|
||||
{#if onCancel}
|
||||
<button class="btn-ghost" onclick={() => onCancel()} disabled={saving}>Abbrechen</button>
|
||||
{/if}
|
||||
<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}
|
||||
|
||||
<style>
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
.success {
|
||||
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;
|
||||
}
|
||||
|
||||
.reward-chip {
|
||||
align-self: flex-start;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.375rem;
|
||||
padding: 0.4375rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(var(--color-primary) / 0.18),
|
||||
hsl(var(--color-primary) / 0.08)
|
||||
);
|
||||
color: hsl(var(--color-primary));
|
||||
border: 1px solid hsl(var(--color-primary) / 0.3);
|
||||
font-weight: 600;
|
||||
animation: reward-in 0.45s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.reward-amount {
|
||||
font-size: 1rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.reward-label {
|
||||
font-size: 0.8125rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
@keyframes reward-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px) scale(0.92);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.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>
|
||||
|
|
@ -3,29 +3,46 @@
|
|||
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}`.
|
||||
Two render modes:
|
||||
- Standalone (`onClick` not provided): renders the heart-half button +
|
||||
opens its own FeedbackQuickModal. Used outside the workbench
|
||||
(e.g. /todo direct route, settings pages).
|
||||
- Hosted (`onClick` provided): just the icon button — the host owns
|
||||
the open-state and renders feedback inline (workbench AppPage).
|
||||
|
||||
ModuleShell auto-injects the standalone variant in its header. The
|
||||
workbench's AppPage suppresses that and wires its own onFeedback to
|
||||
flip into the inline feedback panel (like the Hilfe panel).
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Lightbulb } from '@mana/shared-icons';
|
||||
import { HeartHalf } from '@mana/shared-icons';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import FeedbackQuickModal from './FeedbackQuickModal.svelte';
|
||||
|
||||
interface Props {
|
||||
moduleId?: string;
|
||||
size?: number;
|
||||
/** When set, the button just calls this — host renders the form. */
|
||||
onClick?: () => void;
|
||||
/** When the host renders inline feedback, pass `true` to highlight
|
||||
* the trigger like the help button does. */
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
let { moduleId, size = 22 }: Props = $props();
|
||||
let { moduleId, size = 22, onClick, active = false }: 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.
|
||||
// so silently skip — global pill catches them elsewhere.
|
||||
if (!authStore.user) return;
|
||||
open = true;
|
||||
if (onClick) {
|
||||
onClick();
|
||||
} else {
|
||||
open = true;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -33,14 +50,17 @@
|
|||
<button
|
||||
type="button"
|
||||
class="feedback-hook-btn"
|
||||
class:active
|
||||
onclick={handleClick}
|
||||
title="Idee oder Feedback?"
|
||||
aria-label="Feedback geben"
|
||||
>
|
||||
<Lightbulb {size} weight="bold" />
|
||||
<HeartHalf {size} weight="bold" />
|
||||
</button>
|
||||
|
||||
<FeedbackQuickModal {open} moduleContext={moduleId} onClose={() => (open = false)} />
|
||||
{#if !onClick}
|
||||
<FeedbackQuickModal {open} moduleContext={moduleId} onClose={() => (open = false)} />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
|
|
@ -62,4 +82,9 @@
|
|||
background: hsl(var(--color-surface-hover, var(--color-muted)));
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.feedback-hook-btn.active {
|
||||
background: hsl(var(--color-surface-hover, var(--color-muted)));
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,76 +1,28 @@
|
|||
<!--
|
||||
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.
|
||||
FeedbackQuickModal — Modal wrapper around FeedbackForm. Used outside
|
||||
the workbench (e.g. PillNav user-menu, /todo direct route, settings
|
||||
pages). Workbench cards render the same form inline instead.
|
||||
-->
|
||||
<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';
|
||||
import { X } from '@mana/shared-icons';
|
||||
import type { FeedbackCategory } from '@mana/feedback';
|
||||
import FeedbackForm from './FeedbackForm.svelte';
|
||||
|
||||
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 props: Props = $props();
|
||||
let title = $derived(props.title ?? 'Idee oder Feedback?');
|
||||
let defaultCategory = $derived<FeedbackCategory>(props.defaultCategory ?? 'feature');
|
||||
|
||||
let text = $state('');
|
||||
// svelte-ignore state_referenced_locally
|
||||
let category = $state<FeedbackCategory>(props.defaultCategory ?? 'feature');
|
||||
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: props.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;
|
||||
}
|
||||
}
|
||||
let formRef = $state<{ reset: () => void } | null>(null);
|
||||
|
||||
function handleClose() {
|
||||
text = '';
|
||||
category = defaultCategory;
|
||||
isPublic = true;
|
||||
error = null;
|
||||
submittedDisplayName = null;
|
||||
formRef?.reset();
|
||||
props.onClose();
|
||||
}
|
||||
|
||||
|
|
@ -93,82 +45,20 @@
|
|||
>
|
||||
<div class="modal" role="document" onclick={(e) => e.stopPropagation()}>
|
||||
<header class="modal-header">
|
||||
<h2>{submittedDisplayName ? 'Danke!' : title}</h2>
|
||||
<h2>{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>
|
||||
<div class="reward-chip" aria-live="polite">
|
||||
<span class="reward-amount">+5</span>
|
||||
<span class="reward-label">Mana Credits</span>
|
||||
</div>
|
||||
{#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 props.moduleContext}
|
||||
<div class="context-badge">Modul: {props.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 class="body">
|
||||
<FeedbackForm
|
||||
bind:this={formRef}
|
||||
moduleContext={props.moduleContext}
|
||||
defaultCategory={props.defaultCategory}
|
||||
onCancel={handleClose}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -237,200 +127,6 @@
|
|||
|
||||
.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;
|
||||
}
|
||||
|
||||
.reward-chip {
|
||||
align-self: flex-start;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.375rem;
|
||||
padding: 0.4375rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(var(--color-primary) / 0.18),
|
||||
hsl(var(--color-primary) / 0.08)
|
||||
);
|
||||
color: hsl(var(--color-primary));
|
||||
border: 1px solid hsl(var(--color-primary) / 0.3);
|
||||
font-weight: 600;
|
||||
animation: reward-in 0.45s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.reward-amount {
|
||||
font-size: 1rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.reward-label {
|
||||
font-size: 0.8125rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
@keyframes reward-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px) scale(0.92);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.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>
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@
|
|||
helpOpen?: boolean;
|
||||
onContextMenu?: (e: MouseEvent) => void;
|
||||
|
||||
// Inline feedback hook — renders a small Lightbulb button in the
|
||||
// Inline feedback hook — renders a small heart-half 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. */
|
||||
|
|
@ -75,6 +75,12 @@
|
|||
/** Suppress the auto-injected FeedbackHook (e.g. on the
|
||||
* /community-/feedback-pages where it's redundant). */
|
||||
hideFeedback?: boolean;
|
||||
/** When provided, the heart-half button calls this instead of
|
||||
* opening its own modal. The host renders feedback inline (used
|
||||
* by workbench AppPage to mirror the Hilfe-panel pattern). */
|
||||
onFeedback?: () => void;
|
||||
/** Highlights the heart-half trigger when the inline panel is open. */
|
||||
feedbackOpen?: boolean;
|
||||
|
||||
// Snippets
|
||||
header_left?: Snippet;
|
||||
|
|
@ -106,6 +112,8 @@
|
|||
onContextMenu,
|
||||
moduleId,
|
||||
hideFeedback = false,
|
||||
onFeedback,
|
||||
feedbackOpen = false,
|
||||
header_left,
|
||||
badge,
|
||||
actions,
|
||||
|
|
@ -205,7 +213,7 @@
|
|||
{@render actions()}
|
||||
{/if}
|
||||
{#if !hideFeedback}
|
||||
<FeedbackHook {moduleId} />
|
||||
<FeedbackHook {moduleId} onClick={onFeedback} active={feedbackOpen} />
|
||||
{/if}
|
||||
{#if onHelp}
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import { X, CaretUp, CaretDown, ArrowLeft, SpinnerGap } from '@mana/shared-icons';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { ModuleShell } from '$lib/components/shell';
|
||||
import FeedbackForm from '$lib/components/feedback/FeedbackForm.svelte';
|
||||
import { getApp, getAppByDragType, canDrop, executeDrop } from '$lib/app-registry';
|
||||
import type { Component } from 'svelte';
|
||||
import { dropTarget } from '@mana/shared-ui/dnd';
|
||||
|
|
@ -49,6 +50,19 @@
|
|||
let helpOpen = $state(false);
|
||||
let helpData = $derived(app?.help);
|
||||
|
||||
// ── Inline feedback ────────────────────────────────────
|
||||
let feedbackOpen = $state(false);
|
||||
|
||||
function toggleFeedback() {
|
||||
feedbackOpen = !feedbackOpen;
|
||||
if (feedbackOpen) helpOpen = false;
|
||||
}
|
||||
|
||||
function toggleHelp() {
|
||||
helpOpen = !helpOpen;
|
||||
if (helpOpen) feedbackOpen = false;
|
||||
}
|
||||
|
||||
// ── Cross-module drop target ────────────────────────────
|
||||
let acceptedDropTypes = $derived(app?.acceptsDropFrom ?? []);
|
||||
|
||||
|
|
@ -309,10 +323,17 @@
|
|||
{onMoveLeft}
|
||||
{onMoveRight}
|
||||
{onContextMenu}
|
||||
onHelp={helpData ? () => (helpOpen = !helpOpen) : undefined}
|
||||
onHelp={helpData ? toggleHelp : undefined}
|
||||
{helpOpen}
|
||||
moduleId={appId}
|
||||
onFeedback={toggleFeedback}
|
||||
{feedbackOpen}
|
||||
>
|
||||
{#if helpOpen && helpData}
|
||||
{#if feedbackOpen}
|
||||
<div class="feedback-view">
|
||||
<FeedbackForm moduleContext={appId} onCancel={() => (feedbackOpen = false)} />
|
||||
</div>
|
||||
{:else if helpOpen && helpData}
|
||||
<div class="help-view">
|
||||
<p class="help-desc">{helpData.description}</p>
|
||||
|
||||
|
|
@ -425,6 +446,12 @@
|
|||
overflow-y: auto;
|
||||
height: 100%;
|
||||
}
|
||||
.feedback-view {
|
||||
padding: 1rem 1.125rem 1.5rem;
|
||||
animation: helpFadeIn 0.2s ease-out;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
}
|
||||
.help-desc {
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.65;
|
||||
|
|
|
|||
|
|
@ -50,9 +50,9 @@
|
|||
Globe,
|
||||
GridFour,
|
||||
Heart,
|
||||
HeartHalf,
|
||||
House,
|
||||
Key,
|
||||
Lightbulb,
|
||||
List,
|
||||
MagnifyingGlass,
|
||||
Microphone,
|
||||
|
|
@ -136,7 +136,7 @@
|
|||
scale: Scales,
|
||||
robot: Robot,
|
||||
key: Key,
|
||||
lightbulb: Lightbulb,
|
||||
'heart-half': HeartHalf,
|
||||
shield: Shield,
|
||||
gift: Gift,
|
||||
'music-notes': MusicNotes,
|
||||
|
|
@ -508,7 +508,7 @@
|
|||
links.push({
|
||||
id: 'feedback',
|
||||
label: 'Idee teilen',
|
||||
icon: 'lightbulb',
|
||||
icon: 'heart-half',
|
||||
onClick: onFeedback,
|
||||
});
|
||||
} else if (userEmail && feedbackHref) {
|
||||
|
|
@ -559,7 +559,7 @@
|
|||
out.push({
|
||||
id: 'feedback',
|
||||
label: 'Idee teilen',
|
||||
icon: 'lightbulb',
|
||||
icon: 'heart-half',
|
||||
onClick: () => onFeedback(),
|
||||
});
|
||||
out.push({ id: 'feedback-divider', label: '', divider: true });
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
Gear,
|
||||
Globe,
|
||||
Heart,
|
||||
Lightbulb,
|
||||
HeartHalf,
|
||||
Moon,
|
||||
Palette,
|
||||
Question,
|
||||
|
|
@ -37,7 +37,7 @@
|
|||
sun: Sun,
|
||||
palette: Palette,
|
||||
robot: Robot,
|
||||
lightbulb: Lightbulb,
|
||||
'heart-half': HeartHalf,
|
||||
logout: SignOut,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -23,9 +23,9 @@ import {
|
|||
Globe,
|
||||
GridFour,
|
||||
Heart,
|
||||
HeartHalf,
|
||||
House,
|
||||
Key,
|
||||
Lightbulb,
|
||||
List,
|
||||
MagnifyingGlass,
|
||||
Microphone,
|
||||
|
|
@ -110,7 +110,7 @@ export const phosphorIcons: Record<string, any> = {
|
|||
scale: Scales,
|
||||
robot: Robot,
|
||||
key: Key,
|
||||
lightbulb: Lightbulb,
|
||||
'heart-half': HeartHalf,
|
||||
shield: Shield,
|
||||
gift: Gift,
|
||||
'music-notes': MusicNotes,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue