internal/dashboard/frontend/src/components/EventTable/EventTable.vue

<script setup lang="ts">
import { IconDownload } from "@tabler/icons-vue";
import { useWindowSize } from "@vueuse/core";
import EventTableFooter from "components/EventTable/EventTableFooter.vue";
import EventTableHeader from "components/EventTable/EventTableHeader.vue";
import EventTableRow from "components/EventTable/EventTableRow.vue";
import LoaderOverlay from "components/LoaderOverlay.vue";
import { buildQueryStringFromState, type FilterActions } from "src/store";
import { type HoneypotEvent } from "src/types";
import { formatLocalNumber } from "utils/formatting";
import { URL } from "utils/utils";
import { computed } from "vue";
import EventTableMobileRow from "./EventTableMobileRow.vue";
import EventTableTHead from "./EventTableTHead.vue";

const props = withDefaults(
  defineProps<{
    events: HoneypotEvent[];
    totalEvents: number;
    loading?: boolean;
    columns?: string[];
    filterActions: FilterActions;
  }>(),
  {
    columns: () => ["time", "event", "remote_addr", "details"],
  },
);

const state = props.filterActions.state;

const exportURL = computed(() => {
  let url = `${URL()}/api/events/export/json`;
  const qs = buildQueryStringFromState(state);
  if (qs) {
    url += `?${qs}`;
  }
  return url;
});

const { width } = useWindowSize();
</script>

<template>
  <div class="card relative p-0">
    <EventTableHeader :filter-actions="filterActions">
      <template #header-right>
        <div class="flex flex-wrap items-center gap-2">
          <slot name="header-right">
            <span class="stat-label">
              {{ formatLocalNumber(totalEvents) }} entries
            </span>
          </slot>
          <a :href="exportURL" class="btn-secondary h-8 py-0.5 text-[11px]">
            <IconDownload /> JSON
          </a>
        </div>
      </template>
    </EventTableHeader>

    <LoaderOverlay v-if="loading" />
    <div class="">
      <table class="event-table w-full table-fixed text-sm">
        <EventTableTHead
          v-if="width > 768"
          :columns="columns"
          :filter-state="filterActions.state"
        />
        <tbody>
          <tr v-if="events.length === 0">
            <td :colspan="columns.length" class="px-3 py-8 text-center text-sm">
              <slot name="empty-state"> No events found. </slot>
            </td>
          </tr>
          <slot name="rows" :events="events">
            <EventTableRow
              v-if="width > 768"
              v-for="ev in events"
              :key="ev.id"
              :evt="ev"
              :columns="columns"
              :filter-actions="filterActions"
              :width="width"
            />
            <EventTableMobileRow
              v-else
              v-for="ev in events"
              :key="`mobile-${ev.id}`"
              :evt="ev"
              :columns="columns"
              :filter-actions="filterActions"
              :width="width"
            />
          </slot>
        </tbody>
      </table>
    </div>
    <EventTableFooter
      :total-events="totalEvents"
      :filter-actions="filterActions"
    />
  </div>
</template>

<style>
@reference "src/style.css";

.event-table {
  tbody tr {
    @apply overflow-x-auto border-b border-stone-800 even:bg-stone-950/50 hover:bg-stone-900;

    &:last-child {
      @apply border-b-0;
    }
  }

  th {
    @apply border-b border-stone-700 px-3 py-2 text-left;
  }

  td {
    @apply px-3 py-1.5 text-sm;
  }
}
</style>