<script setup lang="ts">
import { onMounted, ref } from "vue";
import browser from "webextension-polyfill";
import { GnButton } from "gnexus-ui-kit/vue";
interface LatestReport {
title: string;
type: string;
status: string;
created_at: string;
share_token: string;
}
interface SettingsView {
serverUrl: string;
panelUrl: string | null;
token: string | null;
user: { nickname: string } | null;
defaultProjectId: string | null;
}
interface RecorderStateView {
recording: boolean;
stepCount: number;
}
const settings = ref<SettingsView | null>(null);
const latest = ref<LatestReport | null>(null);
const projectToken = ref<string | null>(null);
const recorder = ref<RecorderStateView>({ recording: false, stepCount: 0 });
const busy = ref(false);
const error = ref<string | null>(null);
/** two-step delete: first click arms the button, second deletes */
const confirmingDelete = ref(false);
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() {
error.value = null;
try {
settings.value = await sendMessage<SettingsView>({ type: "settings_get" });
if (!settings.value.token || !settings.value.defaultProjectId) return;
const data = await sendMessage<{ report: LatestReport | null; project_token: string | null }>({
type: "latest_report",
});
latest.value = data?.report ?? null;
projectToken.value = data?.project_token ?? null;
recorder.value = await sendMessage<RecorderStateView>({ type: "recorder_state_request" });
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
}
}
async function startNote() {
await sendMessage({ type: "start_picker" });
window.close();
}
async function toggleRecording() {
busy.value = true;
try {
recorder.value = await sendMessage<RecorderStateView>({ type: "toggle_recorder" });
window.close();
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
} finally {
busy.value = false;
}
}
function reportUrl(report: LatestReport): string {
return `${panelBase()}/r/${report.share_token}`;
}
function panelHome(): string {
return projectToken.value ? `${panelBase()}/p/${projectToken.value}` : panelBase();
}
function panelBase(): string {
return (settings.value?.panelUrl ?? settings.value?.serverUrl ?? "").replace(/\/+$/, "");
}
async function open(url: string) {
await browser.tabs.create({ url });
window.close();
}
async function openOptions() {
await browser.runtime.sendMessage({ type: "open_options" });
window.close();
}
async function replayLatest() {
if (!latest.value) return;
busy.value = true;
try {
await sendMessage({ type: "replay_report", token: latest.value.share_token });
window.close();
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
} finally {
busy.value = false;
}
}
async function deleteLatest() {
if (!latest.value) return;
if (!confirmingDelete.value) {
confirmingDelete.value = true;
return;
}
busy.value = true;
try {
await sendMessage({ type: "delete_report", token: latest.value.share_token });
confirmingDelete.value = false;
await load();
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
} finally {
busy.value = false;
}
}
const statusColor: Record<string, string> = {
open: "var(--ltt-warning, #e0af68)",
fixed: "var(--ltt-success, #9ece6a)",
wont_fix: "var(--ltt-text-dim, #787c99)",
};
onMounted(load);
</script>
<template>
<div class="popup">
<header class="popup-head">
<i class="ph ph-bug popup-logo" />
<span class="popup-title">Live Testing Tool</span>
<button type="button" class="popup-gear" title="Settings" @click="openOptions">
<i class="ph ph-gear" />
</button>
</header>
<!-- signed out -->
<div v-if="settings && !settings.token" class="popup-signin">
<i class="ph ph-sign-in" />
<span>Sign in to capture reports</span>
<GnButton variant="accent" size="sm" icon="ph-sign-in" @click="openOptions">Open settings</GnButton>
</div>
<template v-else>
<!-- mode choice -->
<div class="popup-actions">
<button type="button" class="popup-action" :disabled="busy" @click="startNote">
<i class="ph ph-crosshair" />
<span class="popup-action-label">Element note</span>
<span class="popup-action-hint">Pick an element, add a screenshot</span>
</button>
<button type="button" class="popup-action" :class="{ 'is-recording': recorder.recording }" :disabled="busy" @click="toggleRecording">
<i class="ph" :class="recorder.recording ? 'ph-stop' : 'ph-record'" />
<span class="popup-action-label">{{ recorder.recording ? `Stop recording (${recorder.stepCount})` : "Record steps" }}</span>
<span class="popup-action-hint">{{ recorder.recording ? "Finish and submit the track" : "Capture clicks and input as steps" }}</span>
</button>
</div>
<!-- latest report -->
<div class="popup-section">
<span class="popup-section-title">Last report</span>
<div v-if="latest" class="popup-report">
<a class="popup-report-title" :href="reportUrl(latest)" @click.prevent="open(reportUrl(latest))">{{ latest.title }}</a>
<div class="popup-report-meta">
<span class="popup-report-type">
<i class="ph" :class="latest.type === 'recording' ? 'ph-film-strip' : 'ph-crosshair'" />
{{ latest.type === "recording" ? "recording" : "note" }}
</span>
<span class="popup-report-status" :style="{ color: statusColor[latest.status] }">{{ latest.status }}</span>
<span class="popup-report-date">{{ new Date(latest.created_at).toLocaleDateString() }}</span>
</div>
<div class="popup-report-actions">
<button type="button" class="popup-mini" title="Open in web panel" @click="open(reportUrl(latest))">
<i class="ph ph-arrow-square-out" /> Open
</button>
<button
v-if="latest.type === 'recording'"
type="button"
class="popup-mini"
title="Replay the recorded steps in a new tab"
@click="replayLatest"
>
<i class="ph ph-play" /> Replay
</button>
<button
type="button"
class="popup-mini popup-mini-danger"
:class="{ 'is-armed': confirmingDelete }"
:title="confirmingDelete ? 'Click again to delete' : 'Delete report'"
@click="deleteLatest"
>
<i class="ph" :class="confirmingDelete ? 'ph-warning' : 'ph-trash'" />
{{ confirmingDelete ? "Delete?" : "Delete" }}
</button>
</div>
</div>
<div v-else class="popup-empty">No reports yet in this project</div>
</div>
<!-- web panel -->
<button type="button" class="popup-panel" @click="open(panelHome())">
<i class="ph ph-monitor" /> Open web panel
<i class="ph ph-arrow-up-right popup-panel-arrow" />
</button>
</template>
<p v-if="error" class="popup-error">
<i class="ph ph-warning-octagon" /> {{ error }}
</p>
</div>
</template>
<style scoped>
.popup {
width: 340px;
padding: 14px;
display: flex;
flex-direction: column;
gap: 12px;
background: var(--ltt-bg, #10121c);
color: var(--ltt-text, #c0caf5);
}
.popup-head {
display: flex;
align-items: center;
gap: 8px;
}
.popup-logo {
font-size: 20px;
color: var(--ltt-accent, #7aa2f7);
}
.popup-title {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
margin-right: auto;
}
.popup-gear {
border: none;
background: transparent;
color: inherit;
opacity: 0.6;
cursor: pointer;
font-size: 16px;
padding: 4px;
}
.popup-gear:hover {
opacity: 1;
color: var(--ltt-accent, #7aa2f7);
}
.popup-signin {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
opacity: 0.85;
}
.popup-actions {
display: flex;
flex-direction: column;
gap: 8px;
}
.popup-action {
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: auto auto;
column-gap: 10px;
align-items: center;
text-align: left;
padding: 10px 12px;
border: 2px solid var(--ltt-border, #2f334d);
background: var(--ltt-surface, #1f2335);
color: inherit;
font: inherit;
cursor: pointer;
}
.popup-action:hover {
border-color: var(--ltt-accent, #7aa2f7);
}
.popup-action > i {
grid-row: span 2;
font-size: 20px;
color: var(--ltt-accent, #7aa2f7);
}
.popup-action-label {
font-size: 13px;
font-weight: 700;
}
.popup-action-hint {
font-size: 11px;
opacity: 0.6;
}
.popup-action.is-recording {
border-color: var(--ltt-danger, #f7768e);
}
.popup-action.is-recording > i {
color: var(--ltt-danger, #f7768e);
}
.popup-section {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px;
border: 2px solid var(--ltt-border, #2f334d);
background: var(--ltt-surface, #1f2335);
}
.popup-section-title {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.1em;
opacity: 0.55;
}
.popup-report {
display: flex;
flex-direction: column;
gap: 6px;
}
.popup-report-title {
color: inherit;
font-size: 13px;
font-weight: 600;
text-decoration: none;
overflow-wrap: anywhere;
}
.popup-report-title:hover {
color: var(--ltt-accent, #7aa2f7);
}
.popup-report-meta {
display: flex;
align-items: center;
gap: 10px;
font-size: 11px;
opacity: 0.75;
}
.popup-report-type i {
margin-right: 3px;
}
.popup-report-actions {
display: flex;
gap: 6px;
}
.popup-mini {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 8px;
border: 1px solid var(--ltt-border, #2f334d);
background: transparent;
color: inherit;
font: inherit;
font-size: 11px;
cursor: pointer;
}
.popup-mini:hover {
border-color: var(--ltt-accent, #7aa2f7);
color: var(--ltt-accent, #7aa2f7);
}
.popup-mini-danger:hover,
.popup-mini-danger.is-armed {
border-color: var(--ltt-danger, #f7768e);
color: var(--ltt-danger, #f7768e);
}
.popup-report-empty,
.popup-panel {
font-size: 12px;
}
.popup-panel {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border: 2px solid var(--ltt-border, #2f334d);
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
}
.popup-panel:hover {
border-color: var(--ltt-accent, #7aa2f7);
color: var(--ltt-accent, #7aa2f7);
}
.popup-panel-arrow {
margin-left: auto;
}
.popup-error {
display: flex;
align-items: center;
gap: 6px;
margin: 0;
color: var(--ltt-danger, #f7768e);
font-size: 12px;
overflow-wrap: anywhere;
}
</style>