mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:41:09 +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
|
pre-filled with the calling module's id. Drops into ModuleShell's
|
||||||
window-actions row by default; can be placed anywhere by callers.
|
window-actions row by default; can be placed anywhere by callers.
|
||||||
|
|
||||||
ModuleShell auto-injects this in its header (right next to the
|
Two render modes:
|
||||||
window-controls). Modules opt out via `hideFeedback={true}`.
|
- 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">
|
<script lang="ts">
|
||||||
import { Lightbulb } from '@mana/shared-icons';
|
import { HeartHalf } from '@mana/shared-icons';
|
||||||
import { authStore } from '$lib/stores/auth.svelte';
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
import FeedbackQuickModal from './FeedbackQuickModal.svelte';
|
import FeedbackQuickModal from './FeedbackQuickModal.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
moduleId?: string;
|
moduleId?: string;
|
||||||
size?: number;
|
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);
|
let open = $state(false);
|
||||||
|
|
||||||
function handleClick(e: MouseEvent) {
|
function handleClick(e: MouseEvent) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
// Submit requires login. Guests would just see an auth-error toast,
|
// 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;
|
if (!authStore.user) return;
|
||||||
open = true;
|
if (onClick) {
|
||||||
|
onClick();
|
||||||
|
} else {
|
||||||
|
open = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -33,14 +50,17 @@
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="feedback-hook-btn"
|
class="feedback-hook-btn"
|
||||||
|
class:active
|
||||||
onclick={handleClick}
|
onclick={handleClick}
|
||||||
title="Idee oder Feedback?"
|
title="Idee oder Feedback?"
|
||||||
aria-label="Feedback geben"
|
aria-label="Feedback geben"
|
||||||
>
|
>
|
||||||
<Lightbulb {size} weight="bold" />
|
<HeartHalf {size} weight="bold" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<FeedbackQuickModal {open} moduleContext={moduleId} onClose={() => (open = false)} />
|
{#if !onClick}
|
||||||
|
<FeedbackQuickModal {open} moduleContext={moduleId} onClose={() => (open = false)} />
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
@ -62,4 +82,9 @@
|
||||||
background: hsl(var(--color-surface-hover, var(--color-muted)));
|
background: hsl(var(--color-surface-hover, var(--color-muted)));
|
||||||
color: hsl(var(--color-primary));
|
color: hsl(var(--color-primary));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.feedback-hook-btn.active {
|
||||||
|
background: hsl(var(--color-surface-hover, var(--color-muted)));
|
||||||
|
color: hsl(var(--color-primary));
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,76 +1,28 @@
|
||||||
<!--
|
<!--
|
||||||
FeedbackQuickModal — Lightweight feedback-submission modal opened from
|
FeedbackQuickModal — Modal wrapper around FeedbackForm. Used outside
|
||||||
any FeedbackHook button or the global floating pill.
|
the workbench (e.g. PillNav user-menu, /todo direct route, settings
|
||||||
|
pages). Workbench cards render the same form inline instead.
|
||||||
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">
|
<script lang="ts">
|
||||||
import { X, PaperPlaneTilt } from '@mana/shared-icons';
|
import { X } from '@mana/shared-icons';
|
||||||
import { FEEDBACK_CATEGORY_LABELS, type FeedbackCategory } from '@mana/feedback';
|
import type { FeedbackCategory } from '@mana/feedback';
|
||||||
import { feedbackService } from '$lib/api/feedback';
|
import FeedbackForm from './FeedbackForm.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
/** Module that owns this hook — pre-filled into the submission. */
|
|
||||||
moduleContext?: string;
|
moduleContext?: string;
|
||||||
/** Pre-selected category. Defaults to 'feature'. */
|
|
||||||
defaultCategory?: FeedbackCategory;
|
defaultCategory?: FeedbackCategory;
|
||||||
/** Optional headline override. */
|
|
||||||
title?: string;
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let props: Props = $props();
|
let props: Props = $props();
|
||||||
let title = $derived(props.title ?? 'Idee oder Feedback?');
|
let title = $derived(props.title ?? 'Idee oder Feedback?');
|
||||||
let defaultCategory = $derived<FeedbackCategory>(props.defaultCategory ?? 'feature');
|
|
||||||
|
|
||||||
let text = $state('');
|
let formRef = $state<{ reset: () => void } | null>(null);
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
text = '';
|
formRef?.reset();
|
||||||
category = defaultCategory;
|
|
||||||
isPublic = true;
|
|
||||||
error = null;
|
|
||||||
submittedDisplayName = null;
|
|
||||||
props.onClose();
|
props.onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -93,82 +45,20 @@
|
||||||
>
|
>
|
||||||
<div class="modal" role="document" onclick={(e) => e.stopPropagation()}>
|
<div class="modal" role="document" onclick={(e) => e.stopPropagation()}>
|
||||||
<header class="modal-header">
|
<header class="modal-header">
|
||||||
<h2>{submittedDisplayName ? 'Danke!' : title}</h2>
|
<h2>{title}</h2>
|
||||||
<button class="close-btn" onclick={handleClose} aria-label="Schließen">
|
<button class="close-btn" onclick={handleClose} aria-label="Schließen">
|
||||||
<X size={18} weight="bold" />
|
<X size={18} weight="bold" />
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if submittedDisplayName}
|
<div class="body">
|
||||||
<div class="success">
|
<FeedbackForm
|
||||||
<p>
|
bind:this={formRef}
|
||||||
Dein Feedback ist eingegangen — sichtbar als
|
moduleContext={props.moduleContext}
|
||||||
<strong>{submittedDisplayName}</strong>.
|
defaultCategory={props.defaultCategory}
|
||||||
</p>
|
onCancel={handleClose}
|
||||||
<div class="reward-chip" aria-live="polite">
|
/>
|
||||||
<span class="reward-amount">+5</span>
|
</div>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -237,200 +127,6 @@
|
||||||
|
|
||||||
.body {
|
.body {
|
||||||
padding: 0.5rem 1rem 1rem;
|
padding: 0.5rem 1rem 1rem;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.875rem;
|
|
||||||
overflow-y: auto;
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@
|
||||||
helpOpen?: boolean;
|
helpOpen?: boolean;
|
||||||
onContextMenu?: (e: MouseEvent) => void;
|
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`
|
// window-actions row. Submitted feedback is tagged with `moduleId`
|
||||||
// so the public community feed can group/filter by module.
|
// so the public community feed can group/filter by module.
|
||||||
/** Module identifier passed to the inline FeedbackHook. */
|
/** Module identifier passed to the inline FeedbackHook. */
|
||||||
|
|
@ -75,6 +75,12 @@
|
||||||
/** Suppress the auto-injected FeedbackHook (e.g. on the
|
/** Suppress the auto-injected FeedbackHook (e.g. on the
|
||||||
* /community-/feedback-pages where it's redundant). */
|
* /community-/feedback-pages where it's redundant). */
|
||||||
hideFeedback?: boolean;
|
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
|
// Snippets
|
||||||
header_left?: Snippet;
|
header_left?: Snippet;
|
||||||
|
|
@ -106,6 +112,8 @@
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
moduleId,
|
moduleId,
|
||||||
hideFeedback = false,
|
hideFeedback = false,
|
||||||
|
onFeedback,
|
||||||
|
feedbackOpen = false,
|
||||||
header_left,
|
header_left,
|
||||||
badge,
|
badge,
|
||||||
actions,
|
actions,
|
||||||
|
|
@ -205,7 +213,7 @@
|
||||||
{@render actions()}
|
{@render actions()}
|
||||||
{/if}
|
{/if}
|
||||||
{#if !hideFeedback}
|
{#if !hideFeedback}
|
||||||
<FeedbackHook {moduleId} />
|
<FeedbackHook {moduleId} onClick={onFeedback} active={feedbackOpen} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if onHelp}
|
{#if onHelp}
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
import { X, CaretUp, CaretDown, ArrowLeft, SpinnerGap } from '@mana/shared-icons';
|
import { X, CaretUp, CaretDown, ArrowLeft, SpinnerGap } from '@mana/shared-icons';
|
||||||
import { _ } from 'svelte-i18n';
|
import { _ } from 'svelte-i18n';
|
||||||
import { ModuleShell } from '$lib/components/shell';
|
import { ModuleShell } from '$lib/components/shell';
|
||||||
|
import FeedbackForm from '$lib/components/feedback/FeedbackForm.svelte';
|
||||||
import { getApp, getAppByDragType, canDrop, executeDrop } from '$lib/app-registry';
|
import { getApp, getAppByDragType, canDrop, executeDrop } from '$lib/app-registry';
|
||||||
import type { Component } from 'svelte';
|
import type { Component } from 'svelte';
|
||||||
import { dropTarget } from '@mana/shared-ui/dnd';
|
import { dropTarget } from '@mana/shared-ui/dnd';
|
||||||
|
|
@ -49,6 +50,19 @@
|
||||||
let helpOpen = $state(false);
|
let helpOpen = $state(false);
|
||||||
let helpData = $derived(app?.help);
|
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 ────────────────────────────
|
// ── Cross-module drop target ────────────────────────────
|
||||||
let acceptedDropTypes = $derived(app?.acceptsDropFrom ?? []);
|
let acceptedDropTypes = $derived(app?.acceptsDropFrom ?? []);
|
||||||
|
|
||||||
|
|
@ -309,10 +323,17 @@
|
||||||
{onMoveLeft}
|
{onMoveLeft}
|
||||||
{onMoveRight}
|
{onMoveRight}
|
||||||
{onContextMenu}
|
{onContextMenu}
|
||||||
onHelp={helpData ? () => (helpOpen = !helpOpen) : undefined}
|
onHelp={helpData ? toggleHelp : undefined}
|
||||||
{helpOpen}
|
{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">
|
<div class="help-view">
|
||||||
<p class="help-desc">{helpData.description}</p>
|
<p class="help-desc">{helpData.description}</p>
|
||||||
|
|
||||||
|
|
@ -425,6 +446,12 @@
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
.feedback-view {
|
||||||
|
padding: 1rem 1.125rem 1.5rem;
|
||||||
|
animation: helpFadeIn 0.2s ease-out;
|
||||||
|
overflow-y: auto;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
.help-desc {
|
.help-desc {
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
|
|
|
||||||
|
|
@ -50,9 +50,9 @@
|
||||||
Globe,
|
Globe,
|
||||||
GridFour,
|
GridFour,
|
||||||
Heart,
|
Heart,
|
||||||
|
HeartHalf,
|
||||||
House,
|
House,
|
||||||
Key,
|
Key,
|
||||||
Lightbulb,
|
|
||||||
List,
|
List,
|
||||||
MagnifyingGlass,
|
MagnifyingGlass,
|
||||||
Microphone,
|
Microphone,
|
||||||
|
|
@ -136,7 +136,7 @@
|
||||||
scale: Scales,
|
scale: Scales,
|
||||||
robot: Robot,
|
robot: Robot,
|
||||||
key: Key,
|
key: Key,
|
||||||
lightbulb: Lightbulb,
|
'heart-half': HeartHalf,
|
||||||
shield: Shield,
|
shield: Shield,
|
||||||
gift: Gift,
|
gift: Gift,
|
||||||
'music-notes': MusicNotes,
|
'music-notes': MusicNotes,
|
||||||
|
|
@ -508,7 +508,7 @@
|
||||||
links.push({
|
links.push({
|
||||||
id: 'feedback',
|
id: 'feedback',
|
||||||
label: 'Idee teilen',
|
label: 'Idee teilen',
|
||||||
icon: 'lightbulb',
|
icon: 'heart-half',
|
||||||
onClick: onFeedback,
|
onClick: onFeedback,
|
||||||
});
|
});
|
||||||
} else if (userEmail && feedbackHref) {
|
} else if (userEmail && feedbackHref) {
|
||||||
|
|
@ -559,7 +559,7 @@
|
||||||
out.push({
|
out.push({
|
||||||
id: 'feedback',
|
id: 'feedback',
|
||||||
label: 'Idee teilen',
|
label: 'Idee teilen',
|
||||||
icon: 'lightbulb',
|
icon: 'heart-half',
|
||||||
onClick: () => onFeedback(),
|
onClick: () => onFeedback(),
|
||||||
});
|
});
|
||||||
out.push({ id: 'feedback-divider', label: '', divider: true });
|
out.push({ id: 'feedback-divider', label: '', divider: true });
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
Gear,
|
Gear,
|
||||||
Globe,
|
Globe,
|
||||||
Heart,
|
Heart,
|
||||||
Lightbulb,
|
HeartHalf,
|
||||||
Moon,
|
Moon,
|
||||||
Palette,
|
Palette,
|
||||||
Question,
|
Question,
|
||||||
|
|
@ -37,7 +37,7 @@
|
||||||
sun: Sun,
|
sun: Sun,
|
||||||
palette: Palette,
|
palette: Palette,
|
||||||
robot: Robot,
|
robot: Robot,
|
||||||
lightbulb: Lightbulb,
|
'heart-half': HeartHalf,
|
||||||
logout: SignOut,
|
logout: SignOut,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,9 @@ import {
|
||||||
Globe,
|
Globe,
|
||||||
GridFour,
|
GridFour,
|
||||||
Heart,
|
Heart,
|
||||||
|
HeartHalf,
|
||||||
House,
|
House,
|
||||||
Key,
|
Key,
|
||||||
Lightbulb,
|
|
||||||
List,
|
List,
|
||||||
MagnifyingGlass,
|
MagnifyingGlass,
|
||||||
Microphone,
|
Microphone,
|
||||||
|
|
@ -110,7 +110,7 @@ export const phosphorIcons: Record<string, any> = {
|
||||||
scale: Scales,
|
scale: Scales,
|
||||||
robot: Robot,
|
robot: Robot,
|
||||||
key: Key,
|
key: Key,
|
||||||
lightbulb: Lightbulb,
|
'heart-half': HeartHalf,
|
||||||
shield: Shield,
|
shield: Shield,
|
||||||
gift: Gift,
|
gift: Gift,
|
||||||
'music-notes': MusicNotes,
|
'music-notes': MusicNotes,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue