<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import browser from "webextension-polyfill";
import { GnAvatar, GnButton, GnCard, GnCheckbox, GnInput, GnSelect } from "gnexus-ui-kit/vue";
import logoUrl from "../icons/logo.svg";
interface SettingsView {
serverUrl: string;
panelUrl: string | null;
token: string | null;
user: { id: string; nickname: string; email: string } | null;
defaultProjectId: string | null;
hoverReplay: boolean;
videoCapture: boolean;
consoleCapture: boolean;
}
interface ProjectView {
id: string;
name: string;
}
const settings = ref<SettingsView>({ serverUrl: "http://localhost:8001", panelUrl: null, token: null, user: null, defaultProjectId: null, hoverReplay: true, videoCapture: true, consoleCapture: true });
const projects = ref<ProjectView[]>([]);
const email = ref("");
const password = ref("");
const status = ref<string | null>(null);
const error = ref<string | null>(null);
const busy = ref(false);
/** GnSelect is a native <select>: a null modelValue matches no option and blanks it. */
const panelUrl = computed({
get: () => settings.value.panelUrl ?? "",
set: (value: string) => {
settings.value = { ...settings.value, panelUrl: value.trim() || null };
},
});
const defaultProjectId = computed({
get: () => settings.value.defaultProjectId ?? "",
set: (value: string) => {
settings.value = { ...settings.value, defaultProjectId: value || null };
if (value) void saveDefaultProject();
},
});
const projectOptions = computed(() => [
{ value: "", label: "Select a project…" },
...projects.value.map((project) => ({ value: project.id, label: project.name })),
]);
async function sendMessage<T>(message: Record<string, unknown>): Promise<T> {
const response = (await browser.runtime.sendMessage(message)) as { ok: boolean; data?: T; error?: string };
if (!response?.ok) throw new Error(response?.error ?? "Request failed");
return response.data as T;
}
async function load() {
try {
settings.value = await sendMessage<SettingsView>({ type: "settings_get" });
if (settings.value.token) await loadProjects();
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
}
}
async function loadProjects() {
try {
projects.value = await sendMessage<ProjectView[]>({ type: "list_projects" });
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
}
}
async function signIn() {
busy.value = true;
error.value = null;
try {
await sendMessage({ type: "login", email: email.value, password: password.value });
await load();
status.value = "Signed in";
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
} finally {
busy.value = false;
}
}
async function signOut() {
await sendMessage({ type: "logout" });
settings.value = { ...settings.value, token: null, user: null, defaultProjectId: null };
projects.value = [];
}
async function saveServerUrl() {
busy.value = true;
error.value = null;
try {
await sendMessage({
type: "settings_save",
patch: {
serverUrl: settings.value.serverUrl.replace(/\/+$/, ""),
panelUrl: settings.value.panelUrl ? settings.value.panelUrl.replace(/\/+$/, "") : null,
},
});
status.value = "Server URL saved";
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
} finally {
busy.value = false;
}
}
async function saveDefaultProject() {
await sendMessage({ type: "settings_save", patch: { defaultProjectId: settings.value.defaultProjectId } });
status.value = "Default project saved";
}
/** Toggled in place — persists immediately, no save button. */
async function saveToggle(name: "hoverReplay" | "videoCapture" | "consoleCapture", value: boolean) {
settings.value = { ...settings.value, [name]: value };
try {
await sendMessage({ type: "settings_save", patch: { [name]: value } });
status.value = "Setting saved";
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
}
}
onMounted(load);
</script>
<template>
<div class="options">
<header class="options-head">
<img class="options-logo" :src="logoUrl" alt="" />
<div>
<h1 class="options-title">BugTrail</h1>
<p class="options-tagline">Capture element notes and recordings into your projects</p>
</div>
</header>
<GnCard class="options-card">
<h2 class="options-card-title">Server</h2>
<GnInput v-model="settings.serverUrl" label="Server URL" icon="ph-plugs" type="url" />
<GnInput
v-model="panelUrl"
label="Web panel URL (optional, defaults to the server URL)"
icon="ph-monitor"
type="url"
/>
<GnCheckbox
:model-value="settings.videoCapture"
label="Record screen video around the cursor while recording (uses the debugger API — Chrome shows an infobar)"
@update:model-value="saveToggle('videoCapture', $event)"
/>
<GnCheckbox
:model-value="settings.consoleCapture"
label="Attach console errors and warnings to recordings"
@update:model-value="saveToggle('consoleCapture', $event)"
/>
<GnCheckbox
:model-value="settings.hoverReplay"
label="Replay CSS :hover during replays (uses the debugger API — Chrome shows an infobar)"
@update:model-value="saveToggle('hoverReplay', $event)"
/>
<div class="options-actions">
<GnButton variant="secondary" size="sm" icon="ph-floppy-disk" :disabled="busy" @click="saveServerUrl">
Save server URL
</GnButton>
</div>
</GnCard>
<GnCard class="options-card">
<h2 class="options-card-title">Account</h2>
<template v-if="settings.user">
<div class="options-profile">
<GnAvatar :name="settings.user.nickname" />
<div class="options-profile-info">
<span class="options-profile-name">{{ settings.user.nickname }}</span>
<span class="options-profile-email">{{ settings.user.email }}</span>
</div>
<GnButton variant="secondary" size="sm" icon="ph-sign-out" @click="signOut">Sign out</GnButton>
</div>
</template>
<template v-else>
<GnInput v-model="email" label="Email" icon="ph-envelope-simple" type="email" autocomplete="username" />
<GnInput
v-model="password"
label="Password"
icon="ph-lock-key"
type="password"
autocomplete="current-password"
@keyup.enter="signIn"
/>
<div class="options-actions">
<GnButton
variant="accent"
icon="ph-sign-in"
:loading="busy"
:disabled="busy || !email || !password"
@click="signIn"
>
Sign in
</GnButton>
</div>
</template>
</GnCard>
<GnCard v-if="settings.token" class="options-card">
<h2 class="options-card-title">Default project</h2>
<p class="options-hint">Reports captured with the extension are filed into this project.</p>
<GnSelect v-model="defaultProjectId" :options="projectOptions" icon="ph-folders" />
<div class="options-actions">
<GnButton variant="secondary" size="sm" icon="ph-arrows-clockwise" :disabled="busy" @click="loadProjects">
Refresh projects
</GnButton>
</div>
</GnCard>
<p v-if="status" class="options-status">
<i class="ph ph-check-circle" /> {{ status }}
</p>
<p v-if="error" class="options-error">
<i class="ph ph-warning-octagon" /> {{ error }}
</p>
</div>
</template>
<style scoped>
.options {
max-width: 560px;
margin: 0 auto;
padding: 40px 24px 64px;
display: flex;
flex-direction: column;
gap: 20px;
}
.options-head {
display: flex;
align-items: center;
gap: 14px;
}
.options-logo {
width: 40px;
height: 40px;
border-radius: 10px;
}
.options-title {
margin: 0;
font-size: 16px;
text-transform: uppercase;
letter-spacing: 0.1em;
}
.options-tagline {
margin: 2px 0 0;
font-size: 12px;
opacity: 0.65;
}
.options-card {
width: 100%;
max-width: none;
padding: 20px;
display: flex;
flex-direction: column;
gap: 14px;
}
.options-card-title {
margin: 0;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.1em;
opacity: 0.6;
}
.options-actions {
display: flex;
justify-content: flex-end;
}
.options-profile {
display: flex;
align-items: center;
gap: 12px;
}
.options-profile-info {
display: flex;
flex-direction: column;
margin-right: auto;
min-width: 0;
}
.options-profile-name {
font-weight: 600;
}
.options-profile-email {
font-size: 12px;
opacity: 0.7;
overflow-wrap: anywhere;
}
.options-hint {
margin: 0;
font-size: 12px;
opacity: 0.6;
}
.options-status,
.options-error {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
font-size: 13px;
}
.options-status {
color: var(--ltt-success, #9ece6a);
}
.options-error {
color: var(--ltt-danger, #f7768e);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
</style>