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 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);
    const list = await api.listProjectReports(project.value.id, {
      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>
  <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>
</template>

<style scoped>
.project-page {
  max-width: 1100px;
  margin: 0 auto;
  padding: 20px 24px 60px;
  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>