feat: extension feedback built in

This commit is contained in:
2026-07-26 15:58:03 +09:30
parent fe7127a83c
commit 318000009f
25 changed files with 1473 additions and 167 deletions
@@ -0,0 +1,347 @@
<script lang="ts">
import { fade } from "svelte/transition";
import { onMount } from "svelte";
import Switch from "./Switch.svelte";
import {
FEEDBACK_CATEGORIES,
FEEDBACK_MESSAGE_MAX,
FEEDBACK_MESSAGE_MIN,
type FeedbackCategory,
} from "@/seqta/utils/feedback/constants";
import {
FeedbackApiError,
addPendingFeedbackId,
categoryLabel,
fetchFeedbackStatusItem,
fetchFeedbackStatusList,
formatStatus,
getInstanceHostname,
hasReply,
removePendingFeedbackIds,
submitFeedback,
validateFeedbackForm,
type FeedbackStatusItem,
} from "@/seqta/utils/feedback/client";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
let { onClose, initialFeedbackId = null } = $props<{
onClose: () => void;
initialFeedbackId?: string | null;
}>();
let tab = $state<"send" | "status">(initialFeedbackId ? "status" : "send");
let category = $state<FeedbackCategory>("bug");
let subject = $state("");
let message = $state("");
let includeContact = $state(false);
let contactName = $state("");
let contactEmail = $state("");
let includeInstance = $state(false);
let submitting = $state(false);
let errorMessage = $state<string | null>(null);
let successId = $state<string | null>(null);
let statusLoading = $state(false);
let statusError = $state<string | null>(null);
let statusItems = $state<FeedbackStatusItem[]>([]);
let selectedItem = $state<FeedbackStatusItem | null>(null);
const instanceHostname = getInstanceHostname();
const isDark = $derived(!!$settingsState.DarkMode);
const busy = $derived(submitting || statusLoading);
const fieldStyle = $derived(
isDark
? "background-color:#18181b;color:#fafafa;border-color:#52525b;color-scheme:dark"
: "background-color:#fff;color:#18181b;border-color:#e4e4e7;color-scheme:light",
);
const field =
"feedback-field w-full px-3 py-2.5 text-[18px] rounded-lg border focus:outline-none focus:ring-2 focus:ring-zinc-400";
const btn =
"px-4 py-2 text-[18px] font-medium rounded-lg transition-all duration-200 hover:scale-105 active:scale-95 focus:outline-none focus:ring-2 focus:ring-zinc-400 disabled:opacity-50";
const btnMuted = $derived(
`${btn} ${isDark ? "bg-zinc-700 text-zinc-200" : "bg-zinc-200 text-zinc-700"}`,
);
const btnPrimary = $derived(
`${btn} ${isDark ? "bg-zinc-200 text-zinc-900" : "bg-zinc-800 text-white"}`,
);
function errText(e: unknown): string {
if (e instanceof FeedbackApiError) return e.message;
return e instanceof Error ? e.message : "Something went wrong.";
}
async function loadStatusList() {
statusLoading = true;
statusError = null;
selectedItem = null;
try {
statusItems = await fetchFeedbackStatusList(10);
} catch (e) {
statusItems = [];
statusError = errText(e);
} finally {
statusLoading = false;
}
}
async function openStatusItem(id: string) {
statusLoading = true;
statusError = null;
try {
selectedItem = await fetchFeedbackStatusItem(id);
tab = "status";
successId = null;
if (selectedItem && hasReply(selectedItem)) {
void removePendingFeedbackIds([selectedItem.id]);
}
} catch (e) {
statusError = errText(e);
} finally {
statusLoading = false;
}
}
function selectTab(next: "send" | "status") {
tab = next;
errorMessage = null;
statusError = null;
successId = null;
selectedItem = null;
if (next === "status") void loadStatusList();
}
onMount(() => {
if (initialFeedbackId) void openStatusItem(initialFeedbackId);
});
async function handleSubmit() {
const form = {
category,
subject,
message,
includeContact,
contactName,
contactEmail,
includeInstance,
};
errorMessage = validateFeedbackForm(form);
if (errorMessage) return;
submitting = true;
try {
const result = await submitFeedback(form);
successId = result.id;
void addPendingFeedbackId(result.id);
} catch (e) {
errorMessage = errText(e);
} finally {
submitting = false;
}
}
function tabClass(active: boolean): string {
if (active) {
return isDark
? "bg-zinc-700 text-white font-semibold shadow-sm"
: "bg-white text-zinc-900 font-semibold shadow-sm";
}
return isDark
? "bg-transparent text-zinc-400 hover:text-zinc-200"
: "bg-transparent text-zinc-500 hover:text-zinc-800";
}
</script>
<div
class="flex fixed inset-0 z-[99999] justify-center items-center bg-black/50 backdrop-blur-sm {isDark
? 'dark'
: ''}"
onclick={(e) => {
if (e.target === e.currentTarget && !busy) onClose();
}}
onkeydown={(e) => {
if (e.key === "Escape" && !busy) onClose();
}}
role="button"
tabindex="-1"
transition:fade={{ duration: 150 }}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="p-5 mx-4 w-full max-w-lg max-h-[90vh] overflow-y-auto rounded-2xl shadow-2xl border text-[18px] {isDark
? 'bg-zinc-800 text-white border-zinc-700'
: 'bg-white text-zinc-900 border-zinc-200'}"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="feedback-modal-title"
tabindex="-1"
>
<div
class="flex gap-1 p-1 mb-4 rounded-full {isDark ? 'bg-zinc-900' : 'bg-zinc-100'}"
role="tablist"
aria-label="Feedback views"
>
<button type="button" role="tab" aria-selected={tab === "send"} onclick={() => selectTab("send")} class="flex-1 px-3 py-2.5 rounded-full transition-all duration-200 {tabClass(tab === 'send')}">
Send
</button>
<button type="button" role="tab" aria-selected={tab === "status"} onclick={() => selectTab("status")} class="flex-1 px-3 py-2.5 rounded-full transition-all duration-200 {tabClass(tab === 'status')}">
My feedback
</button>
</div>
{#if tab === "send"}
{#if successId}
<h2 id="feedback-modal-title" class="mb-3 text-xl font-bold">Thanks for the feedback</h2>
<p class="mb-2 text-zinc-600 dark:text-zinc-300">Reference ID:</p>
<p class="mb-4 px-3 py-2 font-mono text-base rounded-lg {isDark ? 'bg-zinc-900' : 'bg-zinc-100'} break-all">{successId}</p>
<div class="flex gap-3 justify-end">
<button type="button" class={btnMuted} onclick={() => successId && openStatusItem(successId)}>Check status</button>
<button type="button" class={btnPrimary} onclick={onClose}>Done</button>
</div>
{:else}
<h2 id="feedback-modal-title" class="mb-1 text-xl font-bold">Send feedback</h2>
<p class="mb-4 text-zinc-600 dark:text-zinc-400">Anonymous by default. Contact and school details are optional.</p>
<div class="flex flex-col gap-4">
<label class="flex flex-col gap-1.5 font-medium">
Category
<select id="feedback-category" bind:value={category} class={field} style={fieldStyle}>
{#each FEEDBACK_CATEGORIES as value (value)}
<option {value} style={fieldStyle}>{categoryLabel(value)}</option>
{/each}
</select>
</label>
<label class="flex flex-col gap-1.5 font-medium">
Subject <span class="font-normal text-zinc-500">(optional)</span>
<input type="text" maxlength={120} bind:value={subject} placeholder="Short summary" class={field} style={fieldStyle} />
</label>
<label class="flex flex-col gap-1.5 font-medium">
Message
<textarea rows={5} maxlength={FEEDBACK_MESSAGE_MAX} bind:value={message} placeholder="What happened, or what would you like to see?" class="{field} resize-y min-h-[120px]" style={fieldStyle}></textarea>
<span class="text-base font-normal text-zinc-500">{message.trim().length}/{FEEDBACK_MESSAGE_MAX} (min {FEEDBACK_MESSAGE_MIN})</span>
</label>
<div class="flex justify-between items-center gap-3">
<div>
<p class="font-medium">Include contact details</p>
<p class="text-base text-zinc-500">Name and email so we can reply</p>
</div>
<Switch state={includeContact} onChange={(v) => (includeContact = v)} />
</div>
{#if includeContact}
<input type="text" maxlength={80} bind:value={contactName} placeholder="Name" class={field} style={fieldStyle} />
<input type="email" maxlength={254} bind:value={contactEmail} placeholder="Email" class={field} style={fieldStyle} />
{/if}
<div class="flex justify-between items-center gap-3">
<div>
<p class="font-medium">Include SEQTA instance</p>
<p class="text-base text-zinc-500">
{#if instanceHostname}
Hostname only: <span class="font-mono">{instanceHostname}</span>
{:else}
Open SEQTA first to detect hostname
{/if}
</p>
</div>
<Switch
state={includeInstance && !!instanceHostname}
onChange={(v) => {
if (instanceHostname) includeInstance = v;
}}
/>
</div>
{#if errorMessage}
<p class="text-red-600 dark:text-red-400" role="alert">{errorMessage}</p>
{/if}
<p class="text-base text-zinc-500">
Sent to betterseqta.org.
<a href="https://betterseqta.org/privacy" target="_blank" rel="noopener noreferrer" class="underline">Privacy</a>
</p>
<div class="flex gap-3 justify-end">
<button type="button" class={btnMuted} onclick={onClose} disabled={submitting}>Cancel</button>
<button type="button" class={btnPrimary} onclick={handleSubmit} disabled={submitting}>
{submitting ? "Sending…" : "Send feedback"}
</button>
</div>
</div>
{/if}
{:else if selectedItem}
<div class="flex items-start justify-between gap-3 mb-3">
<h2 id="feedback-modal-title" class="text-xl font-bold">Feedback status</h2>
<button type="button" class="{btnMuted} !text-base !px-3 !py-1.5" onclick={() => { selectedItem = null; void loadStatusList(); }}>Back</button>
</div>
<p class="mb-1 font-medium">{selectedItem.subject || "Untitled"} · {formatStatus(selectedItem.status)}</p>
<p class="mb-4 font-mono text-base text-zinc-500 break-all">{selectedItem.id}</p>
{#if hasReply(selectedItem)}
<div class="p-3 mb-4 rounded-lg border {isDark ? 'border-zinc-700 bg-zinc-900/50' : 'border-zinc-200 bg-zinc-50'}">
<p class="mb-1 text-base font-semibold uppercase tracking-wide text-zinc-500">Response</p>
<p class="whitespace-pre-wrap">{selectedItem.response}</p>
</div>
{:else}
<p class="mb-4 text-zinc-500">No response yet.</p>
{/if}
{#if statusError}<p class="mb-3 text-red-600 dark:text-red-400" role="alert">{statusError}</p>{/if}
<div class="flex gap-3 justify-end">
<button type="button" class={btnMuted} onclick={() => openStatusItem(selectedItem.id)} disabled={statusLoading}>
{statusLoading ? "Refreshing…" : "Refresh"}
</button>
<button type="button" class={btnPrimary} onclick={onClose}>Close</button>
</div>
{:else}
<div class="flex items-start justify-between gap-3 mb-3">
<h2 id="feedback-modal-title" class="text-xl font-bold">My feedback</h2>
<button type="button" class="{btnMuted} !text-base !px-3 !py-1.5" onclick={() => void loadStatusList()} disabled={statusLoading}>
{statusLoading ? "…" : "Refresh"}
</button>
</div>
{#if statusError}<p class="mb-3 text-red-600 dark:text-red-400" role="alert">{statusError}</p>{/if}
{#if statusLoading && !statusItems.length}
<p class="text-zinc-500">Loading…</p>
{:else if !statusItems.length}
<p class="mb-4 text-zinc-500">No feedback yet.</p>
<button type="button" class={btnPrimary} onclick={() => selectTab("send")}>Send feedback</button>
{:else}
<ul class="flex flex-col gap-2 mb-4">
{#each statusItems as item (item.id)}
<li>
<button
type="button"
onclick={() => openStatusItem(item.id)}
class="w-full p-3 text-left rounded-lg border transition-all duration-200 hover:scale-[1.01] focus:outline-none focus:ring-2 focus:ring-zinc-400 {isDark
? 'border-zinc-700 bg-zinc-900/40'
: 'border-zinc-200 bg-white'}"
>
<p class="text-base text-zinc-500 mb-0.5">
{formatStatus(item.status)}{#if hasReply(item)} · Reply{/if} · {categoryLabel(item.category)}
</p>
<p class="font-medium truncate">{item.subject || "Untitled"}</p>
</button>
</li>
{/each}
</ul>
{/if}
<div class="flex justify-end">
<button type="button" class={btnMuted} onclick={onClose}>Close</button>
</div>
{/if}
</div>
</div>
<style>
:global(.feedback-field),
:global(.feedback-field option) {
-webkit-text-fill-color: currentColor !important;
caret-color: currentColor !important;
}
:global(.feedback-field::placeholder) {
-webkit-text-fill-color: #a1a1aa !important;
color: #a1a1aa !important;
opacity: 1;
}
</style>
+62 -2
View File
@@ -16,7 +16,9 @@
import FontPickerModal from "../components/FontPickerModal.svelte";
import CloudPanel from "../components/CloudPanel.svelte";
import DisclaimerModal from "../components/DisclaimerModal.svelte";
import FeedbackModal from "../components/FeedbackModal.svelte";
import { settingsPopup } from "@/seqta/utils/settingsPopup";
import { consumeOpenFeedbackRequest } from "@/seqta/utils/feedback/client";
import {
checkGithubReleaseUpdate,
dismissNightlyUpdate,
@@ -158,11 +160,18 @@
let showColourPicker = $state<boolean>(false);
let showFontPicker = $state<boolean>(false);
let showCloudPanel = $state<boolean>(false);
let showFeedbackModal = $state<boolean>(false);
let feedbackFocusId = $state<string | null>(null);
const openCloudPanel = () => {
showCloudPanel = true;
};
const openFeedback = (feedbackId?: string | null) => {
feedbackFocusId = feedbackId ?? null;
showFeedbackModal = true;
};
const showDisclaimer = (
onConfirm: () => void,
onCancel: () => void,
@@ -179,6 +188,8 @@
showColourPicker = false;
showFontPicker = false;
showCloudPanel = false;
showFeedbackModal = false;
feedbackFocusId = null;
};
const handleClose = () => {
@@ -209,6 +220,17 @@
});
}
const pendingFeedbackId = consumeOpenFeedbackRequest();
if (pendingFeedbackId) {
openFeedback(pendingFeedbackId);
}
const onOpenFeedback = (event: Event) => {
const id = (event as CustomEvent<{ id?: string }>).detail?.id;
if (typeof id === "string" && id) openFeedback(id);
};
window.addEventListener("bsplus:open-feedback", onOpenFeedback);
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && !standalone) {
closeExtensionPopup();
@@ -218,6 +240,7 @@
return () => {
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("bsplus:open-feedback", onOpenFeedback);
};
});
@@ -350,12 +373,12 @@
<!-- Body: left nav + content -->
<div class="flex flex-1 min-h-0 overflow-hidden">
<nav
class="flex flex-col shrink-0 gap-5 overflow-y-auto no-scrollbar border-r border-zinc-200/60 dark:border-zinc-700/50 bg-zinc-50/80 dark:bg-zinc-900/40 {standalone
class="flex flex-col shrink-0 min-h-0 border-r border-zinc-200/60 dark:border-zinc-700/50 bg-zinc-50/80 dark:bg-zinc-900/40 {standalone
? 'w-[140px] px-2 py-3'
: 'w-[260px] px-4 py-5'}"
aria-label="Settings categories"
>
<div class="relative flex flex-col gap-5" bind:this={navTrackEl}>
<div class="relative flex flex-col flex-1 min-h-0 gap-5 overflow-y-auto no-scrollbar" bind:this={navTrackEl}>
{#if activePage === "settings" && indicatorReady}
<div
class="absolute left-0 right-0 top-0 z-0 rounded-lg bg-zinc-200/90 dark:bg-zinc-700/90 pointer-events-none"
@@ -416,6 +439,33 @@
</div>
{/if}
</div>
<button
type="button"
onclick={openFeedback}
class="shrink-0 mt-4 w-full px-3 py-2.5 text-left text-[18px] font-medium rounded-lg transition-all duration-200
text-zinc-700 dark:text-zinc-200
bg-zinc-200/70 dark:bg-zinc-800/80
hover:bg-zinc-300/80 dark:hover:bg-zinc-700
hover:scale-[1.02] active:scale-95
focus:outline-none focus:ring-2 focus:ring-zinc-400 focus:ring-offset-2 dark:focus:ring-offset-zinc-900"
>
<span class="flex items-center gap-2">
<svg
class="w-5 h-5 shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
<span>Send us feedback!</span>
</span>
</button>
</nav>
<div class="flex flex-col flex-1 min-w-0 min-h-0">
@@ -516,3 +566,13 @@
}}
/>
{/if}
{#if showFeedbackModal}
<FeedbackModal
initialFeedbackId={feedbackFocusId}
onClose={() => {
showFeedbackModal = false;
feedbackFocusId = null;
}}
/>
{/if}