<script setup lang="ts">
import { computed, 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;
/** captured by the region selector before the composer opens */
screenshotDataUrl: string | null;
}>();
const emit = defineEmits<{
(e: "close"): void;
(e: "submitted", reportToken: string): void;
}>();
const screenshot = computed(() => props.screenshotDataUrl);
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";
}
});
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 fit="contain" />
</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(920px, 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 {
border: 2px solid var(--color-border, #2f334d);
}
/* big drawing area: the whole screenshot visible at once, canvas contain-fit */
.composer-screenshot :deep(.annotation-canvas-wrap) {
height: 55vh;
}
.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>