<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import {
GnBadge,
GnButton,
GnCard,
GnConfirmDialog,
GnCopyButton,
GnEmptyState,
GnPageHeader,
GnTabs,
GnTextarea,
GnTimeline,
useToast,
} from "gnexus-ui-kit/vue";
import type { AnnotationShape, ReportDetail } from "@ltt/shared";
import * as api from "../api";
import { useAuth } from "../stores/auth";
import { ScreenshotViewer, ElementContext, EnvironmentInfo } from "@ltt/ui";
import CompactSelect from "../components/CompactSelect.vue";
const { t } = useI18n();
const toast = useToast();
const route = useRoute();
const router = useRouter();
const auth = useAuth();
const token = computed(() => String(route.params.token));
const report = ref<ReportDetail | null>(null);
const loading = ref(true);
const notFound = ref(false);
const savingStatus = ref(false);
const tab = ref("details");
const tabs = computed(() => {
const items = [{ id: "details", label: t("report.details") }];
if (report.value?.steps?.length) items.push({ id: "steps", label: t("report.steps") });
if (report.value?.attachments?.length) items.push({ id: "attachments", label: t("report.attachments") });
return items;
});
const editingDescription = ref(false);
const descriptionDraft = ref("");
const deleteOpen = ref(false);
const screenshot = computed(() => report.value?.attachments.find((a) => a.kind === "screenshot" || a.kind === "annotation") ?? null);
const otherAttachments = computed(() => report.value?.attachments.filter((a) => a !== screenshot.value) ?? []);
/** The share token doubles as authorization for the file download URL — no cookies needed. */
function fileUrl(fileId: string) {
return api.reportFileUrl(token.value, fileId);
}
const canEdit = computed(() => {
if (!report.value || !auth.user.value) return false;
return report.value.author.id === auth.user.value.id;
});
onMounted(async () => {
try {
report.value = await api.getReport(token.value);
} catch (e) {
if (e instanceof Error && e.message === "not-found") notFound.value = true;
else toast.error({ title: t("common.error") });
} finally {
loading.value = false;
}
});
async function setStatus(status: string) {
if (!report.value) return;
savingStatus.value = true;
try {
report.value = await api.updateReport(token.value, { status: status as ReportDetail["status"] });
toast.success({ title: t("report.status"), text: t(`reports.status.${status}`) });
} catch {
toast.error({ title: t("common.error") });
} finally {
savingStatus.value = false;
}
}
function startEditDescription() {
descriptionDraft.value = report.value?.description ?? "";
editingDescription.value = true;
}
async function saveDescription() {
if (!report.value) return;
try {
report.value = await api.updateReport(token.value, { description: descriptionDraft.value || null });
editingDescription.value = false;
toast.success({ title: t("common.save") });
} catch {
toast.error({ title: t("common.error") });
}
}
async function saveAnnotations(shapes: AnnotationShape[]) {
if (!report.value || !screenshot.value) return;
try {
// vector shapes persist on the attachment; the editor renders them over the original PNG
await api.updateAttachmentShapes(token.value, screenshot.value.id, shapes);
screenshot.value.annotation_shapes = shapes;
toast.success({ title: t("report.saveAnnotations") });
} catch {
toast.error({ title: t("common.error") });
}
}
async function deleteReport() {
if (!report.value) return;
try {
await api.deleteReport(token.value);
toast.success({ title: t("report.deleteReport") });
router.push("/");
} catch {
toast.error({ title: t("common.error") });
} finally {
deleteOpen.value = false;
}
}
function statusVariant(status: string) {
return status === "open" ? "warning" : status === "fixed" ? "success" : "secondary";
}
function stepTitle(type: string) {
return t(`report.stepTypes.${type}`);
}
function stepText(step: ReportDetail["steps"][number]) {
const data = step.data ?? {};
const parts: string[] = [];
const element = data.element as Record<string, unknown> | undefined;
if (element && typeof element === "object") {
const tag = element.tag as string | undefined;
const text = element.text_snippet as string | undefined;
if (tag) parts.push(`<${tag}>`);
if (text) parts.push(`"${text}"`);
const selector = element.unique_selector ?? element.selector;
if (typeof selector === "string") parts.push(selector);
}
if (typeof data.value === "string" && data.value) parts.push(`"${data.value}"`);
if (typeof data.value_length === "number") parts.push(`(${data.value_length} chars)`);
if (typeof data.from_url === "string") parts.push(String(data.from_url));
if (typeof data.to_url === "string") parts.push(`→ ${data.to_url}`);
if (typeof data.text === "string") parts.push(data.text);
return parts.join(" ") || "—";
}
function stepTime(offsetMs: number) {
return `${(offsetMs / 1000).toFixed(1)}s`;
}
function fmtDate(iso: string) {
return new Date(iso).toLocaleString();
}
</script>
<template>
<div class="report-page">
<div v-if="notFound" class="report-page-notfound">
<GnEmptyState icon="ph-link-break" :title="t('reports.notFound')" :text="t('reports.notFoundHint')" />
</div>
<template v-else-if="report">
<div class="report-page-nav">
<GnButton variant="secondary" size="sm" icon="ph-arrow-left" @click="router.back()">
{{ t("report.backToProject") }}
</GnButton>
</div>
<GnPageHeader :title="report.title" :subtitle="report.page_title ?? undefined">
<template #actions>
<GnCopyButton :text="api.reportShareUrl(report)" :label="t('report.copyLink')" />
<GnButton
v-if="canEdit"
variant="danger"
size="sm"
icon="ph-trash"
@click="deleteOpen = true"
>
{{ t("report.deleteReport") }}
</GnButton>
</template>
</GnPageHeader>
<div class="report-meta">
<GnBadge variant="accent" outline>{{ t(`reports.type.${report.type}`) }}</GnBadge>
<GnBadge :variant="statusVariant(report.status)">{{ t(`reports.status.${report.status}`) }}</GnBadge>
<span class="report-meta-item">{{ t("report.author") }}: {{ report.author.nickname }}</span>
<span class="report-meta-item">{{ t("report.created") }}: {{ fmtDate(report.created_at) }}</span>
</div>
<GnTabs v-model="tab" :items="tabs">
<template #details>
<div class="report-details-grid">
<div class="report-details-main">
<GnCard class="report-card">
<h3 class="report-card-title">{{ t("report.description") }}</h3>
<template v-if="editingDescription">
<GnTextarea v-model="descriptionDraft" :rows="5" />
<div class="report-card-actions">
<GnButton variant="secondary" size="sm" @click="editingDescription = false">
{{ t("common.cancel") }}
</GnButton>
<GnButton variant="accent" size="sm" @click="saveDescription">
{{ t("common.save") }}
</GnButton>
</div>
</template>
<template v-else>
<p class="report-description" :class="{ 'is-empty': !report.description }">
{{ report.description ?? t("report.noDescription") }}
</p>
<GnButton
v-if="canEdit"
variant="secondary"
size="sm"
icon="ph-pencil-simple"
@click="startEditDescription"
>
{{ t("common.edit") }}
</GnButton>
</template>
</GnCard>
<GnCard v-if="screenshot" class="report-card">
<h3 class="report-card-title">{{ t("report.screenshot") }}</h3>
<ScreenshotViewer
:src="fileUrl(screenshot.file_id)"
:shapes="screenshot.annotation_shapes"
:editable="canEdit"
:labels="{
edit: t('report.editAnnotations'),
save: t('report.saveAnnotations'),
saving: t('report.savingAnnotations'),
cancel: t('common.cancel'),
}"
@save="saveAnnotations"
/>
</GnCard>
<GnCard v-if="report.element" class="report-card">
<h3 class="report-card-title">{{ t("report.elementContext") }}</h3>
<ElementContext :element="report.element" />
</GnCard>
</div>
<div class="report-details-side">
<GnCard class="report-card">
<h3 class="report-card-title">{{ t("report.status") }}</h3>
<CompactSelect
v-if="canEdit"
:model-value="report.status"
:options="[
{ value: 'open', label: t('reports.status.open') },
{ value: 'fixed', label: t('reports.status.fixed') },
{ value: 'wont_fix', label: t('reports.status.wont_fix') },
]"
:width="180"
:disabled="savingStatus"
@update:model-value="setStatus($event)"
/>
<GnBadge v-else :variant="statusVariant(report.status)">
{{ t(`reports.status.${report.status}`) }}
</GnBadge>
</GnCard>
<GnCard v-if="report.page_url" class="report-card">
<h3 class="report-card-title">{{ t("report.page") }}</h3>
<a :href="report.page_url" target="_blank" rel="noopener" class="report-page-url">
{{ report.page_url }}
</a>
</GnCard>
<GnCard class="report-card">
<h3 class="report-card-title">{{ t("report.environment") }}</h3>
<EnvironmentInfo :environment="report.environment" />
</GnCard>
</div>
</div>
</template>
<template v-if="report.steps?.length" #steps>
<GnCard class="report-card">
<GnTimeline
:items="
report.steps.map((step) => ({
key: step.id,
title: `#${step.step_index + 1} ${stepTitle(step.type)}`,
time: stepTime(step.offset_ms),
text: stepText(step),
icon:
step.type === 'click' ? 'ph-cursor-click'
: step.type === 'input' ? 'ph-keyboard'
: step.type === 'url_change' ? 'ph-arrows-left-right'
: step.type === 'note' ? 'ph-note-pencil'
: step.type === 'screenshot' ? 'ph-camera'
: 'ph-navigation-arrow',
}))
"
>
<template #meta="{ item }">
<img
v-if="report!.steps.find((s) => s.id === item.key)?.screenshot_attachment_id"
:src="fileUrl(report!.steps.find((s) => s.id === item.key)!.screenshot_attachment_id!)"
class="step-screenshot"
alt=""
/>
</template>
</GnTimeline>
</GnCard>
</template>
<template v-if="report.attachments?.length" #attachments>
<GnCard class="report-card">
<div class="attachment-list">
<a
v-for="attachment in report.attachments"
:key="attachment.id"
class="attachment-item"
:href="fileUrl(attachment.file_id)"
target="_blank"
rel="noopener"
>
<i
class="ph"
:class="attachment.mime.startsWith('image/') ? 'ph-image' : 'ph-file-text'"
/>
<span class="attachment-name">{{ attachment.filename }}</span>
<span class="attachment-size">{{ (attachment.size / 1024).toFixed(1) }} KB</span>
</a>
</div>
</GnCard>
</template>
</GnTabs>
<GnConfirmDialog
v-model:open="deleteOpen"
:title="t('report.deleteReport')"
:message="t('report.deleteConfirm')"
:confirm-text="t('report.deleteReport')"
confirm-variant="danger"
@confirm="deleteReport"
/>
</template>
<p v-else class="report-page-loading">{{ t("common.loading") }}</p>
</div>
</template>
<style scoped>
.report-page {
max-width: 1100px;
margin: 0 auto;
padding: 20px 24px 60px;
display: flex;
flex-direction: column;
gap: 16px;
}
.report-page-nav {
display: flex;
}
.report-meta {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.report-meta-item {
font-size: 12px;
opacity: 0.7;
}
.report-details-grid {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
gap: 16px;
align-items: start;
}
@media (max-width: 800px) {
.report-details-grid {
grid-template-columns: 1fr;
}
}
.report-details-main,
.report-details-side {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
}
.report-card {
/* kit .card is width:max-content/max-width:340px — report cards fill the grid */
width: 100%;
max-width: none;
padding: 18px;
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
.report-card-title {
margin: 0;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.1em;
opacity: 0.6;
}
.report-description {
margin: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.report-description.is-empty {
opacity: 0.5;
}
.report-card-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.report-page-url {
overflow-wrap: anywhere;
font-size: 13px;
}
.step-screenshot {
max-width: 240px;
border: 1px solid var(--color-border, #2f334d);
margin-top: 6px;
}
.attachment-list {
display: flex;
flex-direction: column;
}
.attachment-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 4px;
border-bottom: 1px solid var(--color-border, #2f334d);
color: inherit;
text-decoration: none;
font-size: 13px;
}
.attachment-item:last-child {
border-bottom: none;
}
.attachment-item:hover {
color: var(--color-accent, #7aa2f7);
}
.attachment-name {
flex: 1;
overflow-wrap: anywhere;
}
.attachment-size {
opacity: 0.5;
font-size: 11px;
}
.report-page-loading {
opacity: 0.6;
}
.report-page-notfound {
padding-top: 15vh;
}
</style>