Newer
Older
bugtrail / packages / extension / src / content / overlay / NoteComposer.vue
<script setup lang="ts">
import { onMounted, ref } from "vue";
import browser from "webextension-polyfill";
import type { ElementContext } from "@ltt/shared";
import { AnnotationEditor } from "@ltt/ui";
import type { AnnotationShape } from "@ltt/shared";
import { collectEnvironment } from "../../lib/selector";
import type { SubmitNotePayload } from "../../lib/messages";

const props = defineProps<{ element: ElementContext }>();

const emit = defineEmits<{
  (e: "close"): void;
  (e: "submitted", reportToken: string): void;
}>();

const screenshot = ref<string | null>(null);
const shapes = ref<AnnotationShape[]>([]);
const title = ref(`Note on ${props.element.tag ?? "element"}`);
const comment = ref("");
const linksRaw = ref("");
const files = ref<File[]>([]);
const submitting = ref(false);
const error = ref<string | null>(null);
const titleInput = ref<HTMLInputElement>();
/** null = checked and fine; string = what the user must fix in settings first */
const settingsProblem = ref<string | null>(null);

onMounted(() => {
  titleInput.value?.focus();
  // the prefill is a starting point — select it so typing replaces it
  titleInput.value?.select();
});

onMounted(async () => {
  const settings = (await browser.runtime.sendMessage({ type: "settings_get" })) as {
    ok: boolean;
    data?: { token: string | null; defaultProjectId: string | null };
  };
  if (!settings?.data?.token) {
    settingsProblem.value = "Not signed in — open the extension settings to log in";
  } else if (!settings.data.defaultProjectId) {
    settingsProblem.value = "No default project selected — open the extension settings";
  }
});

onMounted(async () => {
  const response = (await browser.runtime.sendMessage({ type: "capture" })) as {
    ok: boolean;
    data?: { dataUrl: string };
    error?: string;
  };
  if (response?.ok && response.data) {
    screenshot.value = response.data.dataUrl;
  } else {
    error.value = response?.error ?? "Screenshot capture failed";
  }
});

function onFilesSelected(event: Event) {
  const input = event.target as HTMLInputElement;
  files.value = Array.from(input.files ?? []);
}

function readAsDataUrl(file: File): Promise<string> {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => resolve(String(reader.result));
    reader.onerror = () => reject(reader.error);
    reader.readAsDataURL(file);
  });
}

function parseLinks(): string[] {
  return linksRaw.value
    .split(/\n|,/)
    .map((s) => s.trim())
    .filter(Boolean);
}

async function submit() {
  if (!screenshot.value) {
    error.value = "No screenshot — try again";
    return;
  }
  submitting.value = true;
  error.value = null;
  try {
    const payload: SubmitNotePayload = {
      element: props.element,
      screenshotDataUrl: screenshot.value,
      annotationShapes: shapes.value.length ? shapes.value : null,
      comment: comment.value,
      title: title.value,
      pageUrl: location.href,
      pageTitle: document.title,
      environment: collectEnvironment() as unknown as Record<string, unknown>,
      files: await Promise.all(
        files.value.map(async (file) => ({
          filename: file.name,
          mime: file.type || "application/octet-stream",
          dataUrl: await readAsDataUrl(file),
        }))
      ),
      links: parseLinks(),
    };
    const response = (await browser.runtime.sendMessage({ type: "submit_note", payload })) as {
      ok: boolean;
      data?: { report_token: string };
      error?: string;
    };
    if (!response?.ok) throw new Error(response?.error ?? "Submit failed");
    emit("submitted", response.data!.report_token);
  } catch (err) {
    error.value = err instanceof Error ? err.message : String(err);
  } finally {
    submitting.value = false;
  }
}

async function openSettings() {
  await browser.runtime.sendMessage({ type: "open_options" }).catch(() => {});
}

function onKeyDown(event: KeyboardEvent) {
  if (event.key === "Escape") {
    event.preventDefault();
    event.stopPropagation();
    emit("close");
    return;
  }
  // Ctrl/Cmd+Enter submits from any field
  if (event.key === "Enter" && (event.ctrlKey || event.metaKey) && !submitting.value) {
    event.preventDefault();
    event.stopPropagation();
    void submit();
  }
}
</script>

<template>
  <div class="composer-backdrop" @keydown="onKeyDown">
    <div class="composer">
      <div class="composer-header">
        <span class="composer-title">New element note</span>
        <button type="button" class="composer-close" title="Close (Esc)" @click="emit('close')">
          <i class="ph ph-x" />
        </button>
      </div>

      <div v-if="settingsProblem" class="composer-warn">
        <i class="ph ph-warning" />
        <span>{{ settingsProblem }}</span>
        <button type="button" class="composer-btn composer-btn-warn" @click="openSettings">
          Open settings
        </button>
      </div>

      <div v-if="screenshot" class="composer-screenshot">
        <AnnotationEditor :src="screenshot" v-model:shapes="shapes" editable />
      </div>
      <div v-else class="composer-placeholder">Capturing screenshot…</div>

      <label class="composer-field">
        <span>Title</span>
        <input ref="titleInput" v-model="title" type="text" class="composer-input" />
      </label>

      <label class="composer-field">
        <span>Comment</span>
        <textarea v-model="comment" rows="4" class="composer-input" placeholder="What is wrong here?" />
      </label>

      <label class="composer-field">
        <span>Links (one per line)</span>
        <textarea v-model="linksRaw" rows="2" class="composer-input" placeholder="https://…" />
      </label>

      <label class="composer-field">
        <span>Attachments</span>
        <input type="file" multiple class="composer-input" @change="onFilesSelected" />
      </label>

      <div v-if="error" class="composer-error">{{ error }}</div>

      <div class="composer-actions">
        <button type="button" class="composer-btn composer-btn-ghost" :disabled="submitting" @click="emit('close')">
          Cancel
        </button>
        <button type="button" class="composer-btn" :disabled="submitting || !screenshot" @click="submit">
          {{ submitting ? "Submitting…" : "Submit report" }}
        </button>
      </div>
    </div>
  </div>
</template>

<style scoped>
.composer-backdrop {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(10, 12, 20, 0.6);
}
.composer {
  display: flex;
  flex-direction: column;
  gap: 10px;
  width: min(560px, calc(100vw - 48px));
  max-height: calc(100vh - 48px);
  overflow-y: auto;
  padding: 16px;
  background: var(--color-surface, #1a1c2b);
  border: 2px solid var(--color-border, #2f334d);
  color: var(--color-text, #c0caf5);
}
.composer-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
}
.composer-title {
  font-size: 13px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.08em;
}
.composer-close {
  border: none;
  background: transparent;
  color: inherit;
  cursor: pointer;
  font-size: 16px;
}
.composer-close:hover {
  color: var(--color-danger, #f7768e);
}
.composer-screenshot {
  max-height: 320px;
  overflow: hidden;
  border: 2px solid var(--color-border, #2f334d);
}
.composer-screenshot :deep(.annotation-canvas-wrap) {
  max-height: 300px;
  overflow-y: auto;
}
.composer-placeholder {
  padding: 40px;
  text-align: center;
  border: 2px dashed var(--color-border, #2f334d);
  opacity: 0.7;
}
.composer-field {
  display: flex;
  flex-direction: column;
  gap: 4px;
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.05em;
}
.composer-input {
  padding: 6px 8px;
  border: 2px solid var(--color-border, #2f334d);
  background: var(--color-bg, #10121c);
  color: var(--color-text, #c0caf5);
  font: inherit;
  font-size: 13px;
  text-transform: none;
  letter-spacing: normal;
}
.composer-input:focus {
  outline: none;
  border-color: var(--color-accent, #7aa2f7);
}
.composer-error {
  padding: 6px 8px;
  border: 2px solid var(--color-danger, #f7768e);
  color: var(--color-danger, #f7768e);
  font-size: 12px;
}
.composer-warn {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 10px;
  border: 2px solid var(--color-warning, #e0af68);
  color: var(--color-warning, #e0af68);
  font-size: 12px;
  text-transform: none;
  letter-spacing: normal;
}
.composer-btn-warn {
  margin-left: auto;
  background: transparent;
  color: inherit;
  border-color: var(--color-warning, #e0af68);
}
.composer-actions {
  display: flex;
  justify-content: flex-end;
  gap: 8px;
}
.composer-btn {
  padding: 6px 14px;
  border: 2px solid var(--color-accent, #7aa2f7);
  background: var(--color-accent, #7aa2f7);
  color: var(--color-bg, #10121c);
  font: inherit;
  font-size: 12px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  cursor: pointer;
}
.composer-btn:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}
.composer-btn-ghost {
  background: transparent;
  color: inherit;
  border-color: var(--color-border, #2f334d);
}
</style>