mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 05:41:09 +02:00
feat(community,feedback): +5 reward chip + Phase 3.F legacy-cleanup
UI:
- FeedbackQuickModal Success-State + Onboarding-Wish Confirm zeigen
+5-Mana-Reward-Chip mit reward-in-Animation. Sofortiger Sichtbarer
Reziprozitäts-Loop.
Legacy-Cleanup (Phase 3.F):
- @mana/feedback dropped:
- FeedbackPage.svelte, FeedbackCard.svelte, FeedbackList.svelte,
FeedbackForm.svelte, VoteButton.svelte, StatusBadge.svelte
(alles Pre-Reactions-Markup, durch Community-Modul ersetzt)
- vote/unvote/toggleVote/getPublicFeedback service-shims
- VoteResponse, voteCount, userHasVoted Types
- mana-web dropped:
- lib/modules/feedback/ListView.svelte
- routes/(app)/feedback/+page.svelte
- app-registry-Eintrag 'feedback' (nur Bug-Reports — Community macht
das ohnehin besser via /community)
Pre-launch saubere Lösung: keine Backward-Compat-Shims, keine alten
Markup-Reste. ReactionBar bleibt der einzige Voting-Surface, /community
ist die einzige Feedback-Surface.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
dbe24acfc4
commit
eecf64c1c6
15 changed files with 90 additions and 1017 deletions
|
|
@ -1323,16 +1323,6 @@ registerApp({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
registerApp({
|
|
||||||
id: 'feedback',
|
|
||||||
name: 'Feedback',
|
|
||||||
color: '#8B5CF6',
|
|
||||||
icon: ChatCircleDots,
|
|
||||||
views: {
|
|
||||||
list: { load: () => import('$lib/modules/feedback/ListView.svelte') },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
registerApp({
|
registerApp({
|
||||||
id: 'community',
|
id: 'community',
|
||||||
name: 'Community',
|
name: 'Community',
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,10 @@
|
||||||
Dein Feedback ist eingegangen — sichtbar als
|
Dein Feedback ist eingegangen — sichtbar als
|
||||||
<strong>{submittedDisplayName}</strong>.
|
<strong>{submittedDisplayName}</strong>.
|
||||||
</p>
|
</p>
|
||||||
|
<div class="reward-chip" aria-live="polite">
|
||||||
|
<span class="reward-amount">+5</span>
|
||||||
|
<span class="reward-label">Mana Credits</span>
|
||||||
|
</div>
|
||||||
{#if isPublic}
|
{#if isPublic}
|
||||||
<p class="muted">Es taucht in der Community-Page auf, sobald wir es freigeben.</p>
|
<p class="muted">Es taucht in der Community-Page auf, sobald wir es freigeben.</p>
|
||||||
{:else}
|
{:else}
|
||||||
|
|
@ -261,6 +265,45 @@
|
||||||
margin-top: 0.5rem;
|
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 {
|
.context-badge {
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
|
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
<!--
|
|
||||||
Feedback — Workbench-embedded feedback/bug-report form.
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
import { FeedbackPage } from '@mana/feedback';
|
|
||||||
import { feedbackService } from '$lib/api/feedback';
|
|
||||||
import { authStore } from '$lib/stores/auth.svelte';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="feedback-page">
|
|
||||||
<FeedbackPage {feedbackService} appName="Mana" currentUserId={authStore.user?.id} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.feedback-page {
|
|
||||||
padding: 0.75rem;
|
|
||||||
height: 100%;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { FeedbackPage } from '@mana/feedback';
|
|
||||||
import { feedbackService } from '$lib/api/feedback';
|
|
||||||
import { authStore } from '$lib/stores/auth.svelte';
|
|
||||||
import { RoutePage } from '$lib/components/shell';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<RoutePage appId="feedback">
|
|
||||||
<FeedbackPage {feedbackService} appName="Mana" currentUserId={authStore.user?.id} />
|
|
||||||
</RoutePage>
|
|
||||||
|
|
@ -138,7 +138,11 @@
|
||||||
|
|
||||||
{#if submittedDisplayName}
|
{#if submittedDisplayName}
|
||||||
<aside class="preview" aria-live="polite">
|
<aside class="preview" aria-live="polite">
|
||||||
Gesendet — sichtbar als <strong>{submittedDisplayName}</strong>
|
<div>Gesendet — sichtbar als <strong>{submittedDisplayName}</strong></div>
|
||||||
|
<div class="reward-chip">
|
||||||
|
<span class="reward-amount">+5</span>
|
||||||
|
<span class="reward-label">Mana Credits</span>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -319,11 +323,52 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview {
|
.preview {
|
||||||
padding: 0.625rem 0.875rem;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 0.875rem;
|
||||||
border-radius: 0.625rem;
|
border-radius: 0.625rem;
|
||||||
background: hsl(var(--color-primary) / 0.1);
|
background: hsl(var(--color-primary) / 0.1);
|
||||||
color: hsl(var(--color-primary));
|
color: hsl(var(--color-primary));
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reward-chip {
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
border: 1px solid hsl(var(--color-primary) / 0.35);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,165 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { Feedback } from './feedback';
|
|
||||||
import StatusBadge from './StatusBadge.svelte';
|
|
||||||
import VoteButton from './VoteButton.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
feedback: Feedback;
|
|
||||||
onVote: (feedbackId: string, hasVoted: boolean) => void;
|
|
||||||
showStatus?: boolean;
|
|
||||||
isOwner?: boolean;
|
|
||||||
votingDisabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
feedback,
|
|
||||||
onVote,
|
|
||||||
showStatus = true,
|
|
||||||
isOwner = false,
|
|
||||||
votingDisabled = false,
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
function handleVote() {
|
|
||||||
onVote(feedback.id, feedback.userHasVoted ?? false);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateString: string): string {
|
|
||||||
const date = new Date(dateString);
|
|
||||||
return date.toLocaleDateString('de-DE', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
year: 'numeric',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="feedback-card" class:feedback-card--owner={isOwner}>
|
|
||||||
<div class="feedback-card__vote">
|
|
||||||
<VoteButton
|
|
||||||
count={feedback.voteCount}
|
|
||||||
hasVoted={feedback.userHasVoted ?? false}
|
|
||||||
onToggle={handleVote}
|
|
||||||
disabled={votingDisabled || !feedback.isPublic}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="feedback-card__content">
|
|
||||||
<div class="feedback-card__header">
|
|
||||||
{#if feedback.title}
|
|
||||||
<h3 class="feedback-card__title">{feedback.title}</h3>
|
|
||||||
{/if}
|
|
||||||
{#if showStatus}
|
|
||||||
<StatusBadge status={feedback.status} />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="feedback-card__text">{feedback.feedbackText}</p>
|
|
||||||
|
|
||||||
{#if feedback.adminResponse}
|
|
||||||
<div class="feedback-card__response">
|
|
||||||
<span class="feedback-card__response-label">Admin-Antwort:</span>
|
|
||||||
<p class="feedback-card__response-text">{feedback.adminResponse}</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="feedback-card__footer">
|
|
||||||
<span class="feedback-card__date">{formatDate(feedback.createdAt)}</span>
|
|
||||||
{#if isOwner}
|
|
||||||
<span class="feedback-card__owner-badge">Dein Feedback</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.feedback-card {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
background: hsl(var(--color-surface, 0 0% 100%));
|
|
||||||
border: 1px solid hsl(var(--color-border, 0 0% 90%));
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-card:hover {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-card--owner {
|
|
||||||
border-left: 3px solid hsl(var(--color-primary, 47 95% 58%));
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-card__vote {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-card__content {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-card__header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-card__title {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: hsl(var(--color-foreground, 0 0% 17%));
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-card__text {
|
|
||||||
margin: 0 0 0.75rem 0;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: hsl(var(--color-foreground, 0 0% 17%));
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-card__response {
|
|
||||||
padding: 0.75rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
background: hsl(var(--color-primary, 47 95% 58%) / 0.1);
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-card__response-label {
|
|
||||||
display: block;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: hsl(var(--color-primary, 47 95% 58%));
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-card__response-text {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: hsl(var(--color-foreground, 0 0% 17%));
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-card__footer {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-card__date {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: hsl(var(--color-muted-foreground, 0 0% 40%));
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-card__owner-badge {
|
|
||||||
padding: 0.125rem 0.5rem;
|
|
||||||
border-radius: 9999px;
|
|
||||||
font-size: 0.625rem;
|
|
||||||
font-weight: 600;
|
|
||||||
background: hsl(var(--color-primary, 47 95% 58%) / 0.1);
|
|
||||||
color: hsl(var(--color-primary, 47 95% 58%));
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,191 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { CreateFeedbackInput } from './api';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onSubmit: (input: CreateFeedbackInput) => Promise<void>;
|
|
||||||
onCancel?: () => void;
|
|
||||||
isSubmitting?: boolean;
|
|
||||||
feedbackLabel?: string;
|
|
||||||
submitLabel?: string;
|
|
||||||
cancelLabel?: string;
|
|
||||||
feedbackPlaceholder?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
onSubmit,
|
|
||||||
onCancel,
|
|
||||||
isSubmitting = false,
|
|
||||||
feedbackLabel = 'Dein Feedback',
|
|
||||||
submitLabel = 'Feedback senden',
|
|
||||||
cancelLabel = 'Abbrechen',
|
|
||||||
feedbackPlaceholder = 'Was gefällt dir? Was können wir verbessern?',
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
let feedbackText = $state('');
|
|
||||||
let error = $state('');
|
|
||||||
|
|
||||||
async function handleSubmit(e: Event) {
|
|
||||||
e.preventDefault();
|
|
||||||
error = '';
|
|
||||||
|
|
||||||
if (feedbackText.trim().length < 10) {
|
|
||||||
error = 'Bitte gib mindestens 10 Zeichen ein.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await onSubmit({
|
|
||||||
feedbackText: feedbackText.trim(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reset form on success
|
|
||||||
feedbackText = '';
|
|
||||||
} catch (err) {
|
|
||||||
error = err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<form class="feedback-form" onsubmit={handleSubmit}>
|
|
||||||
<div class="feedback-form__field">
|
|
||||||
<label for="feedback-text" class="feedback-form__label">{feedbackLabel}</label>
|
|
||||||
<textarea
|
|
||||||
id="feedback-text"
|
|
||||||
class="feedback-form__textarea"
|
|
||||||
placeholder={feedbackPlaceholder}
|
|
||||||
bind:value={feedbackText}
|
|
||||||
rows="5"
|
|
||||||
maxlength="2000"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
required
|
|
||||||
></textarea>
|
|
||||||
<span class="feedback-form__counter">{feedbackText.length}/2000</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if error}
|
|
||||||
<div class="feedback-form__error">{error}</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="feedback-form__actions">
|
|
||||||
{#if onCancel}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="feedback-form__button feedback-form__button--secondary"
|
|
||||||
onclick={onCancel}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
{cancelLabel}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="feedback-form__button feedback-form__button--primary"
|
|
||||||
disabled={isSubmitting || feedbackText.trim().length < 10}
|
|
||||||
>
|
|
||||||
{#if isSubmitting}
|
|
||||||
Wird gesendet...
|
|
||||||
{:else}
|
|
||||||
{submitLabel}
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.feedback-form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-form__field {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-form__label {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: hsl(var(--color-foreground, 0 0% 17%));
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-form__textarea {
|
|
||||||
padding: 0.75rem;
|
|
||||||
border: 1px solid hsl(var(--color-border, 0 0% 90%));
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: hsl(var(--color-foreground, 0 0% 17%));
|
|
||||||
background: hsl(var(--color-input, 0 0% 100%));
|
|
||||||
transition: border-color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-form__textarea::placeholder {
|
|
||||||
color: hsl(var(--color-muted-foreground, 0 0% 40%));
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-form__textarea:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: hsl(var(--color-primary, 47 95% 58%));
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-form__textarea {
|
|
||||||
resize: vertical;
|
|
||||||
min-height: 120px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-form__counter {
|
|
||||||
align-self: flex-end;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: hsl(var(--color-muted-foreground, 0 0% 40%));
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-form__error {
|
|
||||||
padding: 0.75rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
background: hsl(var(--color-error, 6 78% 57%) / 0.1);
|
|
||||||
color: hsl(var(--color-error, 6 78% 57%));
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-form__actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-form__button {
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
border: none;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-form__button:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-form__button--primary {
|
|
||||||
background: hsl(var(--color-primary, 47 95% 58%));
|
|
||||||
color: hsl(var(--color-primary-foreground, 0 0% 0%));
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-form__button--primary:hover:not(:disabled) {
|
|
||||||
background: hsl(var(--color-primary, 47 95% 58%) / 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-form__button--secondary {
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid hsl(var(--color-border, 0 0% 90%));
|
|
||||||
color: hsl(var(--color-foreground, 0 0% 17%));
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-form__button--secondary:hover:not(:disabled) {
|
|
||||||
background: hsl(var(--color-muted, 0 0% 90%));
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { Feedback } from './feedback';
|
|
||||||
import FeedbackCard from './FeedbackCard.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
items: Feedback[];
|
|
||||||
currentUserId?: string;
|
|
||||||
onVote: (feedbackId: string, hasVoted: boolean) => void;
|
|
||||||
votingDisabled?: boolean;
|
|
||||||
emptyMessage?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
items,
|
|
||||||
currentUserId,
|
|
||||||
onVote,
|
|
||||||
votingDisabled = false,
|
|
||||||
emptyMessage = 'Noch kein Feedback vorhanden',
|
|
||||||
}: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="feedback-list">
|
|
||||||
{#if items.length === 0}
|
|
||||||
<div class="feedback-list__empty">
|
|
||||||
<p>{emptyMessage}</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
{#each items as feedback (feedback.id)}
|
|
||||||
<FeedbackCard
|
|
||||||
{feedback}
|
|
||||||
{onVote}
|
|
||||||
{votingDisabled}
|
|
||||||
isOwner={currentUserId === feedback.userId}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.feedback-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-list__empty {
|
|
||||||
padding: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
background: hsl(var(--color-surface, 0 0% 100%) / 0.5);
|
|
||||||
border: 1px dashed hsl(var(--color-border, 0 0% 90%));
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-list__empty p {
|
|
||||||
margin: 0;
|
|
||||||
color: hsl(var(--color-muted-foreground, 0 0% 40%));
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,395 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { FeedbackService } from './createFeedbackService';
|
|
||||||
import type { Feedback } from './feedback';
|
|
||||||
import type { CreateFeedbackInput } from './api';
|
|
||||||
import FeedbackForm from './FeedbackForm.svelte';
|
|
||||||
import FeedbackList from './FeedbackList.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
/** Pre-configured feedback service instance */
|
|
||||||
feedbackService: FeedbackService;
|
|
||||||
/** App name for display */
|
|
||||||
appName: string;
|
|
||||||
/** Current user ID for highlighting own feedback */
|
|
||||||
currentUserId?: string;
|
|
||||||
/** Page title */
|
|
||||||
pageTitle?: string;
|
|
||||||
/** Page subtitle */
|
|
||||||
pageSubtitle?: string;
|
|
||||||
/** Tab label for own feedback */
|
|
||||||
myFeedbackLabel?: string;
|
|
||||||
/** Tab label for community feedback */
|
|
||||||
communityLabel?: string;
|
|
||||||
/** Empty state message for own feedback */
|
|
||||||
myFeedbackEmptyMessage?: string;
|
|
||||||
/** Empty state message for community feedback */
|
|
||||||
communityEmptyMessage?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
feedbackService,
|
|
||||||
appName,
|
|
||||||
currentUserId,
|
|
||||||
pageTitle = 'Feedback & Vorschläge',
|
|
||||||
pageSubtitle = 'Teile deine Ideen und stimme für Feature-Wünsche ab',
|
|
||||||
myFeedbackLabel = 'Mein Feedback',
|
|
||||||
communityLabel = 'Community',
|
|
||||||
myFeedbackEmptyMessage = 'Du hast noch kein Feedback eingereicht',
|
|
||||||
communityEmptyMessage = 'Noch keine öffentlichen Vorschläge',
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
// State
|
|
||||||
let activeTab = $state<'my' | 'community'>('community');
|
|
||||||
let myFeedback = $state<Feedback[]>([]);
|
|
||||||
let publicFeedback = $state<Feedback[]>([]);
|
|
||||||
let isLoading = $state(true);
|
|
||||||
let isSubmitting = $state(false);
|
|
||||||
let showForm = $state(false);
|
|
||||||
let successMessage = $state('');
|
|
||||||
|
|
||||||
// Load data on mount
|
|
||||||
$effect(() => {
|
|
||||||
loadFeedback();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function loadFeedback() {
|
|
||||||
isLoading = true;
|
|
||||||
try {
|
|
||||||
const [myResult, publicResult] = await Promise.all([
|
|
||||||
feedbackService.getMyFeedback(),
|
|
||||||
feedbackService.getPublicFeedback({ sort: 'score' }),
|
|
||||||
]);
|
|
||||||
myFeedback = myResult.items;
|
|
||||||
publicFeedback = publicResult.items;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[FeedbackPage] Error loading feedback:', error);
|
|
||||||
} finally {
|
|
||||||
isLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit(input: CreateFeedbackInput) {
|
|
||||||
isSubmitting = true;
|
|
||||||
try {
|
|
||||||
await feedbackService.createFeedback(input);
|
|
||||||
showForm = false;
|
|
||||||
successMessage = 'Feedback erfolgreich gesendet!';
|
|
||||||
setTimeout(() => {
|
|
||||||
successMessage = '';
|
|
||||||
}, 3000);
|
|
||||||
await loadFeedback();
|
|
||||||
} finally {
|
|
||||||
isSubmitting = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleVote(feedbackId: string, _hasVoted: boolean) {
|
|
||||||
try {
|
|
||||||
await feedbackService.toggleVote(feedbackId);
|
|
||||||
await loadFeedback();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[FeedbackPage] Error voting:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setActiveTab(tab: 'my' | 'community') {
|
|
||||||
activeTab = tab;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>{pageTitle} - {appName}</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="feedback-page">
|
|
||||||
<div class="feedback-page__container">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="feedback-page__header">
|
|
||||||
<div class="feedback-page__icon">
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h1 class="feedback-page__title">{pageTitle}</h1>
|
|
||||||
<p class="feedback-page__subtitle">{pageSubtitle}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Success Message -->
|
|
||||||
{#if successMessage}
|
|
||||||
<div class="feedback-page__success">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
|
||||||
<polyline points="22 4 12 14.01 9 11.01" />
|
|
||||||
</svg>
|
|
||||||
{successMessage}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- New Feedback Button / Form -->
|
|
||||||
<div class="feedback-page__form-section">
|
|
||||||
{#if showForm}
|
|
||||||
<div class="feedback-page__form-card">
|
|
||||||
<h2 class="feedback-page__form-title">Neues Feedback</h2>
|
|
||||||
<FeedbackForm
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
onCancel={() => (showForm = false)}
|
|
||||||
{isSubmitting}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<button class="feedback-page__new-button" onclick={() => (showForm = true)}>
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<line x1="12" y1="5" x2="12" y2="19" />
|
|
||||||
<line x1="5" y1="12" x2="19" y2="12" />
|
|
||||||
</svg>
|
|
||||||
Feedback geben
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tabs -->
|
|
||||||
<div class="feedback-page__tabs">
|
|
||||||
<button
|
|
||||||
class="feedback-page__tab"
|
|
||||||
class:feedback-page__tab--active={activeTab === 'community'}
|
|
||||||
onclick={() => setActiveTab('community')}
|
|
||||||
>
|
|
||||||
{communityLabel}
|
|
||||||
<span class="feedback-page__tab-count">{publicFeedback.length}</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="feedback-page__tab"
|
|
||||||
class:feedback-page__tab--active={activeTab === 'my'}
|
|
||||||
onclick={() => setActiveTab('my')}
|
|
||||||
>
|
|
||||||
{myFeedbackLabel}
|
|
||||||
<span class="feedback-page__tab-count">{myFeedback.length}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content -->
|
|
||||||
<div class="feedback-page__content">
|
|
||||||
{#if isLoading}
|
|
||||||
<div class="feedback-page__loading">
|
|
||||||
<div class="feedback-page__spinner"></div>
|
|
||||||
<p>Lade Feedback...</p>
|
|
||||||
</div>
|
|
||||||
{:else if activeTab === 'community'}
|
|
||||||
<FeedbackList
|
|
||||||
items={publicFeedback}
|
|
||||||
{currentUserId}
|
|
||||||
onVote={handleVote}
|
|
||||||
emptyMessage={communityEmptyMessage}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<FeedbackList
|
|
||||||
items={myFeedback}
|
|
||||||
{currentUserId}
|
|
||||||
onVote={handleVote}
|
|
||||||
votingDisabled={true}
|
|
||||||
emptyMessage={myFeedbackEmptyMessage}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.feedback-page {
|
|
||||||
min-height: 100%;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-page__container {
|
|
||||||
max-width: 48rem;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-page__header {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-page__icon {
|
|
||||||
width: 4rem;
|
|
||||||
height: 4rem;
|
|
||||||
margin: 0 auto 1rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 1rem;
|
|
||||||
background: hsl(var(--color-surface, 0 0% 100%));
|
|
||||||
border: 1px solid hsl(var(--color-border, 0 0% 90%));
|
|
||||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
|
||||||
color: hsl(var(--color-primary, 47 95% 58%));
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-page__icon svg {
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-page__title {
|
|
||||||
margin: 0 0 0.5rem 0;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: hsl(var(--color-foreground, 0 0% 17%));
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-page__subtitle {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: hsl(var(--color-muted-foreground, 0 0% 40%));
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-page__success {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
background: hsl(var(--color-success, 145 63% 42%) / 0.1);
|
|
||||||
color: hsl(var(--color-success, 145 63% 42%));
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-page__success svg {
|
|
||||||
width: 1.25rem;
|
|
||||||
height: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-page__form-section {
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-page__new-button {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
width: 100%;
|
|
||||||
padding: 1rem;
|
|
||||||
border: 2px dashed hsl(var(--color-border, 0 0% 90%));
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
background: transparent;
|
|
||||||
color: hsl(var(--color-muted-foreground, 0 0% 40%));
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-page__new-button:hover {
|
|
||||||
border-color: hsl(var(--color-primary, 47 95% 58%));
|
|
||||||
color: hsl(var(--color-primary, 47 95% 58%));
|
|
||||||
background: hsl(var(--color-primary, 47 95% 58%) / 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-page__new-button svg {
|
|
||||||
width: 1.25rem;
|
|
||||||
height: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-page__form-card {
|
|
||||||
padding: 1.5rem;
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
background: hsl(var(--color-surface, 0 0% 100%));
|
|
||||||
border: 1px solid hsl(var(--color-border, 0 0% 90%));
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-page__form-title {
|
|
||||||
margin: 0 0 1rem 0;
|
|
||||||
font-size: 1.125rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: hsl(var(--color-foreground, 0 0% 17%));
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-page__tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
padding: 0.25rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
background: hsl(var(--color-muted, 0 0% 90%));
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-page__tab {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
border: none;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
background: transparent;
|
|
||||||
color: hsl(var(--color-muted-foreground, 0 0% 40%));
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-page__tab:hover {
|
|
||||||
color: hsl(var(--color-foreground, 0 0% 17%));
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-page__tab--active {
|
|
||||||
background: hsl(var(--color-surface, 0 0% 100%));
|
|
||||||
color: hsl(var(--color-foreground, 0 0% 17%));
|
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-page__tab-count {
|
|
||||||
padding: 0.125rem 0.5rem;
|
|
||||||
border-radius: 9999px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
background: hsl(var(--color-muted, 0 0% 90%));
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-page__tab--active .feedback-page__tab-count {
|
|
||||||
background: hsl(var(--color-primary, 47 95% 58%) / 0.1);
|
|
||||||
color: hsl(var(--color-primary, 47 95% 58%));
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-page__content {
|
|
||||||
min-height: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-page__loading {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 3rem;
|
|
||||||
color: hsl(var(--color-muted-foreground, 0 0% 40%));
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-page__spinner {
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
border: 2px solid hsl(var(--color-border, 0 0% 90%));
|
|
||||||
border-top-color: hsl(var(--color-primary, 47 95% 58%));
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { FeedbackStatus } from './feedback';
|
|
||||||
import { FEEDBACK_STATUS_CONFIG } from './feedback';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
status: FeedbackStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { status }: Props = $props();
|
|
||||||
|
|
||||||
const config = $derived(FEEDBACK_STATUS_CONFIG[status]);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<span class="status-badge" style="--badge-color: {config.color};">
|
|
||||||
{config.label}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.status-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
border-radius: 9999px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 500;
|
|
||||||
background: color-mix(in srgb, var(--badge-color) 15%, transparent);
|
|
||||||
color: var(--badge-color);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
interface Props {
|
|
||||||
count: number;
|
|
||||||
hasVoted: boolean;
|
|
||||||
onToggle: () => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { count, hasVoted, onToggle, disabled = false }: Props = $props();
|
|
||||||
|
|
||||||
let isAnimating = $state(false);
|
|
||||||
|
|
||||||
function handleClick() {
|
|
||||||
if (disabled) return;
|
|
||||||
isAnimating = true;
|
|
||||||
onToggle();
|
|
||||||
setTimeout(() => {
|
|
||||||
isAnimating = false;
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="vote-button"
|
|
||||||
class:vote-button--voted={hasVoted}
|
|
||||||
class:vote-button--animating={isAnimating}
|
|
||||||
onclick={handleClick}
|
|
||||||
{disabled}
|
|
||||||
type="button"
|
|
||||||
aria-label={hasVoted ? 'Stimme entfernen' : 'Abstimmen'}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="vote-button__icon"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M18 15l-6-6-6 6" />
|
|
||||||
</svg>
|
|
||||||
<span class="vote-button__count">{count}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.vote-button {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
padding: 0.5rem;
|
|
||||||
border: 1px solid hsl(var(--color-border, 0 0% 90%));
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
background: transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
color: hsl(var(--color-muted-foreground, 0 0% 40%));
|
|
||||||
min-width: 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vote-button:hover:not(:disabled) {
|
|
||||||
border-color: hsl(var(--color-primary, 47 95% 58%));
|
|
||||||
color: hsl(var(--color-primary, 47 95% 58%));
|
|
||||||
}
|
|
||||||
|
|
||||||
.vote-button:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vote-button--voted {
|
|
||||||
background: hsl(var(--color-primary, 47 95% 58%) / 0.1);
|
|
||||||
border-color: hsl(var(--color-primary, 47 95% 58%));
|
|
||||||
color: hsl(var(--color-primary, 47 95% 58%));
|
|
||||||
}
|
|
||||||
|
|
||||||
.vote-button--animating {
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.vote-button__icon {
|
|
||||||
width: 1.25rem;
|
|
||||||
height: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vote-button__count {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -73,10 +73,3 @@ export interface AdminPatchInput {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ReactInput = { emoji: ReactionEmoji };
|
export type ReactInput = { emoji: ReactionEmoji };
|
||||||
|
|
||||||
export interface VoteResponse {
|
|
||||||
success: boolean;
|
|
||||||
newVoteCount?: number;
|
|
||||||
userHasVoted?: boolean;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ import type {
|
||||||
ReactionResponse,
|
ReactionResponse,
|
||||||
AdminPatchInput,
|
AdminPatchInput,
|
||||||
ReactInput,
|
ReactInput,
|
||||||
VoteResponse,
|
|
||||||
} from './api';
|
} from './api';
|
||||||
import type { FeedbackServiceConfig } from './types';
|
import type { FeedbackServiceConfig } from './types';
|
||||||
import type { PublicFeedbackItem, ReactionEmoji } from './feedback';
|
import type { PublicFeedbackItem, ReactionEmoji } from './feedback';
|
||||||
|
|
@ -148,27 +147,6 @@ export function createFeedbackService(config: FeedbackServiceConfig) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Legacy (back-compat shims) ─────────────────────────────────
|
|
||||||
// Older callers still use vote/unvote — translate to 👍-reaction toggles.
|
|
||||||
|
|
||||||
async function vote(feedbackId: string): Promise<VoteResponse> {
|
|
||||||
const res = await toggleReaction(feedbackId, '👍');
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
newVoteCount: res.reactions['👍'] ?? 0,
|
|
||||||
userHasVoted: res.userHasReacted,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const unvote = vote; // toggle, semantically idempotent for legacy callers
|
|
||||||
async function toggleVote(feedbackId: string): Promise<VoteResponse> {
|
|
||||||
return vote(feedbackId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getPublicFeedback(query?: FeedbackQueryParams): Promise<FeedbackListResponse> {
|
|
||||||
const items = await getPublicFeed(query);
|
|
||||||
return { items: items as unknown as FeedbackListResponse['items'] };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
createFeedback,
|
createFeedback,
|
||||||
getPublicFeed,
|
getPublicFeed,
|
||||||
|
|
@ -180,11 +158,6 @@ export function createFeedbackService(config: FeedbackServiceConfig) {
|
||||||
deleteFeedback,
|
deleteFeedback,
|
||||||
adminListAll,
|
adminListAll,
|
||||||
adminPatch,
|
adminPatch,
|
||||||
// Legacy (deprecated):
|
|
||||||
getPublicFeedback,
|
|
||||||
vote,
|
|
||||||
unvote,
|
|
||||||
toggleVote,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,6 @@ export interface Feedback {
|
||||||
status: FeedbackStatus;
|
status: FeedbackStatus;
|
||||||
isPublic: boolean;
|
isPublic: boolean;
|
||||||
adminResponse?: string;
|
adminResponse?: string;
|
||||||
voteCount: number;
|
|
||||||
displayHash?: string;
|
displayHash?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
moduleContext?: string;
|
moduleContext?: string;
|
||||||
|
|
@ -90,8 +89,6 @@ export interface Feedback {
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
publishedAt?: string;
|
publishedAt?: string;
|
||||||
completedAt?: string;
|
completedAt?: string;
|
||||||
// Legacy / derived for older UI surfaces:
|
|
||||||
userHasVoted?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FEEDBACK_CATEGORY_LABELS: Record<FeedbackCategory, string> = {
|
export const FEEDBACK_CATEGORY_LABELS: Record<FeedbackCategory, string> = {
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,6 @@ export {
|
||||||
type ReactionResponse,
|
type ReactionResponse,
|
||||||
type AdminPatchInput,
|
type AdminPatchInput,
|
||||||
type ReactInput,
|
type ReactInput,
|
||||||
type VoteResponse,
|
|
||||||
} from './api';
|
} from './api';
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
|
|
@ -43,10 +42,4 @@ export {
|
||||||
export type { FeedbackServiceConfig, PublicFeedbackServiceConfig } from './types';
|
export type { FeedbackServiceConfig, PublicFeedbackServiceConfig } from './types';
|
||||||
|
|
||||||
// UI Components
|
// UI Components
|
||||||
export { default as FeedbackPage } from './FeedbackPage.svelte';
|
|
||||||
export { default as FeedbackForm } from './FeedbackForm.svelte';
|
|
||||||
export { default as FeedbackList } from './FeedbackList.svelte';
|
|
||||||
export { default as FeedbackCard } from './FeedbackCard.svelte';
|
|
||||||
export { default as VoteButton } from './VoteButton.svelte';
|
|
||||||
export { default as ReactionBar } from './ReactionBar.svelte';
|
export { default as ReactionBar } from './ReactionBar.svelte';
|
||||||
export { default as StatusBadge } from './StatusBadge.svelte';
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue