Newer
Older
bugtrail / packages / web / src / pages / ProjectPage.vue
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { useI18n } from "vue-i18n";
import {
  GnBadge,
  GnButton,
  GnCopyButton,
  GnEmptyState,
  GnPageHeader,
  GnPagination,
  GnTable,
  GnToolbar,
  useToast,
} from "gnexus-ui-kit/vue";
import type { ProjectDetail, ReportListItem } from "@ltt/shared";
import * as api from "../api";
import AppShell from "../components/AppShell.vue";
import CompactSelect from "../components/CompactSelect.vue";

const { t } = useI18n();
const toast = useToast();

const route = useRoute();
const token = computed(() => String(route.params.token));

const project = ref<ProjectDetail | null>(null);
const reports = ref<ReportListItem[]>([]);
const total = ref(0);
const loading = ref(true);
const notFound = ref(false);

const page = ref(1);
const perPage = 20;
// "" = the "all" sentinel: GnSelect is a native <select>, and a null
// modelValue matches no option, which blanks the control
const filterType = ref("");
const filterStatus = ref("");

const typeOptions = [
  { value: "", label: t("reports.allTypes") },
  { value: "element_note", label: t("reports.type.element_note") },
  { value: "recording", label: t("reports.type.recording") },
];
const statusOptions = [
  { value: "", label: t("reports.allStatuses") },
  { value: "open", label: t("reports.status.open") },
  { value: "fixed", label: t("reports.status.fixed") },
  { value: "wont_fix", label: t("reports.status.wont_fix") },
];

const columns = [
  { key: "title", label: t("reports.columns.title") },
  { key: "type", label: t("reports.columns.type") },
  { key: "status", label: t("reports.columns.status") },
  { key: "author", label: t("reports.columns.author") },
  { key: "created", label: t("reports.columns.created") },
];

function fmtDate(iso: string) {
  return new Date(iso).toLocaleString();
}

async function load() {
  loading.value = true;
  notFound.value = false;
  try {
    project.value = await api.getProjectByToken(token.value);
    // token-addressed, not /projects/{id}/reports — the page must render for
    // visitors without a session (the owner endpoint answers 401 to them)
    const list = await api.listProjectReportsByToken(token.value, {
      limit: perPage,
      offset: (page.value - 1) * perPage,
      report_type: filterType.value || null,
      report_status: filterStatus.value || null,
    });
    reports.value = list.items;
    total.value = list.total;
  } catch (e) {
    if (e instanceof Error && e.message === "not-found") {
      notFound.value = true;
    } else {
      toast.error({ title: t("common.error") });
    }
  } finally {
    loading.value = false;
  }
}

watch([page, filterType, filterStatus], load);
watch(token, () => {
  page.value = 1;
  load();
});
onMounted(load);

function statusVariant(status: string) {
  return status === "open" ? "warning" : status === "fixed" ? "success" : "secondary";
}
</script>

<template>
  <AppShell>
    <div class="project-page">
      <div v-if="notFound" class="project-page-notfound">
        <GnEmptyState icon="ph-link-break" :title="t('reports.notFound')" :text="t('reports.notFoundHint')" />
      </div>

      <template v-else-if="project">
        <GnPageHeader :title="project.name" :subtitle="project.description ?? undefined">
          <template #actions>
            <GnCopyButton :text="api.projectShareUrl(project)" :label="t('projects.share')" />
          </template>
        </GnPageHeader>

        <GnToolbar
          :title="t('reports.title')"
          :meta="`${project.report_counts.total} total / ${project.report_counts.open} open`"
        >
          <template #actions>
            <div class="filter-selects">
              <CompactSelect
                v-model="filterType"
                :options="typeOptions"
                icon="ph-funnel"
                :width="190"
              />
              <CompactSelect
                v-model="filterStatus"
                :options="statusOptions"
                icon="ph-circle-half"
                :width="180"
              />
            </div>
          </template>
        </GnToolbar>

        <GnEmptyState
          v-if="!loading && reports.length === 0"
          icon="ph-bug"
          :title="t('reports.empty')"
          :text="t('reports.emptyHint')"
        />

        <GnTable
          v-else-if="reports.length"
          :columns="columns"
          :rows="reports"
        >
          <template #cell-title="{ row }">
            <RouterLink :to="`/r/${row.share_token}`" class="report-link">{{ row.title }}</RouterLink>
          </template>
          <template #cell-type="{ row }">
            <GnBadge variant="accent" outline>{{ t(`reports.type.${row.type}`) }}</GnBadge>
          </template>
          <template #cell-status="{ row }">
            <GnBadge :variant="statusVariant(row.status)">{{ t(`reports.status.${row.status}`) }}</GnBadge>
          </template>
          <template #cell-author="{ row }">{{ row.author.nickname }}</template>
          <template #cell-created="{ row }">{{ fmtDate(row.created_at) }}</template>
        </GnTable>

        <GnPagination
          v-if="total > perPage"
          :page="page"
          :total-pages="Math.ceil(total / perPage)"
          @update:page="page = $event"
        />
      </template>

      <p v-else class="project-page-loading">{{ t("common.loading") }}</p>
    </div>
  </AppShell>
</template>

<style scoped>
.project-page {
  display: flex;
  flex-direction: column;
  gap: 16px;
}
.filter-selects {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
}
.report-link {
  color: inherit;
  text-decoration: none;
  font-weight: 600;
}
.report-link:hover {
  color: var(--color-accent, #7aa2f7);
}
.project-page-loading {
  opacity: 0.6;
}
.project-page-notfound {
  padding-top: 15vh;
}
</style>