<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>
<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>
<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;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.project-name {
margin: 0;
font-size: 16px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.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>