refactor: swap out dropdown with reka components (#1245)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Introduced a new `DropdownMenu` component in user profiles with
dynamic content rendering.
- Added a new `Popover` component with interactive Storybook demos,
improving component discoverability.
- Added a new `DropdownMenuArrow` component to enhance dropdown visuals.
- Implemented new CSS custom properties for charts, enhancing styling
capabilities in light and dark themes.
- Enhanced dropdown functionality by encapsulating dropdown logic in a
new `UpcDropdownMenu` component.
- Added a new `Select` component for improved user interaction within
the `Sheet` component.
- Introduced a new `SheetWithSelect` story to showcase selection
functionality within the `Sheet` component.
- Updated the `Sidebar` component to improve modal behavior and content
positioning.
- Enhanced `UserProfile` components with a new feedback function for
better status indication.

- **Style**
- Refined layouts by replacing fixed widths with flexible, responsive
designs.
- Updated global styling with a refreshed chart color palette and
expanded dark mode support.

- **Refactor**
- Migrated components to use a unified UI library, streamlining
interactions and boosting consistency.
- Improved type safety in `BrandLoading` component by utilizing a new
type for variants and sizes.
- Updated component imports and organization to enhance maintainability.

- **Bug Fixes**
- Removed unused promotional code and components, simplifying the
codebase and improving performance.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: mdatelle <mike@datelle.net>
Co-authored-by: Zack Spear <hi@zackspear.com>
Co-authored-by: Eli Bosley <ekbosley@gmail.com>
This commit is contained in:
Michael Datelle
2025-03-24 17:24:52 -04:00
committed by GitHub
parent cc85fba207
commit a1d02b486a
76 changed files with 838 additions and 934 deletions

View File

@@ -20,7 +20,6 @@ import type { ComposerTranslation } from 'vue-i18n';
import { useAccountStore } from '~/store/account';
import { useCallbackActionsStore } from '~/store/callbackActions';
import { useInstallKeyStore } from '~/store/installKey';
// import { usePromoStore } from '~/store/promo';
import { useServerStore } from '~/store/server';
import { useUpdateOsActionsStore } from '~/store/updateOsActions';
@@ -36,7 +35,6 @@ const props = withDefaults(defineProps<Props>(), {
const accountStore = useAccountStore();
const callbackActionsStore = useCallbackActionsStore();
const installKeyStore = useInstallKeyStore();
// const promoStore = usePromoStore();
const serverStore = useServerStore();
const updateOsActionStore = useUpdateOsActionsStore();
@@ -67,8 +65,6 @@ const {
*/
const isSettingsPage = ref<boolean>(document.location.pathname === '/Settings/ManagementAccess');
// const showPromoCta = computed(() => callbackStatus.value === 'success' && !connectPluginInstalled.value);
const heading = computed(() => {
if (updateOsStatus.value === 'confirming') {
return callbackTypeDowngrade.value
@@ -143,11 +139,6 @@ const cancelUpdateOs = () => {
callbackActionsStore.setCallbackStatus('ready');
};
// const promoClick = () => {
// promoStore.openOnNextLoad();
// close();
// };
const keyInstallStatusCopy = computed((): { text: string } => {
let txt1 = props.t('Installing');
let txt2 = props.t('Installed');
@@ -334,12 +325,6 @@ const showUpdateEligibility = computed(() => {
:error="accountActionStatus === 'failed'"
:text="accountActionStatusCopy.text"
/>
<!-- <UpcCallbackFeedbackStatus
v-if="showPromoCta"
:icon="InformationCircleIcon"
:text="t('Enhance your experience with Unraid Connect')"
/> -->
</div>
<template v-if="updateOsStatus === 'confirming' && !stateDataError">
@@ -388,12 +373,6 @@ const showUpdateEligibility = computed(() => {
:text="t('Configure Connect Features')"
/>
</template>
<!-- <BrandButton
v-if="showPromoCta"
:text="t('Learn More')"
@click="promoClick"
/> -->
</template>
<template v-if="updateOsStatus === 'confirming' && !stateDataError">

View File

@@ -1,38 +0,0 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia';
import { TransitionRoot } from '@headlessui/vue';
import type { ComposerTranslation } from 'vue-i18n';
import { useDropdownStore } from '~/store/dropdown';
import { useServerStore } from '~/store/server';
defineProps<{ t: ComposerTranslation }>();
const dropdownStore = useDropdownStore();
const { dropdownVisible } = storeToRefs(dropdownStore);
const { state } = storeToRefs(useServerStore());
const showLaunchpad = computed(() => state.value === 'ENOKEYFILE');
</script>
<template>
<TransitionRoot
:show="dropdownVisible"
enter="transition-all duration-200"
enter-from="opacity-0 translate-y-[16px]"
enter-to="opacity-100"
leave="transition-all duration-150"
leave-from="opacity-100"
leave-to="opacity-0 translate-y-[16px]"
>
<UpcDropdownWrapper
class="DropdownWrapper_blip text-foreground absolute z-30 top-full right-0 transition-all"
>
<UpcDropdownLaunchpad v-if="showLaunchpad" :t="t" />
<UpcDropdownContent v-else :t="t" />
</UpcDropdownWrapper>
</TransitionRoot>
</template>

View File

@@ -15,6 +15,7 @@ const { username } = storeToRefs(useServerStore());
const unraidApiStore = useUnraidApiStore();
const { unraidApiStatus, unraidApiRestartAction } = storeToRefs(unraidApiStore);
const brandLoading = () => h(BrandLoading, { size: 'custom' });
interface StatusOutput {
icon: typeof BrandLoading | typeof ExclamationTriangleIcon | typeof CheckCircleIcon;
@@ -25,16 +26,16 @@ interface StatusOutput {
const status = computed((): StatusOutput | undefined => {
if (unraidApiStatus.value === 'connecting') {
return {
icon: BrandLoading,
iconClasses: 'w-16px',
icon: brandLoading,
iconClasses: 'w-4',
text: props.t('Loading…'),
textClasses: 'italic',
};
}
if (unraidApiStatus.value === 'restarting') {
return {
icon: BrandLoading,
iconClasses: 'w-16px',
icon: brandLoading,
iconClasses: 'w-4',
text: props.t('Restarting unraid-api…'),
textClasses: 'italic',
};
@@ -55,14 +56,16 @@ const status = computed((): StatusOutput | undefined => {
}
return undefined;
});
const statusItemClasses = "text-14px flex flex-row justify-start items-center gap-8px mt-8px px-8px";
</script>
<template>
<li v-if="username" class="flex flex-row justify-start items-center gap-8px mt-8px px-8px">
<li v-if="username" :class="statusItemClasses">
<UserCircleIcon class="w-16px h-16px" aria-hidden="true" />
{{ username }}
</li>
<li v-if="status" class="flex flex-row justify-start items-center gap-8px mt-8px px-8px">
<li v-if="status" :class="statusItemClasses">
<component :is="status.icon" :class="status.iconClasses" aria-hidden="true" />
{{ status.text }}
</li>

View File

@@ -189,7 +189,7 @@ const unraidConnectWelcome = computed(() => {
</script>
<template>
<div class="flex flex-col gap-y-8px min-w-300px max-w-350px">
<div class="flex flex-col grow gap-y-8px">
<header
v-if="connectPluginInstalled"
class="flex flex-col items-start justify-between mt-8px mx-8px"

View File

@@ -22,7 +22,7 @@ const showExpireTime = computed(
</script>
<template>
<div class="flex flex-col gap-y-24px w-full min-w-300px md:min-w-[500px] max-w-xl p-16px">
<div class="flex flex-col grow gap-y-24px p-16px">
<header>
<h2 class="text-24px text-center font-semibold" v-html="t(stateData.heading)" />
<div

View File

@@ -0,0 +1,23 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { DropdownMenu, DropdownMenuArrow, DropdownMenuContent, DropdownMenuTrigger } from '@unraid/ui';
import type { ComposerTranslation } from 'vue-i18n';
defineProps<{ t: ComposerTranslation }>();
const open = ref(false);
</script>
<template>
<DropdownMenu v-model:open="open">
<DropdownMenuTrigger>
<slot name="trigger" />
</DropdownMenuTrigger>
<DropdownMenuContent :side-offset="4" :align="'end'" :side="'bottom'" class="w-[350px]">
<UpcDropdownContent :t="t" />
<DropdownMenuArrow :rounded="true" class="fill-popover" :height="10" :width="16" />
</DropdownMenuContent>
</DropdownMenu>
</template>

View File

@@ -1,13 +1,14 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia';
import {
Bars3Icon,
Bars3BottomRightIcon,
BellAlertIcon,
ExclamationTriangleIcon,
InformationCircleIcon,
ShieldExclamationIcon,
} from '@heroicons/vue/24/solid';
import type { ComposerTranslation } from 'vue-i18n';
import { useDropdownStore } from '~/store/dropdown';
@@ -15,7 +16,7 @@ import { useErrorsStore } from '~/store/errors';
import { useServerStore } from '~/store/server';
import { useUpdateOsStore } from '~/store/updateOs';
const props = defineProps<{ t: ComposerTranslation; }>();
const props = defineProps<{ t: ComposerTranslation }>();
const dropdownStore = useDropdownStore();
const { dropdownVisible } = storeToRefs(dropdownStore);
@@ -26,14 +27,22 @@ const { available: osUpdateAvailable } = storeToRefs(useUpdateOsStore());
const showErrorIcon = computed(() => errors.value.length || stateData.value.error);
const text = computed((): string => {
if ((stateData.value.error) && state.value !== 'EEXPIRED') { return props.t('Fix Error'); }
if (stateData.value.error && state.value !== 'EEXPIRED') {
return props.t('Fix Error');
}
return '';
});
const title = computed((): string => {
if (state.value === 'ENOKEYFILE') { return props.t('Get Started'); }
if (state.value === 'EEXPIRED') { return props.t('Trial Expired, see options below'); }
if (showErrorIcon.value) { return props.t('Learn more about the error'); }
if (state.value === 'ENOKEYFILE') {
return props.t('Get Started');
}
if (state.value === 'EEXPIRED') {
return props.t('Trial Expired, see options below');
}
if (showErrorIcon.value) {
return props.t('Learn more about the error');
}
return dropdownVisible.value ? props.t('Close Dropdown') : props.t('Open Dropdown');
});
</script>
@@ -45,19 +54,32 @@ const title = computed((): string => {
@click="dropdownStore.dropdownToggle()"
>
<template v-if="errors.length && errors[0].level">
<InformationCircleIcon v-if="errors[0].level === 'info'" class="text-unraid-red fill-current relative w-24px h-24px" />
<ExclamationTriangleIcon v-if="errors[0].level === 'warning'" class="text-unraid-red fill-current relative w-24px h-24px" />
<ShieldExclamationIcon v-if="errors[0].level === 'error'" class="text-unraid-red fill-current relative w-24px h-24px" />
<InformationCircleIcon
v-if="errors[0].level === 'info'"
class="text-unraid-red fill-current relative w-24px h-24px"
/>
<ExclamationTriangleIcon
v-if="errors[0].level === 'warning'"
class="text-unraid-red fill-current relative w-24px h-24px"
/>
<ShieldExclamationIcon
v-if="errors[0].level === 'error'"
class="text-unraid-red fill-current relative w-24px h-24px"
/>
</template>
<span v-if="text" class="relative leading-none ">
<span v-if="text" class="relative leading-none">
<span>{{ text }}</span>
<span class="absolute bottom-[-3px] inset-x-0 h-2px w-full bg-gradient-to-r from-unraid-red to-orange rounded opacity-0 group-hover:opacity-100 group-focus:opacity-100 transition-opacity" />
<span
class="absolute bottom-[-3px] inset-x-0 h-2px w-full bg-gradient-to-r from-unraid-red to-orange rounded opacity-0 group-hover:opacity-100 group-focus:opacity-100 transition-opacity"
/>
</span>
<BellAlertIcon v-if="osUpdateAvailable && !rebootType" class="hover:animate-pulse fill-current relative w-16px h-16px" />
<BellAlertIcon
v-if="osUpdateAvailable && !rebootType"
class="hover:animate-pulse fill-current relative w-16px h-16px"
/>
<Bars3Icon v-if="!dropdownVisible" class="w-20px" />
<Bars3BottomRightIcon v-else class="w-20px" />
<Bars3Icon class="w-20px" />
<BrandAvatar />
</button>

View File

@@ -1,159 +0,0 @@
<script lang="ts" setup>
/**
* @todo future idea turn this into a carousel. each feature could have a short video if we ever them
*/
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/solid';
import { Switch, SwitchGroup, SwitchLabel } from '@headlessui/vue';
import { CONNECT_DOCS } from '~/helpers/urls';
import type { UserProfilePromoFeature } from '~/types/userProfile';
import type { ComposerTranslation } from 'vue-i18n';
import useInstallPlugin from '~/composables/installPlugin';
import { usePromoStore } from '~/store/promo';
export interface Props {
open?: boolean;
t: ComposerTranslation;
}
withDefaults(defineProps<Props>(), {
open: false,
});
const promoStore = usePromoStore();
/**
* These are translated in the component below. So if you add a new feature, make sure to add it to the translation file.
*/
const features = ref<UserProfilePromoFeature[]>([
{
title: 'Dynamic Remote Access',
copy: 'Toggle on/off server accessibility with dynamic remote access. Automatically turn on UPnP and open a random WAN port on your router at the click of a button and close off access in seconds.',
},
{
title: 'Manage Your Server Within Connect',
copy: 'Servers equipped with a myunraid.net certificate can be managed directly from within the Connect web UI. Manage multiple servers from your phone, tablet, laptop, or PC in the same browser window.',
},
{
title: 'Deep Linking',
copy: 'The Connect dashboard links to relevant sections of the webgui, allowing quick access to those settings and server sections.',
},
{
title: 'Online Flash Backup',
copy: 'Never ever be left without a backup of your config. If you need to change flash drives, generate a backup from Connect and be up and running in minutes.',
},
{
title: 'Real-time Monitoring',
copy: "Get an overview of your server's state, storage space, apps and VMs status, and more.",
},
{
title: 'Customizable Dashboard Tiles',
copy: "Set custom server tiles how you like and automatically display your server's banner image on your Connect Dashboard.",
},
{
title: 'License Management',
copy: 'Manage your license keys at any time via the My Keys section.',
},
{
title: 'Plus more on the way',
copy: 'All you need is an active internet connection, an Unraid.net account, and the Connect plugin. Get started by installing the plugin.',
},
]);
const staging = ref(false);
const connectPluginUrl = computed((): string => {
const url = new URL(
`https://sfo2.digitaloceanspaces.com/unraid-dl/unraid-api/dynamix.unraid.net${staging.value ? '.staging.plg' : '.plg'}`
);
return url.toString();
});
const { install } = useInstallPlugin();
</script>
<template>
<Modal
:t="t"
:title="t('Introducing Unraid Connect')"
:description="t('Enhance your Unraid experience')"
:open="open"
:show-close-x="true"
max-width="max-w-800px"
@close="promoStore.promoHide()"
>
<template #headerTitle>
<span><UpcBeta class="relative -top-1" /></span>
</template>
<template #main>
<div class="text-center relative w-full">
<div class="grid grid-cols-1 sm:grid-cols-2 justify-center p-16px md:py-24px gap-16px">
<UpcPromoFeature
v-for="(feature, index) in features"
:key="index"
:title="t(feature.title)"
:copy="t(feature.copy)"
/>
</div>
</div>
</template>
<template #footer>
<div class="w-full max-w-xs flex flex-col items-center gap-y-16px mx-auto">
<SwitchGroup v-if="import.meta.env.DEV" as="div" class="flex items-center justify-center">
<Switch
v-model="staging"
:class="[
staging ? 'bg-indigo-600' : 'bg-gray-200',
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2',
]"
>
<span
aria-hidden="true"
:class="[
staging ? 't-x-5' : 't-x-0',
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
]"
/>
</Switch>
<SwitchLabel as="span" class="ml-3 text-12px">
<span class="font-semibold">Install Staging</span>
</SwitchLabel>
</SwitchGroup>
<button
class="text-white text-14px text-center w-full flex flex-row items-center justify-center gap-x-8px px-8px py-8px cursor-pointer rounded-md bg-gradient-to-r from-unraid-red to-orange hover:from-unraid-red/60 hover:to-orange/60 focus:from-unraid-red/60 focus:to-orange/60"
@click="install({ pluginUrl: connectPluginUrl, modalTitle: t('Installing Connect') })"
>
{{ staging ? 'Install Connect Staging' : t('Install Connect') }}
</button>
<div>
<a
:href="CONNECT_DOCS.toString()"
class="text-12px tracking-wide inline-flex flex-row items-center justify-start gap-8px mx-8px opacity-60 hover:opacity-100 focus:opacity-100 underline transition"
target="_blank"
rel="noopener noreferrer"
:title="t('Checkout the Connect Documentation')"
>
{{ t('Learn More') }}
<ArrowTopRightOnSquareIcon class="w-16px" />
</a>
<button
class="text-12px tracking-wide inline-block mx-8px opacity-60 hover:opacity-100 focus:opacity-100 underline transition"
:title="t('Close')"
@click="promoStore.promoHide()"
>
{{ t('No thanks') }}
</button>
</div>
</div>
</template>
</Modal>
</template>
<style lang="postcss">
/* Import unraid-ui globals first */
@import '@unraid/ui/styles';
@import '~/assets/main.css';
</style>

View File

@@ -1,39 +0,0 @@
<script setup lang="ts">
export interface Props {
center?: boolean;
copy?: string;
icon?: string;
title?: string;
}
defineProps<Props>();
</script>
<template>
<div class="text-left relative flex overflow-hidden">
<span v-if="!center" class="flex-shrink-0">
<slot />
</span>
<div class="inline-flex flex-col" :class="{ 'text-center': center }">
<h3
class="text-16px font-semibold"
:class="{
'mt-0 mb-4px': copy,
'my-0': !copy,
'flex flex-row justify-center items-center': center
}"
>
<span v-if="center" class="flex-shrink-0 mr-8px">
<slot />
</span>
{{ title }}
</h3>
<p
v-if="copy"
class="text-14px opacity-75 py-0"
:class="{'px-8px': center}"
v-html="copy"
/>
</div>
</div>
</template>