Newer
Older
bugtrail / packages / web / src / pages / ProjectsPage.vue
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import {
  GnCard,
  GnConfirmDialog,
  GnCopyButton,
  GnEmptyState,
  GnIconButton,
  GnInput,
  GnModal,
  GnPageHeader,
  GnSearchField,
  GnTextarea,
  GnToolbar,
  useToast,
} from "gnexus-ui-kit/vue";
import type { Project } from "@ltt/shared";
import * as api from "../api";
import AppShell from "../components/AppShell.vue";

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

const projects = ref<Project[]>([]);
const loading = ref(true);
const query = ref("");

const creating = ref(false);
const createOpen = ref(false);
const newName = ref("");
const newDescription = ref("");

const deleteTarget = ref<Project | null>(null);
const deleteOpen = ref(false);
const deleting = ref(false);

const filtered = computed(() => {
  const q = query.value.trim().toLowerCase();
  if (!q) return projects.value;
  return projects.value.filter(
    (p) => p.name.toLowerCase().includes(q) || (p.description ?? "").toLowerCase().includes(q)
  );
});

onMounted(async () => {
  try {
    projects.value = await api.listProjects();
  } catch {
    toast.error({ title: t("common.error") });
  } finally {
    loading.value = false;
  }
});

async function deleteProject() {
  const target = deleteTarget.value;
  if (!target) return;
  deleting.value = true;
  try {
    await api.deleteProject(target.id);
    projects.value = projects.value.filter((p) => p.id !== target.id);
    deleteTarget.value = null;
    toast.success({ title: t("projects.deleted"), text: target.name });
  } catch {
    toast.error({ title: t("common.error") });
  } finally {
    deleting.value = false;
  }
}

async function createProject() {
  creating.value = true;
  try {
    const project = await api.createProject(newName.value, newDescription.value || null);
    projects.value.unshift(project);
    createOpen.value = false;
    newName.value = "";
    newDescription.value = "";
    toast.success({ title: t("projects.createTitle"), text: project.name });
  } catch {
    toast.error({ title: t("common.error") });
  } finally {
    creating.value = false;
  }
}
</script>

<template>
  <AppShell>
    <div class="projects-page">
      <GnPageHeader :title="t('projects.title')" :subtitle="t('app.tagline')">
        <template #actions>
          <GnButton variant="accent" icon="ph-plus" @click="createOpen = true">
            {{ t("projects.create") }}
          </GnButton>
        </template>
      </GnPageHeader>

      <GnToolbar :title="t('projects.count', filtered.length)">
        <template #actions>
          <GnSearchField v-model="query" :placeholder="t('projects.search')" />
        </template>
      </GnToolbar>

      <GnEmptyState
        v-if="!loading && filtered.length === 0"
        icon="ph-folders"
        :title="t('projects.empty')"
        :text="t('projects.emptyHint')"
      >
        <template #actions>
          <GnButton variant="accent" icon="ph-plus" @click="createOpen = true">
            {{ t("projects.create") }}
          </GnButton>
        </template>
      </GnEmptyState>

      <div v-else-if="filtered.length" class="projects-grid">
        <GnCard v-for="project in filtered" :key="project.id" class="project-card">
          <div class="project-card-head">
            <h2 class="project-name">
              <RouterLink :to="`/p/${project.share_token}`">{{ project.name }}</RouterLink>
            </h2>
            <div class="project-card-actions">
              <GnCopyButton
                :text="api.projectShareUrl(project)"
                :label="t('projects.share')"
                size="sm"
              />
              <GnIconButton
                icon="ph-trash"
                :label="t('projects.delete')"
                size="sm"
                @click="deleteTarget = project; deleteOpen = true"
              />
            </div>
          </div>
          <p class="project-description">{{ project.description ?? "—" }}</p>
          <div class="project-card-footer">
            <RouterLink :to="`/p/${project.share_token}`" class="project-card-open">
              {{ t("projects.open") }} <i class="ph ph-arrow-right" />
            </RouterLink>
          </div>
        </GnCard>
      </div>

      <GnConfirmDialog
        v-model:open="deleteOpen"
        :title="t('projects.delete')"
        :message="t('projects.deleteConfirm')"
        :confirm-text="t('projects.delete')"
        confirm-variant="danger"
        @confirm="deleteProject"
      />

      <GnModal v-model:open="createOpen" :title="t('projects.createTitle')">
        <form class="create-form" @submit.prevent="createProject">
          <GnInput v-model="newName" :label="t('projects.name')" icon="ph-folder-simple" required />
          <GnTextarea v-model="newDescription" :label="t('projects.description')" :rows="3" />
          <div class="create-form-actions">
            <GnButton variant="secondary" type="button" @click="createOpen = false">
              {{ t("projects.cancel") }}
            </GnButton>
            <GnButton type="submit" variant="accent" :loading="creating" :disabled="!newName.trim()">
              {{ t("projects.confirm") }}
            </GnButton>
          </div>
        </form>
      </GnModal>
    </div>
  </AppShell>
</template>

<style scoped>
.projects-page {
  display: flex;
  flex-direction: column;
  gap: 20px;
}
.projects-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
  gap: 16px;
}
.project-card {
  /* kit .card is width:max-content/max-width:340px — cards fill their grid cell */
  width: 100%;
  max-width: none;
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 18px;
}
.project-card-head {
  display: flex;
  align-items: center;
  gap: 10px;
}
.project-name {
  margin: 0;
  font-size: 16px;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
}
.project-card-actions {
  /* keep both buttons on the right edge, whatever the title width */
  margin-left: auto;
  display: flex;
  align-items: center;
  gap: 8px;
}
.project-name a {
  color: inherit;
  text-decoration: none;
}
.project-name a:hover {
  color: var(--ltt-accent, #7aa2f7);
}
.project-description {
  margin: 0;
  font-size: 13px;
  line-height: 1.5;
  opacity: 0.75;
  flex: 1;
  display: -webkit-box;
  -webkit-line-clamp: 3;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.project-card-footer {
  display: flex;
  align-items: center;
  border-top: 1px solid var(--ltt-border, rgba(192, 202, 245, 0.24));
  padding-top: 10px;
}
.project-card-open {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  color: var(--ltt-accent, #7aa2f7);
  text-decoration: none;
  font-size: 12px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.05em;
}
.project-card-open:hover {
  text-decoration: underline;
}
.create-form {
  display: flex;
  flex-direction: column;
  gap: 14px;
}
.create-form > :deep(*) {
  width: 100%;
}
.create-form-actions {
  display: flex;
  justify-content: flex-end;
  gap: 10px;
}
</style>