Newer
Older
bugtrail / packages / extension / src / popup / Popup.vue
<script setup lang="ts">
import { onMounted, ref } from "vue";
import browser from "webextension-polyfill";
import { GnButton } from "gnexus-ui-kit/vue";
import logoUrl from "../icons/logo.svg";

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">
      <img class="popup-logo" :src="logoUrl" alt="" />
      <span class="popup-title">BugTrail</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 {
  width: 20px;
  height: 20px;
  border-radius: 5px;
}
.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>