From 1bb54c0ca1e7b48969ab19fa98af1bad8bbe2184 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:55:34 +0200 Subject: [PATCH] feat(ui): scrollable resource tabs and railpack default Add a shared resource-heading-tabs scroller with overflow chevrons, wire it into resource/server navbars, keep links menus outside overflow, and default new git apps to Railpack instead of Nixpacks. --- .../Project/New/GithubPrivateRepository.php | 2 +- .../New/GithubPrivateRepositoryDeployKey.php | 2 +- .../Project/New/GitlabPrivateRepository.php | 2 +- .../Project/New/PublicGitRepository.php | 2 +- resources/css/app.css | 104 +++++++++++ .../components/applications/links.blade.php | 2 +- .../views/components/project/navbar.blade.php | 4 +- .../resource-heading-tabs.blade.php | 167 ++++++++++++++++++ .../views/components/services/links.blade.php | 2 +- .../project/application/general.blade.php | 2 +- .../project/application/heading.blade.php | 40 +++-- .../project/database/heading.blade.php | 28 +-- ...ub-private-repository-deploy-key.blade.php | 2 +- .../new/github-private-repository.blade.php | 2 +- .../new/gitlab-private-repository.blade.php | 2 +- .../new/public-git-repository.blade.php | 2 +- .../project/service/heading.blade.php | 32 ++-- .../views/livewire/server/navbar.blade.php | 29 +-- ...pplicationGeneralBuildpackSelectorTest.php | 10 +- .../NewApplicationBuildpackDefaultsTest.php | 20 ++- .../ResourceHeadingUnifiedNavbarTest.php | 97 ++++++++-- 21 files changed, 452 insertions(+), 101 deletions(-) create mode 100644 resources/views/components/resource-heading-tabs.blade.php diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index 479c2a1f5..925f8d969 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -64,7 +64,7 @@ class GithubPrivateRepository extends Component protected int $page = 1; - public $build_pack = 'nixpacks'; + public $build_pack = 'railpack'; public bool $show_is_static = true; diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index fb3c0b2c3..502a69bec 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -47,7 +47,7 @@ class GithubPrivateRepositoryDeployKey extends Component public string $branch; - public $build_pack = 'nixpacks'; + public $build_pack = 'railpack'; public bool $show_is_static = true; diff --git a/app/Livewire/Project/New/GitlabPrivateRepository.php b/app/Livewire/Project/New/GitlabPrivateRepository.php index a0ff15304..3da796a81 100644 --- a/app/Livewire/Project/New/GitlabPrivateRepository.php +++ b/app/Livewire/Project/New/GitlabPrivateRepository.php @@ -57,7 +57,7 @@ class GitlabPrivateRepository extends Component protected int $page = 1; - public $build_pack = 'nixpacks'; + public $build_pack = 'railpack'; public bool $show_is_static = true; diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index fdae52f7c..81d65bc85 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -56,7 +56,7 @@ class PublicGitRepository extends Component public string $git_repository; - public $build_pack = 'nixpacks'; + public $build_pack = 'railpack'; public bool $show_is_static = true; diff --git a/resources/css/app.css b/resources/css/app.css index 5698f307e..2162e9dac 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1152,6 +1152,110 @@ body.terminal-is-fullscreen .terminal-fullscreen-shell [data-terminal-mobile-too height: 0; } +/* + * Kumo-style overflow controls for resource heading tabs. + * Circular chevrons sit over gradient fades when more tabs are off-screen. + */ +.resource-heading-tabs-scroller { + --resource-heading-tabs-fade: #f5f5f5; /* neutral-100, matches pill track */ +} + +.dark .resource-heading-tabs-scroller { + /* Matches dark:bg-white/[0.035] pill track + panel so fade blends */ + --resource-heading-tabs-fade: color-mix(in srgb, var(--color-panel, #0a0a0a) 96.5%, white); +} + +.resource-heading-tabs-control { + position: absolute; + inset-block: 0; + z-index: 3; + display: flex; + align-items: center; + width: 2.5rem; + margin: 0; + padding: 0; + border: 0; + pointer-events: none; + opacity: 0; + transition: opacity 0.15s ease; +} + +.resource-heading-tabs-control.is-visible { + pointer-events: auto; + opacity: 1; +} + +.resource-heading-tabs-control.is-start { + left: 0; + justify-content: flex-start; + background: linear-gradient( + to right, + var(--resource-heading-tabs-fade) 35%, + color-mix(in srgb, var(--resource-heading-tabs-fade) 85%, transparent) 70%, + transparent + ); +} + +.resource-heading-tabs-control.is-end { + right: 0; + justify-content: flex-end; + background: linear-gradient( + to left, + var(--resource-heading-tabs-fade) 35%, + color-mix(in srgb, var(--resource-heading-tabs-fade) 85%, transparent) 70%, + transparent + ); +} + +.resource-heading-tabs-control-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + margin-inline: 0.25rem; + border-radius: 9999px; + color: #737373; + background: #ffffff; + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.06), + 0 0 0 1px rgba(0, 0, 0, 0.08); + transition: + background-color 0.15s ease, + color 0.15s ease, + box-shadow 0.15s ease; +} + +.dark .resource-heading-tabs-control-icon { + color: var(--color-fg-dim, #a3a3a3); + background: var(--color-raised, #171717); + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.35), + 0 0 0 1px rgba(255, 255, 255, 0.1); +} + +.resource-heading-tabs-control:hover .resource-heading-tabs-control-icon, +.resource-heading-tabs-control:focus-visible .resource-heading-tabs-control-icon { + color: #000000; + background: #ffffff; +} + +.dark .resource-heading-tabs-control:hover .resource-heading-tabs-control-icon, +.dark .resource-heading-tabs-control:focus-visible .resource-heading-tabs-control-icon { + color: var(--color-fg, #fafafa); + background: var(--color-raised, #171717); +} + +.resource-heading-tabs-control:focus { + outline: none; +} + +.resource-heading-tabs-control:focus-visible .resource-heading-tabs-control-icon { + box-shadow: + 0 0 0 2px color-mix(in srgb, var(--color-coollabs, #6b16ed) 45%, transparent), + 0 0 0 1px rgba(0, 0, 0, 0.08); +} + .resource-heading-menus, .resource-heading-actions { overflow: visible; diff --git a/resources/views/components/applications/links.blade.php b/resources/views/components/applications/links.blade.php index d1c5a0835..9fd8cbd6d 100644 --- a/resources/views/components/applications/links.blade.php +++ b/resources/views/components/applications/links.blade.php @@ -22,7 +22,7 @@
+ class="absolute right-0 z-[90] mt-2 min-w-60 max-w-96 max-h-80 overflow-y-auto rounded-lg border border-neutral-200 bg-white py-1.5 shadow-modal scrollbar dark:border-white/10 dark:bg-raised md:left-0 md:right-auto"> @if ($hasLinks) @if (data_get($application, 'gitBrancLocation')) diff --git a/resources/views/components/project/navbar.blade.php b/resources/views/components/project/navbar.blade.php index 5dd261e76..cf8a11efd 100644 --- a/resources/views/components/project/navbar.blade.php +++ b/resources/views/components/project/navbar.blade.php @@ -24,7 +24,7 @@ :class="[typeof collapsed !== 'undefined' && collapsed ? 'lg:left-16' : 'lg:left-56']">
- +
@isset($actions) diff --git a/resources/views/components/resource-heading-tabs.blade.php b/resources/views/components/resource-heading-tabs.blade.php new file mode 100644 index 000000000..d5e1de70b --- /dev/null +++ b/resources/views/components/resource-heading-tabs.blade.php @@ -0,0 +1,167 @@ +{{-- + Scrollable resource tab strip with Kumo-style overflow arrows. + Left/right circular chevrons appear only when more tabs exist off-screen. + On load / Livewire navigate, the active tab is scrolled into view so + right-side items are not hidden after navigation on mobile. +--}} +@props([ + 'listClass' => '', +]) + +
class('resource-heading-tabs-scroller relative isolate min-w-0') }} + x-data="{ + canStart: false, + canEnd: false, + ro: null, + mo: null, + onNavigated: null, + update() { + const el = this.$refs.list + if (!el) { + return + } + const max = Math.max(0, el.scrollWidth - el.clientWidth) + const left = Math.min(Math.max(0, el.scrollLeft), max) + this.canStart = left > 1 + this.canEnd = (max - left) > 1 + }, + scrollByDir(dir) { + const el = this.$refs.list + if (!el) { + return + } + const amount = Math.max(80, Math.floor(el.clientWidth * 0.7)) + el.scrollBy({ left: dir === 'start' ? -amount : amount, behavior: 'smooth' }) + }, + findActiveTab() { + const el = this.$refs.list + if (!el) { + return null + } + + return Array.from(el.children).find((child) => { + if (!(child instanceof HTMLElement)) { + return false + } + if (child.getAttribute('aria-current') === 'page') { + return true + } + if (child.hasAttribute('data-active') || child.dataset.active === 'true') { + return true + } + // Active resource tabs use coollabs/warning highlight classes. + const cls = typeof child.className === 'string' ? child.className : '' + return cls.includes('ring-coollabs') + || cls.includes('bg-coollabs') + || cls.includes('ring-warning') + || cls.includes('bg-warning') + }) ?? null + }, + scrollActiveIntoView() { + const list = this.$refs.list + const active = this.findActiveTab() + if (!list || !active) { + this.update() + return + } + + // Center the active tab so right-edge items are fully visible after navigate. + const listWidth = list.clientWidth + const maxScroll = Math.max(0, list.scrollWidth - listWidth) + const activeCenter = active.offsetLeft + (active.offsetWidth / 2) + const target = Math.min(maxScroll, Math.max(0, activeCenter - (listWidth / 2))) + + list.scrollTo({ left: target, behavior: 'auto' }) + this.update() + }, + scheduleScrollActiveIntoView() { + // Layout may not be final on first paint (fonts, Livewire morph, flex). + this.$nextTick(() => { + this.scrollActiveIntoView() + requestAnimationFrame(() => { + this.scrollActiveIntoView() + requestAnimationFrame(() => this.scrollActiveIntoView()) + }) + }) + }, + init() { + this.onNavigated = () => this.scheduleScrollActiveIntoView() + document.addEventListener('livewire:navigated', this.onNavigated) + + this.$nextTick(() => { + this.scheduleScrollActiveIntoView() + const el = this.$refs.list + if (!el || typeof ResizeObserver === 'undefined') { + return + } + this.ro = new ResizeObserver(() => { + this.update() + }) + this.ro.observe(el) + if (typeof MutationObserver !== 'undefined') { + this.mo = new MutationObserver(() => { + this.update() + }) + this.mo.observe(el, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['class', 'aria-current', 'data-active'] }) + } + }) + }, + destroy() { + this.ro?.disconnect() + this.mo?.disconnect() + if (this.onNavigated) { + document.removeEventListener('livewire:navigated', this.onNavigated) + } + }, + }" + x-init="init()" + @resize.window.debounce.50ms="update()" +> +
+ {{ $slot }} +
+ + + + +
diff --git a/resources/views/components/services/links.blade.php b/resources/views/components/services/links.blade.php index 7740b3eab..e94abc576 100644 --- a/resources/views/components/services/links.blade.php +++ b/resources/views/components/services/links.blade.php @@ -14,7 +14,7 @@
+ class="absolute right-0 z-[90] mt-2 min-w-60 max-w-96 max-h-80 overflow-y-auto rounded-lg border border-neutral-200 bg-white py-1.5 shadow-modal scrollbar dark:border-white/10 dark:bg-raised md:left-0 md:right-auto"> @foreach ($links as $link) diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index f1aa4b4b0..747245ddd 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -81,8 +81,8 @@
- @foreach ($applicationMenuItems as $menuItem) - @php - $isMobileApplicationItemActive = $menuItem['active'] - || ($menuItem['label'] === 'Settings' && $activeConfigurationMenuItem); - @endphp - $isMobileApplicationItemActive, - ]) - @if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif - href="{{ route($menuItem['route'], $parameters) }}"> - {{ $menuItem['label'] }} - - @endforeach + class="flex min-w-0 items-center gap-0.5 rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.07] dark:bg-white/[0.035]"> + {{-- Tabs may scroll; keep Links outside overflow so the dropdown never creates a scrollbar. --}} + + @foreach ($applicationMenuItems as $menuItem) + @php + $isMobileApplicationItemActive = $menuItem['active'] + || ($menuItem['label'] === 'Settings' && $activeConfigurationMenuItem); + @endphp + $isMobileApplicationItemActive, + ]) + @if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif + href="{{ route($menuItem['route'], $parameters) }}"> + {{ $menuItem['label'] }} + + @endforeach + +
+ +