Newer
Older
bugtrail / packages / extension / src / options / Options.vue
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import browser from "webextension-polyfill";
import { GnAvatar, GnButton, GnCard, GnInput, GnSelect } from "gnexus-ui-kit/vue";

interface SettingsView {
  serverUrl: string;
  token: string | null;
  user: { id: string; nickname: string; email: string } | null;
  defaultProjectId: string | null;
}

interface ProjectView {
  id: string;
  name: string;
}

const settings = ref<SettingsView>({ serverUrl: "http://localhost:8001", token: null, user: null, defaultProjectId: null });
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 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(/\/+$/, "") } });
    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";
}

onMounted(load);
</script>

<template>
  <div class="options">
    <header class="options-head">
      <i class="ph ph-bug options-logo" />
      <div>
        <h1 class="options-title">Live Testing Tool</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" />
      <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 {
  font-size: 34px;
  color: var(--ltt-accent, #7aa2f7);
}
.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>