mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-20 06:23:23 +00:00
Add reusable pagination and page-size controls, improve mobile dropdown positioning, and migrate paginated views to consistent table components.
313 lines
19 KiB
PHP
313 lines
19 KiB
PHP
<div>
|
||
<x-slot:title>
|
||
{{ data_get_str($project, 'name')->limit(10) }} > Environments | Coolify
|
||
</x-slot>
|
||
<div x-data="projectEnvironments()" class="w-full">
|
||
<header class="mb-5 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||
<div class="min-w-0">
|
||
<h1 class="truncate text-[24px]! leading-7! font-semibold! tracking-tight!">{{ $project->name }}</h1>
|
||
<p class="mt-1 text-[13px] text-neutral-500 dark:text-fg-dim">
|
||
<span
|
||
x-text="`${environments.length} ${environments.length === 1 ? 'environment' : 'environments'}`"></span>
|
||
in this project
|
||
</p>
|
||
</div>
|
||
|
||
@can('update', $project)
|
||
<div class="flex w-fit shrink-0 items-center gap-2">
|
||
<a href="{{ route('project.edit', ['project_uuid' => $project->uuid]) }}"
|
||
{{ wireNavigate() }}
|
||
class="button"
|
||
title="Project settings"
|
||
aria-label="Open settings for {{ $project->name }}">
|
||
<x-reicon name="settings" class="size-3.5" />
|
||
Settings
|
||
</a>
|
||
|
||
<x-modal-input title="New Environment">
|
||
<x-slot:content>
|
||
<button type="button"
|
||
class="button button-highlighted">
|
||
<x-reicon name="plus" class="size-3.5" />
|
||
New environment
|
||
</button>
|
||
</x-slot:content>
|
||
|
||
<form class="space-y-4" wire:submit="submit">
|
||
<x-forms.input placeholder="staging" id="name" label="Name" required />
|
||
|
||
<footer
|
||
class="flex justify-end border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
|
||
<x-forms.button type="submit"
|
||
defaultClass="button button-highlighted">
|
||
Create environment
|
||
</x-forms.button>
|
||
</footer>
|
||
</form>
|
||
</x-modal-input>
|
||
</div>
|
||
@endcan
|
||
</header>
|
||
|
||
@if ($project->environments->isEmpty())
|
||
<x-empty title="No environments yet"
|
||
description="Add an environment to start organizing this project's resources."
|
||
icon-name="layers" />
|
||
@else
|
||
<div class="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||
<div class="relative w-full sm:max-w-sm">
|
||
<x-reicon name="search"
|
||
class="pointer-events-none absolute top-1/2 left-2.5 z-10 size-3.5 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" />
|
||
<input x-model.debounce.150ms="search" x-on:input="page = 1" type="search"
|
||
placeholder="Search environments"
|
||
class="h-8! w-full rounded-lg! border-neutral-200! bg-white! py-0! pr-8! pl-8! text-[12px]! shadow-none! placeholder:text-neutral-400 focus:border-accent! focus:ring-0! dark:border-white/[0.08]! dark:bg-white/[0.035]! dark:text-fg! dark:placeholder:text-fg-faint">
|
||
<button x-cloak x-show="search" x-on:click="search = ''; page = 1" type="button"
|
||
class="absolute top-1/2 right-2 flex size-5 -translate-y-1/2 items-center justify-center rounded text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.07] dark:hover:text-fg"
|
||
aria-label="Clear search">
|
||
<span class="text-sm leading-none">×</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="flex items-center gap-2">
|
||
<x-table.dropdown panel-class="w-48!">
|
||
<x-slot:trigger>
|
||
<button type="button" class="button" aria-haspopup="listbox" :aria-expanded="open">
|
||
<svg class="size-3.5 opacity-65" viewBox="0 0 24 24" fill="none"
|
||
aria-hidden="true">
|
||
<path d="M8 5v14m0 0-3-3m3 3 3-3M16 19V5m0 0-3 3m3-3 3 3"
|
||
stroke="currentColor" stroke-width="1.7" stroke-linecap="round"
|
||
stroke-linejoin="round" />
|
||
</svg>
|
||
Sort
|
||
</button>
|
||
</x-slot:trigger>
|
||
<template x-for="option in sortOptions" :key="option.value">
|
||
<button type="button"
|
||
class="flex h-9 w-full items-center rounded-md px-2 text-left text-[12px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
|
||
x-on:click="sortBy = option.value; close(); page = 1">
|
||
<span class="flex-1" x-text="option.label"></span>
|
||
<svg x-show="sortBy === option.value" class="size-3.5 text-warning"
|
||
viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||
<path d="m2.5 6.25 2.1 2.1 4.9-5" stroke="currentColor"
|
||
stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" />
|
||
</svg>
|
||
</button>
|
||
</template>
|
||
</x-table.dropdown>
|
||
|
||
<div
|
||
class="flex h-9 items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.06]">
|
||
<button type="button" x-on:click="setViewMode('table')"
|
||
class="flex size-7.5 items-center justify-center rounded-md transition-colors"
|
||
:class="viewMode === 'table'
|
||
?
|
||
'control-selected' :
|
||
'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'"
|
||
aria-label="Table view" title="Table view">
|
||
<x-reicon name="unordered-list" class="size-3.5" />
|
||
</button>
|
||
<button type="button" x-on:click="setViewMode('grid')"
|
||
class="flex size-7.5 items-center justify-center rounded-md transition-colors"
|
||
:class="viewMode === 'grid'
|
||
?
|
||
'control-selected' :
|
||
'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'"
|
||
aria-label="Grid view" title="Grid view">
|
||
<x-reicon name="grid" class="size-3.5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div x-cloak x-show="viewMode === 'grid'">
|
||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||
<template x-for="environment in paginatedEnvironments" :key="environment.uuid">
|
||
<article
|
||
class="group relative flex min-h-28 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]">
|
||
<a :href="environment.href" {{ wireNavigate() }} class="absolute inset-0 rounded-xl"
|
||
:aria-label="`Open ${environment.name}`"></a>
|
||
|
||
<div class="flex items-start gap-3">
|
||
<div
|
||
class="flex size-8 shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-fg-dim">
|
||
<x-reicon name="layers" class="size-4" />
|
||
</div>
|
||
<div class="min-w-0 flex-1">
|
||
<h2
|
||
class="truncate text-[13px]! leading-4! font-semibold! text-black dark:text-fg"
|
||
x-text="environment.name"></h2>
|
||
<p class="mt-0.5 truncate text-[11px] text-neutral-500 dark:text-fg-faint"
|
||
x-text="environment.description || 'Environment'"></p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mt-auto flex items-center justify-between gap-3 pt-4">
|
||
<p class="min-w-0 truncate text-[11px] text-neutral-500 dark:text-fg-dim"
|
||
x-text="`${environment.resourceCount} ${environment.resourceCount === 1 ? 'resource' : 'resources'}`">
|
||
</p>
|
||
|
||
<div class="relative z-10 flex shrink-0 items-center gap-0.5">
|
||
<a x-show="environment.addResourceHref" :href="environment.addResourceHref"
|
||
{{ wireNavigate() }}
|
||
class="flex size-7.5 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg"
|
||
title="Add resource" :aria-label="`Add resource to ${environment.name}`">
|
||
<x-reicon name="plus" class="size-3" />
|
||
</a>
|
||
<a x-show="environment.settingsHref" :href="environment.settingsHref"
|
||
{{ wireNavigate() }}
|
||
class="flex size-7.5 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg"
|
||
title="Environment settings"
|
||
:aria-label="`Open settings for ${environment.name}`">
|
||
<x-reicon name="settings" class="size-3" />
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
</template>
|
||
</div>
|
||
<x-client-pagination x-show="filteredEnvironments.length > 0" class="mt-3 rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.025]"
|
||
summary="`${rangeStart}-${rangeEnd} of ${filteredEnvironments.length}`" page-size-model="pageSize"
|
||
storage-key="coolify.page-size.project-environments" :options="[12, 24, 48, 96]" />
|
||
</div>
|
||
|
||
<div x-show="viewMode === 'table'"
|
||
class="overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.025]">
|
||
<div
|
||
class="environments-table-grid border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[11px] font-medium text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-faint">
|
||
<div>Environment</div>
|
||
<div class="environment-resource-count">Resources</div>
|
||
<div class="environment-description">Description</div>
|
||
<div></div>
|
||
</div>
|
||
|
||
<template x-for="environment in paginatedEnvironments" :key="environment.uuid">
|
||
<div
|
||
class="environments-table-grid group relative min-h-14 items-center border-b border-neutral-200 px-4 py-2.5 transition-colors last:border-b-0 hover:bg-neutral-50 dark:border-white/[0.07] dark:hover:bg-white/[0.025]">
|
||
<a :href="environment.href" {{ wireNavigate() }} class="absolute inset-0"
|
||
:aria-label="`Open ${environment.name}`"></a>
|
||
<div class="flex min-w-0 items-center gap-3">
|
||
<div
|
||
class="flex size-8 shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.035] dark:text-fg-dim">
|
||
<x-reicon name="layers" class="size-4" />
|
||
</div>
|
||
<a :href="environment.href" {{ wireNavigate() }}
|
||
class="relative truncate text-[13px] font-semibold text-black hover:underline dark:text-fg"
|
||
x-text="environment.name"></a>
|
||
</div>
|
||
|
||
<div class="environment-resource-count text-[12px] text-neutral-600 dark:text-fg-dim"
|
||
x-text="environment.resourceCount"></div>
|
||
<p class="environment-description truncate text-[12px] text-neutral-500 dark:text-fg-dim"
|
||
x-text="environment.description || '-'"></p>
|
||
|
||
<div class="relative flex items-center justify-end gap-0.5">
|
||
<a x-show="environment.addResourceHref" :href="environment.addResourceHref"
|
||
{{ wireNavigate() }}
|
||
class="flex size-7 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg"
|
||
title="Add resource" :aria-label="`Add resource to ${environment.name}`">
|
||
<x-reicon name="plus" class="size-3.5" />
|
||
</a>
|
||
<a x-show="environment.settingsHref" :href="environment.settingsHref" {{ wireNavigate() }}
|
||
class="flex size-7 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg"
|
||
title="Environment settings"
|
||
:aria-label="`Open settings for ${environment.name}`">
|
||
<x-reicon name="settings" class="size-3.5" />
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<x-client-pagination x-show="filteredEnvironments.length > 0"
|
||
summary="`${rangeStart}-${rangeEnd} of ${filteredEnvironments.length}`" page-size-model="pageSize"
|
||
storage-key="coolify.page-size.project-environments" :options="[12, 24, 48, 96]" />
|
||
</div>
|
||
|
||
<div x-show="filteredEnvironments.length === 0"
|
||
class="flex min-h-52 flex-col items-center justify-center rounded-xl border border-neutral-200 bg-white px-6 text-center dark:border-white/[0.08] dark:bg-white/[0.025]">
|
||
<x-reicon name="search" class="mb-3 size-6 text-neutral-300 dark:text-fg-faint" />
|
||
<p class="text-[13px] font-medium">No matching environments</p>
|
||
<p class="mt-1 text-[12px] text-neutral-500 dark:text-fg-dim">
|
||
Try a different search.
|
||
</p>
|
||
</div>
|
||
@endif
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
function projectEnvironments() {
|
||
return {
|
||
search: '',
|
||
sortBy: 'name-asc',
|
||
sortOpen: false,
|
||
viewMode: localStorage.getItem('project-environments-view') || 'grid',
|
||
page: 1,
|
||
pageSize: 12,
|
||
environments: @js($environmentsJs),
|
||
sortOptions: [{
|
||
value: 'name-asc',
|
||
label: 'Name A–Z'
|
||
},
|
||
{
|
||
value: 'name-desc',
|
||
label: 'Name Z–A'
|
||
},
|
||
{
|
||
value: 'resources',
|
||
label: 'Most resources'
|
||
},
|
||
],
|
||
get filteredEnvironments() {
|
||
const query = this.search.trim().toLowerCase();
|
||
const environments = this.environments.filter((environment) => {
|
||
const searchable = [environment.name, environment.description]
|
||
.filter(Boolean)
|
||
.join(' ')
|
||
.toLowerCase();
|
||
|
||
return !query || searchable.includes(query);
|
||
});
|
||
|
||
return environments.sort((first, second) => {
|
||
if (this.sortBy === 'name-desc') {
|
||
return second.name.localeCompare(first.name);
|
||
}
|
||
if (this.sortBy === 'resources') {
|
||
return second.resourceCount - first.resourceCount ||
|
||
first.name.localeCompare(second.name);
|
||
}
|
||
|
||
return first.name.localeCompare(second.name);
|
||
});
|
||
},
|
||
get totalPages() {
|
||
return Math.max(1, Math.ceil(this.filteredEnvironments.length / this.pageSize));
|
||
},
|
||
get paginatedEnvironments() {
|
||
if (this.page > this.totalPages) {
|
||
this.page = this.totalPages;
|
||
}
|
||
|
||
const start = (this.page - 1) * this.pageSize;
|
||
return this.filteredEnvironments.slice(start, start + this.pageSize);
|
||
},
|
||
get rangeStart() {
|
||
return this.filteredEnvironments.length === 0 ? 0 : ((this.page - 1) * this.pageSize) + 1;
|
||
},
|
||
get rangeEnd() {
|
||
return Math.min(this.page * this.pageSize, this.filteredEnvironments.length);
|
||
},
|
||
setViewMode(mode) {
|
||
this.viewMode = mode;
|
||
this.page = 1;
|
||
localStorage.setItem('project-environments-view', mode);
|
||
},
|
||
previousPage() {
|
||
this.page = Math.max(1, this.page - 1);
|
||
},
|
||
nextPage() {
|
||
this.page = Math.min(this.totalPages, this.page + 1);
|
||
},
|
||
};
|
||
}
|
||
</script>
|