feat(ui): pro badge, icon button animations, splitpanes nav fixes (#5009)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3168aa0b85
commit
e9b2f7065c
Vendored
+1
@@ -195,6 +195,7 @@ declare module 'vue' {
|
|||||||
'Ph:stack': typeof import('~icons/ph/stack')['default']
|
'Ph:stack': typeof import('~icons/ph/stack')['default']
|
||||||
'Ph:stackSimple': typeof import('~icons/ph/stack-simple')['default']
|
'Ph:stackSimple': typeof import('~icons/ph/stack-simple')['default']
|
||||||
Popup: typeof import('./components/Popup.vue')['default']
|
Popup: typeof import('./components/Popup.vue')['default']
|
||||||
|
ProBadge: typeof import('./components/common/ProBadge.vue')['default']
|
||||||
RandomColorTag: typeof import('./components/LogViewer/RandomColorTag.vue')['default']
|
RandomColorTag: typeof import('./components/LogViewer/RandomColorTag.vue')['default']
|
||||||
RelativeTime: typeof import('./components/common/RelativeTime.vue')['default']
|
RelativeTime: typeof import('./components/common/RelativeTime.vue')['default']
|
||||||
'Ri:terminalWindowFill': typeof import('~icons/ri/terminal-window-fill')['default']
|
'Ri:terminalWindowFill': typeof import('~icons/ri/terminal-window-fill')['default']
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
@closed="releaseSeen = mostRecent?.tag ?? config.version"
|
@closed="releaseSeen = mostRecent?.tag ?? config.version"
|
||||||
>
|
>
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<mdi:announcement class="size-6 -rotate-12" />
|
<mdi:announcement class="icon-wiggle size-6 -rotate-12" />
|
||||||
<template v-if="announcements.length > 0 && releaseSeen != mostRecent?.tag">
|
<template v-if="announcements.length > 0 && releaseSeen != mostRecent?.tag">
|
||||||
<span class="bg-red absolute top-0 right-px size-2 animate-ping rounded-full opacity-75"></span>
|
<span class="bg-red absolute top-0 right-px size-2 animate-ping rounded-full opacity-75"></span>
|
||||||
<span class="bg-red absolute top-0 right-px size-2 rounded-full"></span>
|
<span class="bg-red absolute top-0 right-px size-2 rounded-full"></span>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<template #trigger>
|
<template #trigger>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<mdi:cloud
|
<mdi:cloud
|
||||||
class="size-6"
|
class="icon-float size-6"
|
||||||
:class="
|
:class="
|
||||||
!cloudConfig
|
!cloudConfig
|
||||||
? 'text-base-content/40'
|
? 'text-base-content/40'
|
||||||
|
|||||||
@@ -58,14 +58,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="join max-md:hidden">
|
<div class="join max-md:hidden">
|
||||||
<button
|
<button
|
||||||
class="btn join-item btn-xs md:btn-sm"
|
class="icon-btn btn join-item btn-xs md:btn-sm"
|
||||||
:class="statMode === 'chart' ? 'btn-active' : 'btn-ghost'"
|
:class="statMode === 'chart' ? 'btn-active' : 'btn-ghost'"
|
||||||
@click="statMode = 'chart'"
|
@click="statMode = 'chart'"
|
||||||
>
|
>
|
||||||
<mdi:chart-bar />
|
<mdi:chart-bar />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="btn join-item btn-xs md:btn-sm"
|
class="icon-btn btn join-item btn-xs md:btn-sm"
|
||||||
:class="statMode === 'progress' ? 'btn-active' : 'btn-ghost'"
|
:class="statMode === 'progress' ? 'btn-active' : 'btn-ghost'"
|
||||||
@click="statMode = 'progress'"
|
@click="statMode = 'progress'"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="dropdown dropdown-end dropdown-hover z-20">
|
<div class="dropdown dropdown-end dropdown-hover z-20">
|
||||||
<label tabindex="0" class="btn btn-ghost btn-sm relative w-8 gap-0 px-0 md:gap-0.5">
|
<label tabindex="0" class="icon-btn btn btn-ghost btn-sm relative w-8 gap-0 px-0 md:gap-0.5">
|
||||||
<carbon:circle-solid class="text-red w-2 md:w-2.5" v-if="streamConfig.stderr" />
|
<carbon:circle-solid class="text-red w-2 md:w-2.5" v-if="streamConfig.stderr" />
|
||||||
<carbon:circle-solid class="text-blue w-2 md:w-2.5" v-if="streamConfig.stdout" />
|
<carbon:circle-solid class="text-blue w-2 md:w-2.5" v-if="streamConfig.stdout" />
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="@container flex min-w-0 flex-1 items-center gap-1.5 md:gap-2">
|
<div class="@container flex min-w-0 flex-1 items-center gap-1.5 md:gap-2">
|
||||||
<label class="swap swap-rotate size-4">
|
<label class="icon-btn swap swap-rotate size-4">
|
||||||
<input type="checkbox" v-model="pinned" />
|
<input type="checkbox" v-model="pinned" />
|
||||||
<carbon:star-filled class="swap-on text-secondary" />
|
<carbon:star-filled class="swap-on text-secondary" />
|
||||||
<carbon:star class="swap-off" />
|
<carbon:star class="swap-off" />
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
:placeholder="placeholderCopy"
|
:placeholder="placeholderCopy"
|
||||||
/>
|
/>
|
||||||
<form method="dialog" class="flex">
|
<form method="dialog" class="flex">
|
||||||
<button v-if="isMobile" class="text-base-content/50 hover:text-base-content">
|
<button v-if="isMobile" class="icon-btn text-base-content/50 hover:text-base-content">
|
||||||
<mdi:close class="size-5" />
|
<mdi:close class="size-5" />
|
||||||
</button>
|
</button>
|
||||||
<button v-else>
|
<button v-else>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
data-testid="notifications"
|
data-testid="notifications"
|
||||||
class="btn btn-circle btn-sm"
|
class="btn btn-circle btn-sm"
|
||||||
>
|
>
|
||||||
<mdi:bell class="size-6" />
|
<mdi:bell class="icon-ring size-6" />
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
<CloudPopover v-if="config.enableCloud" />
|
<CloudPopover v-if="config.enableCloud" />
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
data-testid="settings"
|
data-testid="settings"
|
||||||
class="btn btn-circle btn-sm"
|
class="btn btn-circle btn-sm"
|
||||||
>
|
>
|
||||||
<mdi:cog class="size-6" />
|
<mdi:cog class="icon-spin size-6" />
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
<dropdown class="dropdown-end" data-testid="user-menu" v-if="config.user">
|
<dropdown class="dropdown-end" data-testid="user-menu" v-if="config.user">
|
||||||
|
|||||||
@@ -30,13 +30,13 @@
|
|||||||
Raw JSON
|
Raw JSON
|
||||||
|
|
||||||
<UseClipboard v-slot="{ copy, copied }" :source="entry.rawMessage">
|
<UseClipboard v-slot="{ copy, copied }" :source="entry.rawMessage">
|
||||||
<button class="swap outline-hidden" @click="copy()" :class="{ 'hover:swap-active': copied }">
|
<button class="icon-btn swap outline-hidden" @click="copy()" :class="{ 'hover:swap-active': copied }">
|
||||||
<mdi:check class="swap-on" />
|
<mdi:check class="swap-on" />
|
||||||
<material-symbols:content-copy class="swap-off" />
|
<material-symbols:content-copy class="swap-off" />
|
||||||
</button>
|
</button>
|
||||||
</UseClipboard>
|
</UseClipboard>
|
||||||
|
|
||||||
<button class="outline-hidden" @click="downloadJSON()" title="Download JSON">
|
<button class="icon-btn outline-hidden" @click="downloadJSON()" title="Download JSON">
|
||||||
<material-symbols:download />
|
<material-symbols:download />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="dropdown dropdown-end dropdown-hover z-20">
|
<div class="dropdown dropdown-end dropdown-hover z-20">
|
||||||
<label tabindex="0" class="btn btn-ghost btn-sm w-8 gap-0 px-0 md:gap-0.5">
|
<label tabindex="0" class="icon-btn btn btn-ghost btn-sm w-8 gap-0 px-0 md:gap-0.5">
|
||||||
<carbon:circle-solid class="text-red w-2 md:w-2.5" v-if="streamConfig.stderr" />
|
<carbon:circle-solid class="text-red w-2 md:w-2.5" v-if="streamConfig.stderr" />
|
||||||
<carbon:circle-solid class="text-blue w-2 md:w-2.5" v-if="streamConfig.stdout" />
|
<carbon:circle-solid class="text-blue w-2 md:w-2.5" v-if="streamConfig.stdout" />
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
<div class="mr-16 text-right" v-if="!historical">
|
<div class="mr-16 text-right" v-if="!historical">
|
||||||
<transition name="fade">
|
<transition name="fade">
|
||||||
<button
|
<button
|
||||||
class="btn btn-primary text-primary-content fixed bottom-8 rounded-sm p-3 shadow-sm transition-colors"
|
class="icon-btn btn btn-primary text-primary-content fixed bottom-8 rounded-sm p-3 shadow-sm transition-colors"
|
||||||
:class="hasMore ? 'btn-secondary animate-bounce-fast text-secondary-content' : ''"
|
:class="hasMore ? 'btn-secondary animate-bounce-fast text-secondary-content' : ''"
|
||||||
@click="scrollToBottom()"
|
@click="scrollToBottom()"
|
||||||
v-show="scrollContext.paused"
|
v-show="scrollContext.paused"
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
<h1>
|
<h1>
|
||||||
<router-link :to="{ name: '/' }" class="flex w-full items-center gap-2.5 overflow-hidden text-4xl font-thin">
|
<router-link :to="{ name: '/' }" class="flex w-full items-center gap-2.5 overflow-hidden text-4xl font-thin">
|
||||||
<Logo class="h-11 w-11 shrink-0" />
|
<Logo class="h-11 w-11 shrink-0" />
|
||||||
Dozzle
|
<span class="truncate">Dozzle</span>
|
||||||
|
<ProBadge />
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
<small class="mt-4 block text-sm font-light" v-if="hostname">{{ hostname }}</small>
|
<small class="mt-4 block text-sm font-light" v-if="hostname">{{ hostname }}</small>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
:href="container.url"
|
:href="container.url"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="text-base-content/40 hover:text-primary inline-flex shrink-0 items-center gap-1 transition-colors"
|
class="icon-btn text-base-content/40 hover:text-primary inline-flex shrink-0 items-center gap-1 transition-colors"
|
||||||
:title="label"
|
:title="label"
|
||||||
:aria-label="label"
|
:aria-label="label"
|
||||||
@click.stop
|
@click.stop
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<button
|
<button
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
role="button"
|
role="button"
|
||||||
class="text-base-content/25 hover:text-primary shrink-0 cursor-pointer transition-colors"
|
class="icon-btn text-base-content/25 hover:text-primary shrink-0 transition-colors"
|
||||||
:title="title"
|
:title="title"
|
||||||
:aria-label="title"
|
:aria-label="title"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
<nav class="border-base-content/20 bg-base-200 pt-safe fixed top-0 z-30 w-full border-b" data-testid="navigation">
|
<nav class="border-base-content/20 bg-base-200 pt-safe fixed top-0 z-30 w-full border-b" data-testid="navigation">
|
||||||
<div class="px-4 py-2">
|
<div class="px-4 py-2">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<router-link :to="{ name: '/' }">
|
<router-link :to="{ name: '/' }" class="flex items-center gap-1.5">
|
||||||
<Logo class="h-10" />
|
<Logo class="h-10" />
|
||||||
|
<ProBadge />
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
<div class="ml-auto flex items-center gap-2">
|
<div class="ml-auto flex items-center gap-2">
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<template>
|
||||||
|
<a
|
||||||
|
v-if="isPro"
|
||||||
|
:href="cloudUrl"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
class="pro-badge status-pill status-pill-secondary"
|
||||||
|
:title="$t('cloud.pro-badge')"
|
||||||
|
data-testid="pro-badge"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
PRO
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
const cloudUrl = __CLOUD_URL__;
|
||||||
|
const { isPro, cloudConfig, ensureCloudStatus } = useCloudConfig();
|
||||||
|
|
||||||
|
// The badge can mount before the shared cloud config resolves, so ask again
|
||||||
|
// once it lands. `ensureCloudStatus` is a no-op when the status is already in.
|
||||||
|
watch(
|
||||||
|
() => cloudConfig.value?.linked,
|
||||||
|
() => ensureCloudStatus(),
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@reference "@/main.css";
|
||||||
|
|
||||||
|
/* Rides on the app's status-pill idiom (mono, uppercase, tracked, thin border)
|
||||||
|
* in the logo's gold, and only trims it down to sit beside the wordmark. The
|
||||||
|
* right padding drops the tracking added after the final letter so the text
|
||||||
|
* stays optically centered. */
|
||||||
|
.pro-badge {
|
||||||
|
@apply border-secondary/50 relative shrink-0 overflow-hidden rounded-[3px] py-px pl-[0.3rem] text-[0.6875rem] leading-[1.25] font-bold;
|
||||||
|
padding-right: calc(0.3rem - 0.1em);
|
||||||
|
transition:
|
||||||
|
border-color 150ms ease-out,
|
||||||
|
background-color 150ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pro-badge:hover {
|
||||||
|
@apply border-secondary/80 bg-secondary/20;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A single narrow glint, in the accent color rather than white so it stays
|
||||||
|
* crisp on both themes. Long pause between passes keeps it a sheen. */
|
||||||
|
.pro-badge::after {
|
||||||
|
content: "";
|
||||||
|
@apply pointer-events-none absolute inset-0;
|
||||||
|
background: linear-gradient(
|
||||||
|
105deg,
|
||||||
|
transparent 44%,
|
||||||
|
color-mix(in oklab, var(--color-secondary) 55%, transparent) 50%,
|
||||||
|
transparent 56%
|
||||||
|
);
|
||||||
|
transform: translateX(-160%);
|
||||||
|
animation: pro-shine 5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pro-shine {
|
||||||
|
0%,
|
||||||
|
78% {
|
||||||
|
transform: translateX(-160%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(160%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.pro-badge::after {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
<div class="absolute right-0 flex items-center gap-3">
|
<div class="absolute right-0 flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
v-if="!isMobile"
|
v-if="!isMobile"
|
||||||
class="hover:text-base-content/60 cursor-pointer outline-hidden"
|
class="icon-btn hover:text-base-content/60 outline-hidden"
|
||||||
type="button"
|
type="button"
|
||||||
:title="maximized ? $t('drawer.restore') : $t('drawer.maximize')"
|
:title="maximized ? $t('drawer.restore') : $t('drawer.maximize')"
|
||||||
:aria-label="maximized ? $t('drawer.restore') : $t('drawer.maximize')"
|
:aria-label="maximized ? $t('drawer.restore') : $t('drawer.maximize')"
|
||||||
@@ -15,10 +15,10 @@
|
|||||||
<mdi:arrow-expand v-else />
|
<mdi:arrow-expand v-else />
|
||||||
</button>
|
</button>
|
||||||
<form method="dialog">
|
<form method="dialog">
|
||||||
<button v-if="isMobile" class="cursor-pointer">
|
<button v-if="isMobile" class="icon-btn">
|
||||||
<mdi:close />
|
<mdi:close />
|
||||||
</button>
|
</button>
|
||||||
<button v-else class="swap hover:swap-active cursor-pointer outline-hidden">
|
<button v-else class="icon-btn swap hover:swap-active outline-hidden">
|
||||||
<mdi:keyboard-esc class="swap-off" />
|
<mdi:keyboard-esc class="swap-off" />
|
||||||
<mdi:close class="swap-on" />
|
<mdi:close class="swap-on" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -47,6 +47,17 @@ async function fetchCloudStatus() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dedupes concurrent/repeat status fetches so several components (nav popover,
|
||||||
|
// pro badge) can ask for the status without each firing its own request.
|
||||||
|
let pendingStatus: Promise<void> | null = null;
|
||||||
|
async function ensureCloudStatus() {
|
||||||
|
if (!config.enableCloud || !cloudConfig.value?.linked || cloudStatus.value) return;
|
||||||
|
pendingStatus ??= fetchCloudStatus().finally(() => (pendingStatus = null));
|
||||||
|
return pendingStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPro = computed(() => cloudStatus.value?.plan.name.toLowerCase() === "pro");
|
||||||
|
|
||||||
function clearCloudState() {
|
function clearCloudState() {
|
||||||
cloudConfig.value = null;
|
cloudConfig.value = null;
|
||||||
cloudStatus.value = null;
|
cloudStatus.value = null;
|
||||||
@@ -59,9 +70,11 @@ export function useCloudConfig() {
|
|||||||
cloudStatus,
|
cloudStatus,
|
||||||
cloudStatusError,
|
cloudStatusError,
|
||||||
isLoadingCloudStatus,
|
isLoadingCloudStatus,
|
||||||
|
isPro,
|
||||||
initialLoad,
|
initialLoad,
|
||||||
fetchCloudConfig,
|
fetchCloudConfig,
|
||||||
fetchCloudStatus,
|
fetchCloudStatus,
|
||||||
|
ensureCloudStatus,
|
||||||
clearCloudState,
|
clearCloudState,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
<div>
|
<div>
|
||||||
<MobileMenu v-if="isMobile && !forceMenuHidden" @search="showFuzzySearch"></MobileMenu>
|
<MobileMenu v-if="isMobile && !forceMenuHidden" @search="showFuzzySearch"></MobileMenu>
|
||||||
<Splitpanes @resized="onResized($event)">
|
<Splitpanes @resized="onResized($event)">
|
||||||
<Pane min-size="10" :size="menuWidth" v-if="!isMobile && !collapseNav && !forceMenuHidden">
|
<Pane min-size="10" :size="menuWidth" v-if="navVisible">
|
||||||
<SidePanel />
|
<SidePanel />
|
||||||
</Pane>
|
</Pane>
|
||||||
<Pane min-size="10" :size="100 - menuWidth">
|
<Pane min-size="10" :size="navVisible ? 100 - menuWidth : 100">
|
||||||
<Splitpanes>
|
<Splitpanes>
|
||||||
<Pane class="router-view min-h-screen">
|
<Pane class="router-view min-h-screen">
|
||||||
<router-view></router-view>
|
<router-view></router-view>
|
||||||
@@ -72,6 +72,14 @@ const { open, openSearch: showFuzzySearch, closeSearch } = useFuzzySearch();
|
|||||||
const searchParams = new URLSearchParams(window.location.search);
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
const forceMenuHidden = ref(searchParams.has("hideMenu"));
|
const forceMenuHidden = ref(searchParams.has("hideMenu"));
|
||||||
|
|
||||||
|
// splitpanes only reads a pane's `size` as its "given size" when the pane first
|
||||||
|
// registers, and its initial layout is only exact when every pane declares one.
|
||||||
|
// So both panes keep an explicit size, and this one tells the content pane to
|
||||||
|
// claim the whole width while the nav pane is unmounted — otherwise the two
|
||||||
|
// panes would together still claim 100% + menuWidth once the nav came back, and
|
||||||
|
// splitpanes would shrink the nav to its min-size to compensate.
|
||||||
|
const navVisible = computed(() => !isMobile.value && !collapseNav.value && !forceMenuHidden.value);
|
||||||
|
|
||||||
watch(open, () => {
|
watch(open, () => {
|
||||||
if (open.value) {
|
if (open.value) {
|
||||||
modal.value?.showModal();
|
modal.value?.showModal();
|
||||||
@@ -87,7 +95,11 @@ onKeyStroke("k", (e) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function onResized({ panes }: { panes: { size: number }[] }) {
|
// splitpanes also emits `resized` for its own relayouts (the collapse toggle
|
||||||
|
// adding/removing the nav pane, a remount). Those carry neither `event` nor
|
||||||
|
// `index`, so only a width the user actually dragged to gets persisted.
|
||||||
|
function onResized({ panes, event, index }: { panes: { size: number }[]; event?: Event; index?: number }) {
|
||||||
|
if (event === undefined && index === undefined) return;
|
||||||
if (panes.length == 2) {
|
if (panes.length == 2) {
|
||||||
menuWidth.value = Math.min(panes[0].size, 50);
|
menuWidth.value = Math.min(panes[0].size, 50);
|
||||||
}
|
}
|
||||||
@@ -98,7 +110,26 @@ function onResized({ panes }: { panes: { size: number }[] }) {
|
|||||||
@reference "@/main.css";
|
@reference "@/main.css";
|
||||||
|
|
||||||
:deep(.splitpanes--vertical > .splitpanes__splitter) {
|
:deep(.splitpanes--vertical > .splitpanes__splitter) {
|
||||||
@apply bg-base-100 hover:bg-secondary min-w-[5px];
|
@apply bg-base-100 relative min-w-[5px];
|
||||||
|
transition: background-color 150ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grab zone reaches a few pixels past the visible divider so the drag is
|
||||||
|
* easier to start without making the divider itself any wider. */
|
||||||
|
:deep(.splitpanes--vertical > .splitpanes__splitter)::before {
|
||||||
|
content: "";
|
||||||
|
@apply absolute inset-y-0 -right-1 -left-1 z-20;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.splitpanes--vertical > .splitpanes__splitter):hover,
|
||||||
|
:deep(.splitpanes--dragging.splitpanes--vertical > .splitpanes__splitter) {
|
||||||
|
@apply bg-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
:deep(.splitpanes--vertical > .splitpanes__splitter) {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 768px) {
|
@media screen and (max-width: 768px) {
|
||||||
|
|||||||
+110
@@ -274,9 +274,119 @@ body {
|
|||||||
.status-pill-primary {
|
.status-pill-primary {
|
||||||
@apply text-primary border-primary/30 bg-primary/10;
|
@apply text-primary border-primary/30 bg-primary/10;
|
||||||
}
|
}
|
||||||
|
.status-pill-secondary {
|
||||||
|
@apply text-secondary border-secondary/30 bg-secondary/10;
|
||||||
|
}
|
||||||
.status-pill-warning {
|
.status-pill-warning {
|
||||||
@apply text-warning border-warning/30 bg-warning/10;
|
@apply text-warning border-warning/30 bg-warning/10;
|
||||||
}
|
}
|
||||||
.status-pill-error {
|
.status-pill-error {
|
||||||
@apply text-error border-error/30 bg-error/10;
|
@apply text-error border-error/30 bg-error/10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Icon button motion ---------------------------------------------------
|
||||||
|
* Icon-only buttons get a small spring on hover and a press-in on click. The
|
||||||
|
* transform sits on the glyph rather than the button so the circle and border
|
||||||
|
* are left alone. This includes daisyUI `swap` icons: swap-rotate animates the
|
||||||
|
* standalone `rotate` property, which composes with `transform` rather than
|
||||||
|
* fighting it (swap-flip, which does use `transform`, is unused here).
|
||||||
|
*
|
||||||
|
* `btn-circle` and `btn-square` are icon-only by definition; `.icon-btn` opts
|
||||||
|
* in a bare <button>/<a> or a daisyUI button that is icon-only without being
|
||||||
|
* one of those shapes.
|
||||||
|
*
|
||||||
|
* Everything composes through custom properties instead of competing
|
||||||
|
* `transform` declarations, so a per-icon flavour below only has to set a
|
||||||
|
* variable and never has to out-specify the generic hover rule. */
|
||||||
|
:is(.btn-circle, .btn-square, .icon-btn) :where(svg, img) {
|
||||||
|
--icon-scale: 1;
|
||||||
|
--icon-rotate: 0deg;
|
||||||
|
--icon-lift: 0px;
|
||||||
|
transform: translateY(var(--icon-lift)) rotate(var(--icon-rotate)) scale(var(--icon-scale));
|
||||||
|
/* Includes the standalone rotate/scale/translate properties so a Tailwind
|
||||||
|
* `rotate-180` toggle on the glyph keeps animating rather than snapping, and
|
||||||
|
* opacity so this rule winning over daisyUI's `.swap > *` does not kill the
|
||||||
|
* swap crossfade. */
|
||||||
|
transition-property: transform, rotate, scale, translate, opacity;
|
||||||
|
transition-duration: 200ms;
|
||||||
|
transition-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-circle,
|
||||||
|
.btn-square,
|
||||||
|
.icon-btn {
|
||||||
|
@apply cursor-pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(.btn-circle, .btn-square, .icon-btn):hover :where(svg, img) {
|
||||||
|
--icon-scale: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(.btn-circle, .btn-square, .icon-btn):active :where(svg, img) {
|
||||||
|
--icon-scale: 0.92;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Per-icon flavours, opted into by the icon itself. */
|
||||||
|
.btn:hover .icon-spin {
|
||||||
|
--icon-rotate: 90deg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover .icon-float {
|
||||||
|
--icon-lift: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The two animated flavours restate the hover scale in their keyframes so they
|
||||||
|
* settle at the same size the generic hover rule would have given them. */
|
||||||
|
.btn:hover .icon-wiggle {
|
||||||
|
animation: icon-wiggle 600ms ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover .icon-ring {
|
||||||
|
animation: icon-ring 700ms ease-in-out;
|
||||||
|
transform-origin: top center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes icon-wiggle {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: rotate(0deg) scale(1.15);
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
transform: rotate(-14deg) scale(1.15);
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
transform: rotate(10deg) scale(1.15);
|
||||||
|
}
|
||||||
|
80% {
|
||||||
|
transform: rotate(-5deg) scale(1.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes icon-ring {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: rotate(0deg) scale(1.15);
|
||||||
|
}
|
||||||
|
15% {
|
||||||
|
transform: rotate(14deg) scale(1.15);
|
||||||
|
}
|
||||||
|
35% {
|
||||||
|
transform: rotate(-12deg) scale(1.15);
|
||||||
|
}
|
||||||
|
55% {
|
||||||
|
transform: rotate(8deg) scale(1.15);
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
transform: rotate(-4deg) scale(1.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
:is(.btn-circle, .btn-square, .icon-btn) :where(svg, img),
|
||||||
|
.btn:hover .icon-wiggle,
|
||||||
|
.btn:hover .icon-ring {
|
||||||
|
animation: none !important;
|
||||||
|
transform: none !important;
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<section>
|
<section>
|
||||||
<div class="mb-4 flex items-center justify-between">
|
<div class="mb-4 flex items-center justify-between">
|
||||||
<h2 class="text-lg font-semibold">{{ $t("label.host-count", { count: Object.keys(hosts).length }) }}</h2>
|
<h2 class="text-lg font-semibold">{{ $t("label.host-count", { count: Object.keys(hosts).length }) }}</h2>
|
||||||
<button @click="hostsCollapsed = !hostsCollapsed" class="btn btn-ghost btn-sm">
|
<button @click="hostsCollapsed = !hostsCollapsed" class="icon-btn btn btn-ghost btn-sm">
|
||||||
<mdi:chevron-down :class="{ 'rotate-180': !hostsCollapsed }" class="transition-transform" />
|
<mdi:chevron-down :class="{ 'rotate-180': !hostsCollapsed }" class="transition-transform" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
<h2 class="text-lg font-semibold">
|
<h2 class="text-lg font-semibold">
|
||||||
{{ $t("label.container", { count: dashboardContainers.length }) }}
|
{{ $t("label.container", { count: dashboardContainers.length }) }}
|
||||||
</h2>
|
</h2>
|
||||||
<button @click="containersCollapsed = !containersCollapsed" class="btn btn-ghost btn-sm">
|
<button @click="containersCollapsed = !containersCollapsed" class="icon-btn btn btn-ghost btn-sm">
|
||||||
<mdi:chevron-down :class="{ 'rotate-180': !containersCollapsed }" class="transition-transform" />
|
<mdi:chevron-down :class="{ 'rotate-180': !containersCollapsed }" class="transition-transform" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
@@ -457,6 +457,7 @@ cloud:
|
|||||||
link-instance: Link instance
|
link-instance: Link instance
|
||||||
relink-instance: Re-link instance
|
relink-instance: Re-link instance
|
||||||
connected: Connected
|
connected: Connected
|
||||||
|
pro-badge: Dozzle Cloud Pro
|
||||||
plan: Plan
|
plan: Plan
|
||||||
usage: Events this period
|
usage: Events this period
|
||||||
dashboard: Dashboard
|
dashboard: Dashboard
|
||||||
|
|||||||
Reference in New Issue
Block a user