Newer
Older
bugtrail / packages / web / src / pages / ReportPage.vue
<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,
  GnIconButton,
  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 AppShell from "../components/AppShell.vue";
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>
  <AppShell>
    <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>
                    <GnIconButton
                      v-if="canEdit"
                      icon="ph-pencil-simple"
                      :label="t('common.edit')"
                      size="sm"
                      @click="startEditDescription"
                    />
                  </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'),
                      expand: t('report.openFullscreen'),
                    }"
                    @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="'100%'"
                    :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-mouse-simple'
                      : 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-grid">
                <a
                  v-for="attachment in report.attachments"
                  :key="attachment.id"
                  class="attachment-item"
                  :href="fileUrl(attachment.file_id)"
                  target="_blank"
                  rel="noopener"
                >
                  <span class="attachment-thumb">
                    <img
                      v-if="attachment.mime.startsWith('image/')"
                      :src="fileUrl(attachment.file_id)"
                      :alt="attachment.filename"
                      loading="lazy"
                    />
                    <i v-else class="ph ph-file-text" />
                  </span>
                  <span class="attachment-info">
                    <span class="attachment-name">{{ attachment.filename }}</span>
                    <span class="attachment-size">{{ (attachment.size / 1024).toFixed(1) }} KB</span>
                  </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>
  </AppShell>
</template>

<style scoped>
.report-page {
  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;
     the kit also wraps content in .card-content (padding:15px), so the padding
     lives there and the card itself adds none */
  width: 100%;
  max-width: none;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 12px;
  min-width: 0;
}
.report-card :deep(.card-content) {
  padding: 18px;
}
.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;
}
/* kit .tabs caps itself at 900px and is a flex row — its .tabs-panels wrapper
   shrink-wraps the content; the report layout spans the whole shell instead */
:deep(.tabs) {
  max-width: none;
}
:deep(.tabs-panels) {
  width: 100%;
}
.attachment-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
  gap: 12px;
}
.attachment-item {
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 10px;
  border: 1px solid var(--ltt-border, rgba(192, 202, 245, 0.24));
  color: inherit;
  text-decoration: none;
  font-size: 13px;
  transition: border-color 0.15s ease;
}
.attachment-item:hover {
  border-color: var(--ltt-accent, #7aa2f7);
}
.attachment-thumb {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 150px;
  overflow: hidden;
  background: var(--ltt-surface-sunken, #10121c);
}
.attachment-thumb img {
  width: 100%;
  height: 100%;
  object-fit: contain;
}
.attachment-thumb .ph {
  font-size: 40px;
  opacity: 0.4;
}
.attachment-info {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
  min-width: 0;
}
.attachment-name {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.attachment-size {
  opacity: 0.5;
  font-size: 11px;
  flex-shrink: 0;
}
.report-page-loading {
  opacity: 0.6;
}
.report-page-notfound {
  padding-top: 15vh;
}
</style>