259 lines
10 KiB
Vue
259 lines
10 KiB
Vue
<script setup lang="ts">
|
|
import { Head, Link, useForm } from '@inertiajs/vue3';
|
|
import { ArrowRight, LayoutTemplate, Plus, Trash2 } from 'lucide-vue-next';
|
|
import { computed, ref } from 'vue';
|
|
import FeedbackState from '@/components/FeedbackState.vue';
|
|
import FormFeedback from '@/components/FormFeedback.vue';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { useFormProtection } from '@/composables/useFormProtection';
|
|
import { useLocalization } from '@/composables/useLocalization';
|
|
import { useProjectTemplateAreas } from '@/composables/useProjectTemplateAreas';
|
|
import AppLayout from '@/layouts/AppLayout.vue';
|
|
import { destroy, edit, index } from '@/routes/project-templates';
|
|
import {
|
|
create as createProject,
|
|
index as projectsIndex,
|
|
} from '@/routes/projects';
|
|
import type { BreadcrumbItem, ProjectTemplatesIndexPageProps } from '@/types';
|
|
|
|
import type { ProjectTemplateModel } from '@/types/models/project-template-model';
|
|
|
|
const props = defineProps<ProjectTemplatesIndexPageProps>();
|
|
const { t } = useLocalization();
|
|
const { areaText } = useProjectTemplateAreas();
|
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
|
{ title: t('navigation.templates'), href: index() },
|
|
]);
|
|
const templates = computed(() =>
|
|
props.templates.filter((template) => template.is_published),
|
|
);
|
|
const pending = ref<ProjectTemplateModel | null>(null);
|
|
const form = useForm({});
|
|
const protection = useFormProtection(form);
|
|
function requestDelete(template: ProjectTemplateModel): void {
|
|
form.resetAndClearErrors();
|
|
protection.clearFeedback();
|
|
pending.value = template;
|
|
}
|
|
function close(open: boolean): void {
|
|
if (!open && !form.processing) {
|
|
pending.value = null;
|
|
form.clearErrors();
|
|
protection.clearFeedback();
|
|
}
|
|
}
|
|
function removeTemplate(): void {
|
|
if (!pending.value) {
|
|
return;
|
|
}
|
|
protection.submit(destroy(pending.value.id), {
|
|
preserveScroll: true,
|
|
onSuccess: () => {
|
|
pending.value = null;
|
|
},
|
|
});
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<AppLayout :breadcrumbs="breadcrumbs">
|
|
<Head :title="t('navigation.templates')" />
|
|
<div class="p-page gap-page mx-auto grid w-full min-w-0 max-w-7xl">
|
|
<header
|
|
class="gap-column flex flex-col sm:flex-row sm:items-start sm:justify-between"
|
|
>
|
|
<div class="gap-stack grid min-w-0">
|
|
<h1 class="text-headline-mobile md:text-headline">
|
|
{{ t('navigation.templates') }}
|
|
</h1>
|
|
<p class="text-muted-foreground max-w-2xl">
|
|
{{ t('templates.description') }}
|
|
</p>
|
|
</div>
|
|
<Button as-child class="shrink-0"
|
|
><Link :href="createProject()"
|
|
><Plus aria-hidden="true" />{{
|
|
t('projects.create')
|
|
}}</Link
|
|
></Button
|
|
>
|
|
</header>
|
|
<FeedbackState
|
|
v-if="status"
|
|
state="success"
|
|
:title="t('templates.updated')"
|
|
/>
|
|
<section
|
|
class="bg-card gap-column flex min-w-0 flex-col rounded-md border p-5 sm:flex-row sm:items-center"
|
|
aria-labelledby="save-template-help"
|
|
>
|
|
<LayoutTemplate
|
|
class="text-primary size-6 shrink-0"
|
|
aria-hidden="true"
|
|
/>
|
|
<div class="gap-stack grid min-w-0 flex-1">
|
|
<h2 id="save-template-help" class="text-title">
|
|
{{ t('templates.fromProject') }}
|
|
</h2>
|
|
<p class="text-caption text-muted-foreground">
|
|
{{ t('templates.fromProjectHelp') }}
|
|
</p>
|
|
</div>
|
|
<Button as-child variant="outline" class="shrink-0"
|
|
><Link :href="projectsIndex()"
|
|
>{{ t('templates.browseProjects')
|
|
}}<ArrowRight aria-hidden="true" /></Link
|
|
></Button>
|
|
</section>
|
|
<FeedbackState
|
|
v-if="templates.length === 0"
|
|
state="empty"
|
|
:title="t('templates.empty')"
|
|
:description="t('templates.emptyHelp')"
|
|
/>
|
|
<ul
|
|
v-else
|
|
class="gap-column grid min-w-0 md:grid-cols-2 xl:grid-cols-3"
|
|
:aria-label="t('templates.published')"
|
|
>
|
|
<li
|
|
v-for="template in templates"
|
|
:key="template.id"
|
|
class="bg-card flex min-w-0 flex-col rounded-md border"
|
|
:data-template="template.id"
|
|
>
|
|
<div class="gap-column grid min-w-0 p-5">
|
|
<div class="gap-stack grid min-w-0">
|
|
<h2 class="text-title break-words">
|
|
{{ template.name }}
|
|
</h2>
|
|
<p
|
|
class="text-caption text-muted-foreground break-all"
|
|
>
|
|
{{ template.slug }}
|
|
</p>
|
|
</div>
|
|
<Badge
|
|
variant="secondary"
|
|
class="w-fit whitespace-normal"
|
|
>{{
|
|
t('templates.linkedCount', {
|
|
count: template.linked_project_count ?? 0,
|
|
})
|
|
}}</Badge
|
|
>
|
|
<p
|
|
class="text-caption text-muted-foreground break-words"
|
|
>
|
|
{{
|
|
template.source_project
|
|
? t('templates.savedFrom', {
|
|
project: template.source_project.name,
|
|
key: template.source_project.key,
|
|
})
|
|
: t('templates.sourceUnavailable')
|
|
}}
|
|
</p>
|
|
<ul
|
|
class="flex flex-wrap gap-2"
|
|
:aria-label="t('templates.groups')"
|
|
>
|
|
<li v-for="area in template.copy_areas" :key="area">
|
|
<Badge
|
|
variant="outline"
|
|
class="whitespace-normal"
|
|
>{{ areaText(area, 'label') }}</Badge
|
|
>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
<div class="gap-stack mt-auto flex flex-wrap border-t p-4">
|
|
<Button as-child variant="secondary"
|
|
><Link
|
|
:href="
|
|
createProject({
|
|
query: { template: template.id },
|
|
})
|
|
"
|
|
>{{ t('templates.use') }}</Link
|
|
></Button
|
|
>
|
|
<Button as-child variant="outline"
|
|
><Link
|
|
:href="edit(template.id)"
|
|
:aria-label="
|
|
t('templates.editTitle', {
|
|
name: template.name,
|
|
})
|
|
"
|
|
>{{ t('templates.edit') }}</Link
|
|
></Button
|
|
>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
class="text-destructive ml-auto"
|
|
:aria-label="
|
|
t('templates.deleteTitle', {
|
|
name: template.name,
|
|
})
|
|
"
|
|
@click="requestDelete(template)"
|
|
><Trash2 aria-hidden="true"
|
|
/></Button>
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
<Dialog :open="pending !== null" @update:open="close">
|
|
<DialogContent :show-close-button="!form.processing">
|
|
<DialogHeader>
|
|
<DialogTitle>{{
|
|
t('templates.deleteTitle', {
|
|
name: pending?.name ?? '',
|
|
})
|
|
}}</DialogTitle>
|
|
<DialogDescription>{{
|
|
t('templates.deleteHelp')
|
|
}}</DialogDescription>
|
|
</DialogHeader>
|
|
<p v-if="pending?.linked_project_count" class="text-body">
|
|
{{
|
|
t('templates.deleteImpact', {
|
|
count: pending.linked_project_count,
|
|
})
|
|
}}
|
|
</p>
|
|
<FormFeedback
|
|
:state="protection.state.value"
|
|
@retry="removeTemplate"
|
|
/>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
:disabled="form.processing"
|
|
@click="close(false)"
|
|
>{{ t('common.cancel') }}</Button
|
|
>
|
|
<Button
|
|
variant="destructive"
|
|
:loading="form.processing"
|
|
:loading-label="t('templates.deleting')"
|
|
@click="removeTemplate"
|
|
>{{ t('templates.delete') }}</Button
|
|
>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</AppLayout>
|
|
</template>
|