style : use modal component for forms based on Lesstime pattern

- Create reusable AppModal component (Teleport, backdrop blur, transitions)
- Replace inline forms with modals on list and detail pages
- Consistent with Lesstime TaskModal design (header, body scroll, footer)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-06 14:02:51 +02:00
parent 3a3a46992c
commit 157d7c96b9
3 changed files with 308 additions and 176 deletions

View File

@@ -0,0 +1,128 @@
<template>
<Teleport v-if="isOpen" to="body">
<Transition name="modal" appear>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<!-- Backdrop -->
<div
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
@click="close"
/>
<!-- Modal -->
<div
class="relative z-10 flex w-full flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/5"
:class="maxWidthClass"
style="max-height: min(90vh, 900px)"
>
<!-- Header -->
<div class="border-b border-neutral-100 bg-neutral-50/80 px-4 py-4 sm:px-8 sm:py-5">
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold tracking-tight text-neutral-900">
<slot name="title" />
</h2>
<button
class="rounded-md p-1 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-600"
@click="close"
>
<Icon name="mdi:close" size="20" />
</button>
</div>
</div>
<!-- Body -->
<div class="overflow-y-auto px-4 py-4 sm:px-8 sm:py-6">
<slot />
</div>
<!-- Footer -->
<div class="border-t border-neutral-100 px-4 py-4 sm:px-8">
<div class="flex justify-end gap-3">
<slot name="footer">
<button
type="button"
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50"
@click="close"
>
{{ cancelLabel }}
</button>
<MalioButton
:loading="loading"
@click="$emit('submit')"
>
{{ submitLabel }}
</MalioButton>
</slot>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
const props = withDefaults(defineProps<{
modelValue: boolean
submitLabel?: string
cancelLabel?: string
loading?: boolean
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl'
}>(), {
submitLabel: 'Enregistrer',
cancelLabel: 'Annuler',
loading: false,
maxWidth: '2xl',
})
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'submit'): void
}>()
const isOpen = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
function close() {
isOpen.value = false
}
const maxWidthClass = computed(() => {
const map = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
'2xl': 'max-w-2xl',
}
return map[props.maxWidth]
})
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-active > div:last-child,
.modal-leave-active > div:last-child {
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-from > div:last-child {
transform: scale(0.95) translateY(8px);
opacity: 0;
}
.modal-leave-to > div:last-child {
transform: scale(0.97);
opacity: 0;
}
</style>

View File

@@ -10,12 +10,15 @@ const slug = route.params.slug as string
const application = ref<Application | null>(null)
const loading = ref(true)
const editingApp = ref(false)
const editForm = ref<ApplicationWrite>({ name: '', slug: '', registryImage: '', description: '', giteaUrl: '' })
const isSubmitting = ref(false)
const pendingMaintenanceByEnvId = ref<Record<number, boolean>>({})
// Environment form
const showEnvForm = ref(false)
// App edit modal
const showAppModal = ref(false)
const editForm = ref<ApplicationWrite>({ name: '', slug: '', registryImage: '', description: '', giteaUrl: '' })
const isSubmittingApp = ref(false)
// Env modal
const showEnvModal = ref(false)
const editingEnvId = ref<number | null>(null)
const envForm = ref<EnvironmentWrite>({
name: '',
@@ -26,7 +29,6 @@ const envForm = ref<EnvironmentWrite>({
logFiles: [],
})
const isSubmittingEnv = ref(false)
const pendingMaintenanceByEnvId = ref<Record<number, boolean>>({})
async function loadApplication() {
loading.value = true
@@ -38,7 +40,7 @@ async function loadApplication() {
}
// Application edit
function startEditApp() {
function openEditAppModal() {
if (!application.value) return
editForm.value = {
name: application.value.name,
@@ -47,19 +49,19 @@ function startEditApp() {
description: application.value.description ?? '',
giteaUrl: application.value.giteaUrl ?? '',
}
editingApp.value = true
showAppModal.value = true
}
async function saveApp() {
isSubmitting.value = true
isSubmittingApp.value = true
try {
application.value = await updateApplication(slug, editForm.value)
editingApp.value = false
showAppModal.value = false
if (editForm.value.slug !== slug) {
router.replace(`/applications/${editForm.value.slug}`)
}
} finally {
isSubmitting.value = false
isSubmittingApp.value = false
}
}
@@ -70,13 +72,13 @@ async function handleDeleteApp() {
}
// Environment CRUD
function startCreateEnv() {
function openCreateEnvModal() {
editingEnvId.value = null
envForm.value = { name: '', containerName: '', deployScriptPath: '', maintenanceFilePath: '', appUrl: '', logFiles: [] }
showEnvForm.value = true
showEnvModal.value = true
}
function startEditEnv(env: Environment) {
function openEditEnvModal(env: Environment) {
editingEnvId.value = env.id!
envForm.value = {
name: env.name,
@@ -86,7 +88,7 @@ function startEditEnv(env: Environment) {
appUrl: env.appUrl ?? '',
logFiles: env.logFiles.map(lf => ({ label: lf.label, path: lf.path })),
}
showEnvForm.value = true
showEnvModal.value = true
}
async function saveEnv() {
@@ -97,7 +99,7 @@ async function saveEnv() {
} else {
await createEnvironment(slug, envForm.value)
}
showEnvForm.value = false
showEnvModal.value = false
await loadApplication()
} finally {
isSubmittingEnv.value = false
@@ -129,6 +131,10 @@ function removeLogFile(index: number) {
envForm.value.logFiles.splice(index, 1)
}
const envModalTitle = computed(() =>
editingEnvId.value ? t('environments.editButton') : t('environments.addButton')
)
onMounted(loadApplication)
</script>
@@ -154,10 +160,10 @@ onMounted(loadApplication)
<h1 class="text-2xl font-bold text-primary-500 sm:text-4xl">{{ application.name }}</h1>
<p v-if="application.description" class="text-neutral-500 mt-2">{{ application.description }}</p>
</div>
<div class="flex gap-2" v-if="!editingApp">
<div class="flex gap-2">
<button
class="rounded-lg border border-neutral-300 px-3 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50"
@click="startEditApp"
@click="openEditAppModal"
>
{{ t('applications.detail.editButton') }}
</button>
@@ -171,7 +177,7 @@ onMounted(loadApplication)
</div>
<!-- Application info -->
<div v-if="!editingApp" class="rounded-lg bg-tertiary-500 p-5 mb-8">
<div class="rounded-lg bg-tertiary-500 p-5 mb-8">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div>
<span class="text-neutral-400">{{ t('applications.detail.registryImage') }} :</span>
@@ -186,122 +192,20 @@ onMounted(loadApplication)
</div>
</div>
<!-- Edit form -->
<div v-else class="rounded-lg border border-neutral-200 bg-white p-6 mb-8">
<form @submit.prevent="saveApp" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-semibold text-neutral-700 mb-1">{{ t('applications.form.name') }}</label>
<MalioInputText v-model="editForm.name" required />
</div>
<div>
<label class="block text-sm font-semibold text-neutral-700 mb-1">{{ t('applications.form.slug') }}</label>
<MalioInputText v-model="editForm.slug" required />
</div>
<div>
<label class="block text-sm font-semibold text-neutral-700 mb-1">{{ t('applications.form.registryImage') }}</label>
<MalioInputText v-model="editForm.registryImage" required />
</div>
<div>
<label class="block text-sm font-semibold text-neutral-700 mb-1">{{ t('applications.form.giteaUrl') }}</label>
<MalioInputText v-model="editForm.giteaUrl" />
</div>
</div>
<div>
<label class="block text-sm font-semibold text-neutral-700 mb-1">{{ t('applications.form.description') }}</label>
<textarea
v-model="editForm.description"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:ring-2 focus:ring-secondary-500/20"
rows="2"
/>
</div>
<div class="flex justify-end gap-3">
<button
type="button"
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50"
@click="editingApp = false"
>
{{ t('applications.form.cancel') }}
</button>
<MalioButton type="submit" :loading="isSubmitting">
{{ t('applications.form.save') }}
</MalioButton>
</div>
</form>
</div>
<!-- Environments section -->
<div>
<div class="flex items-center justify-between pb-4">
<h2 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ t('environments.title') }}</h2>
<button
class="rounded-lg bg-primary-500 px-3 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
@click="startCreateEnv"
@click="openCreateEnvModal"
>
+ {{ t('environments.addButton') }}
</button>
</div>
<!-- Environment form -->
<div v-if="showEnvForm" class="rounded-lg border border-neutral-200 bg-white p-6 mb-6">
<form @submit.prevent="saveEnv" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-semibold text-neutral-700 mb-1">{{ t('environments.form.name') }}</label>
<MalioInputText v-model="envForm.name" required />
</div>
<div>
<label class="block text-sm font-semibold text-neutral-700 mb-1">{{ t('environments.form.containerName') }}</label>
<MalioInputText v-model="envForm.containerName" required />
</div>
<div>
<label class="block text-sm font-semibold text-neutral-700 mb-1">{{ t('environments.form.deployScriptPath') }}</label>
<MalioInputText v-model="envForm.deployScriptPath" required />
</div>
<div>
<label class="block text-sm font-semibold text-neutral-700 mb-1">{{ t('environments.form.maintenanceFilePath') }}</label>
<MalioInputText v-model="envForm.maintenanceFilePath" required />
</div>
<div>
<label class="block text-sm font-semibold text-neutral-700 mb-1">{{ t('environments.form.appUrl') }}</label>
<MalioInputText v-model="envForm.appUrl" />
</div>
</div>
<!-- Log files -->
<div>
<div class="flex items-center justify-between mb-2">
<label class="text-sm font-semibold text-neutral-700">{{ t('environments.logFiles.title') }}</label>
<button type="button" @click="addLogFile" class="text-primary-500 hover:underline text-sm font-semibold">
+ {{ t('environments.logFiles.addButton') }}
</button>
</div>
<div v-for="(lf, index) in envForm.logFiles" :key="index" class="flex gap-2 mb-2 items-center">
<MalioInputText v-model="lf.label" :placeholder="t('environments.logFiles.label')" class="flex-1" required />
<MalioInputText v-model="lf.path" :placeholder="t('environments.logFiles.path')" class="flex-[2]" required />
<button type="button" @click="removeLogFile(index)" class="text-red-500 hover:text-red-700 p-1">
<Icon name="mdi:close" size="18" />
</button>
</div>
</div>
<div class="flex justify-end gap-3">
<button
type="button"
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50"
@click="showEnvForm = false"
>
{{ t('environments.form.cancel') }}
</button>
<MalioButton type="submit" :loading="isSubmittingEnv">
{{ t('environments.form.save') }}
</MalioButton>
</div>
</form>
</div>
<!-- Environments list -->
<div v-if="!application.environments?.length && !showEnvForm" class="rounded-lg border border-neutral-200 bg-white p-6 text-center text-neutral-500">
<div v-if="!application.environments?.length" class="rounded-lg border border-neutral-200 bg-white p-6 text-center text-neutral-500">
{{ t('applications.card.noEnvironments') }}
</div>
@@ -346,7 +250,7 @@ onMounted(loadApplication)
</button>
<button
class="rounded-lg border border-neutral-300 px-3 py-2 text-xs font-semibold text-neutral-600 hover:bg-white"
@click="startEditEnv(env)"
@click="openEditEnvModal(env)"
>
{{ t('environments.editButton') }}
</button>
@@ -370,5 +274,105 @@ onMounted(loadApplication)
</div>
</div>
</template>
<!-- Edit application modal -->
<AppModal
v-model="showAppModal"
:submit-label="t('applications.form.save')"
:cancel-label="t('applications.form.cancel')"
:loading="isSubmittingApp"
@submit="saveApp"
>
<template #title>{{ t('applications.detail.editButton') }}</template>
<form @submit.prevent="saveApp" class="space-y-4">
<div class="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2">
<MalioInputText
v-model="editForm.name"
:label="t('applications.form.name')"
required
/>
<MalioInputText
v-model="editForm.slug"
:label="t('applications.form.slug')"
required
/>
<MalioInputText
v-model="editForm.registryImage"
:label="t('applications.form.registryImage')"
required
/>
<MalioInputText
v-model="editForm.giteaUrl"
:label="t('applications.form.giteaUrl')"
/>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ t('applications.form.description') }}</label>
<textarea
v-model="editForm.description"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:ring-2 focus:ring-secondary-500/20"
rows="3"
/>
</div>
</form>
</AppModal>
<!-- Environment modal -->
<AppModal
v-model="showEnvModal"
:submit-label="t('environments.form.save')"
:cancel-label="t('environments.form.cancel')"
:loading="isSubmittingEnv"
@submit="saveEnv"
>
<template #title>{{ envModalTitle }}</template>
<form @submit.prevent="saveEnv" class="space-y-4">
<div class="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2">
<MalioInputText
v-model="envForm.name"
:label="t('environments.form.name')"
required
/>
<MalioInputText
v-model="envForm.containerName"
:label="t('environments.form.containerName')"
required
/>
<MalioInputText
v-model="envForm.deployScriptPath"
:label="t('environments.form.deployScriptPath')"
required
/>
<MalioInputText
v-model="envForm.maintenanceFilePath"
:label="t('environments.form.maintenanceFilePath')"
required
/>
<MalioInputText
v-model="envForm.appUrl"
:label="t('environments.form.appUrl')"
/>
</div>
<!-- Log files -->
<div>
<div class="flex items-center justify-between mb-2">
<p class="text-sm font-medium text-neutral-700">{{ t('environments.logFiles.title') }}</p>
<button type="button" @click="addLogFile" class="text-primary-500 hover:underline text-sm font-semibold">
+ {{ t('environments.logFiles.addButton') }}
</button>
</div>
<div v-for="(lf, index) in envForm.logFiles" :key="index" class="flex gap-2 mb-2 items-center">
<MalioInputText v-model="lf.label" :placeholder="t('environments.logFiles.label')" class="flex-1" required />
<MalioInputText v-model="lf.path" :placeholder="t('environments.logFiles.path')" class="flex-[2]" required />
<button type="button" @click="removeLogFile(index)" class="text-red-500 hover:text-red-700 p-1">
<Icon name="mdi:close" size="18" />
</button>
</div>
</div>
</form>
</AppModal>
</div>
</template>

View File

@@ -7,7 +7,7 @@ const router = useRouter()
const applications = ref<Application[]>([])
const loading = ref(true)
const showCreateForm = ref(false)
const showCreateModal = ref(false)
const createForm = ref<ApplicationWrite>({
name: '',
slug: '',
@@ -26,12 +26,16 @@ async function loadApplications() {
}
}
function openCreateModal() {
createForm.value = { name: '', slug: '', registryImage: '', description: '', giteaUrl: '' }
showCreateModal.value = true
}
async function handleCreate() {
isSubmitting.value = true
try {
const created = await createApplication(createForm.value)
showCreateForm.value = false
createForm.value = { name: '', slug: '', registryImage: '', description: '', giteaUrl: '' }
showCreateModal.value = false
router.push(`/applications/${created.slug}`)
} finally {
isSubmitting.value = false
@@ -51,60 +55,12 @@ onMounted(loadApplications)
<h1 class="text-2xl font-bold text-primary-500 sm:text-4xl">{{ t('applications.title') }}</h1>
<button
class="rounded-lg bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
@click="showCreateForm = !showCreateForm"
@click="openCreateModal"
>
+ {{ t('applications.addButton') }}
</button>
</div>
<!-- Create form -->
<div v-if="showCreateForm" class="rounded-lg border border-neutral-200 bg-white p-6 mb-8">
<form @submit.prevent="handleCreate" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-semibold text-neutral-700 mb-1">{{ t('applications.form.name') }}</label>
<MalioInputText
v-model="createForm.name"
@update:model-value="generateSlug"
required
/>
</div>
<div>
<label class="block text-sm font-semibold text-neutral-700 mb-1">{{ t('applications.form.slug') }}</label>
<MalioInputText v-model="createForm.slug" required />
</div>
<div>
<label class="block text-sm font-semibold text-neutral-700 mb-1">{{ t('applications.form.registryImage') }}</label>
<MalioInputText v-model="createForm.registryImage" required />
</div>
<div>
<label class="block text-sm font-semibold text-neutral-700 mb-1">{{ t('applications.form.giteaUrl') }}</label>
<MalioInputText v-model="createForm.giteaUrl" />
</div>
</div>
<div>
<label class="block text-sm font-semibold text-neutral-700 mb-1">{{ t('applications.form.description') }}</label>
<textarea
v-model="createForm.description"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:ring-2 focus:ring-secondary-500/20"
rows="2"
/>
</div>
<div class="flex justify-end gap-3">
<button
type="button"
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50"
@click="showCreateForm = false"
>
{{ t('applications.form.cancel') }}
</button>
<MalioButton type="submit" :loading="isSubmitting">
{{ t('applications.form.save') }}
</MalioButton>
</div>
</form>
</div>
<!-- Loading -->
<div v-if="loading" class="grid gap-6 [grid-template-columns:repeat(auto-fill,minmax(280px,1fr))]">
<div v-for="i in 3" :key="i" class="rounded-lg bg-tertiary-500 p-5 animate-pulse">
@@ -147,5 +103,49 @@ onMounted(loadApplications)
</p>
</NuxtLink>
</div>
<!-- Create modal -->
<AppModal
v-model="showCreateModal"
:submit-label="t('applications.form.save')"
:cancel-label="t('applications.form.cancel')"
:loading="isSubmitting"
@submit="handleCreate"
>
<template #title>{{ t('applications.addButton') }}</template>
<form @submit.prevent="handleCreate" class="space-y-4">
<div class="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2">
<MalioInputText
v-model="createForm.name"
:label="t('applications.form.name')"
@update:model-value="generateSlug"
required
/>
<MalioInputText
v-model="createForm.slug"
:label="t('applications.form.slug')"
required
/>
<MalioInputText
v-model="createForm.registryImage"
:label="t('applications.form.registryImage')"
required
/>
<MalioInputText
v-model="createForm.giteaUrl"
:label="t('applications.form.giteaUrl')"
/>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ t('applications.form.description') }}</label>
<textarea
v-model="createForm.description"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:ring-2 focus:ring-secondary-500/20"
rows="3"
/>
</div>
</form>
</AppModal>
</div>
</template>