implement settings page

it was painful but it is done, just needs a bit more styling
This commit is contained in:
MiniDigger | Martin 2022-04-02 16:09:24 +02:00
parent 53580da032
commit 81f4c679cf
11 changed files with 511 additions and 30 deletions

View File

@ -153,10 +153,10 @@ once QA has passed, the checkboxes can be removed and the page can be ~~striked
- [x] design
- [ ] qa
* settings
- [ ] fetch
- [ ] layout
- [ ] functionality
- [ ] design
- [x] fetch
- [x] layout
- [x] functionality
- [ ] design (needs styling the tabs)
- [ ] qa
* stars
- [x] fetch
@ -192,9 +192,9 @@ once QA has passed, the checkboxes can be removed and the page can be ~~striked
- [ ] qa
- [version]
- [x] fetch
- [ ] layout (do we wanna do tabs again?)
- [x] layout
- [x] functionality
- [ ] design
- [x] design
- [ ] qa
* [platform] (empty)
- index
@ -257,7 +257,7 @@ once QA has passed, the checkboxes can be removed and the page can be ~~striked
- versions
- [x] fetch
- [x] layout
- [ ] functionality (expansion missing)
- [x] functionality
- [x] design
- [ ] qa
- user (empty)

View File

@ -0,0 +1,39 @@
<script lang="ts" setup>
import { computed } from "vue";
import Link from "~/components/design/Link.vue";
const emit = defineEmits<{
(e: "update:modelValue", value: string): void;
}>();
const internalValue = computed({
get: () => props.modelValue,
set: (value) => emit("update:modelValue", value),
});
interface Tab {
value: string;
header: string;
}
const props = defineProps<{
modelValue: string;
tabs: Tab[];
}>();
</script>
<template>
<div class="flex gap-2">
<div>
<ul>
<li v-for="tab in tabs" :key="tab.value">
<Link :href="'#' + tab.value" @click.prevent="internalValue = tab.value">{{ tab.header }}</Link>
</li>
</ul>
</div>
<div>
<template v-for="tab in tabs" :key="tab.value">
<slot v-if="internalValue === tab.value" :name="tab.value" />
</template>
</div>
</div>
</template>

View File

@ -0,0 +1,39 @@
<script lang="ts" setup>
import { TranslateResult, useI18n } from "vue-i18n";
import Button from "~/components/design/Button.vue";
import Modal from "~/components/modals/Modal.vue";
import { ref } from "vue";
import InputTextarea from "~/components/ui/InputTextarea.vue";
const props = defineProps<{
title: string | TranslateResult;
label: string;
submit: (msg: string) => Promise<void>;
}>();
const message = ref("");
const loading = ref(false);
const i18n = useI18n();
async function _submit(close: () => void) {
loading.value = true;
await props.submit(message.value);
loading.value = false;
close();
}
</script>
<template>
<Modal :title="props.title">
<template #default="{ on }">
<InputTextarea v-model.trim="message" :label="label" :rows="2" @keydown.enter.prevent="" />
<Button class="mt-2" v-on="on">{{ i18n.t("general.close") }}</Button>
<Button class="mt-2 ml-2" :disabled="loading" @click="_submit(on.click)">{{ i18n.t("general.submit") }}</Button>
</template>
<template #activator="{ on }">
<slot name="activator" :on="on"></slot>
</template>
</Modal>
</template>

View File

@ -53,8 +53,8 @@ async function submit(closeModal: () => void): Promise<void> {
<Button class="mt-2 ml-2" @click="submit(on.click)">{{ i18n.t("general.submit") }}</Button>
</template>
<template #activator="{ on }">
<Button v-bind="$attrs" class="mr-1" v-on="on">
<IconMdiEye />
<Button v-bind="$attrs" class="inline-flex items-center mr-1" v-on="on">
<IconMdiEye class="mr-1" />
{{ i18n.t("visibility.modal.activatorBtn") }}
</Button>
</template>

View File

@ -0,0 +1,20 @@
<script lang="ts" setup>
import { computed } from "vue";
const emit = defineEmits<{
(e: "update:modelValue", file: string): void;
}>();
const file = computed({
get: () => props.modelValue,
set: (value) => emit("update:modelValue", value),
});
const props = defineProps<{
modelValue: string;
showSize?: boolean;
}>();
</script>
<template>
<!-- todo make fancy, implement functionality -->
<input type="file" v-bind="$attrs" />
</template>

View File

@ -2,7 +2,7 @@
import { computed } from "vue";
const emit = defineEmits<{
(e: "update:modelValue", value: object | string | boolean | number | null): void;
(e: "update:modelValue", value: object | string | boolean | number | null | undefined): void;
}>();
const internalVal = computed({
get: () => props.modelValue,
@ -15,7 +15,7 @@ export interface Option {
}
const props = defineProps<{
modelValue: object | string | boolean | number | null;
modelValue: object | string | boolean | number | null | undefined;
values: Option[];
disabled?: boolean;
label?: string;

View File

@ -11,14 +11,19 @@ const tags = computed({
});
const props = defineProps<{
modelValue: string[];
label?: string;
counter?: boolean;
maxlength?: number;
}>();
if (!tags.value) tags.value = [];
function remove(t: string) {
tags.value = tags.value.filter((v) => v != t);
}
function add() {
if (tag.value) {
if (tag.value && (!props.maxlength || tags.value.length < props.maxlength)) {
tags.value.push(tag.value);
tag.value = "";
}
@ -26,12 +31,15 @@ function add() {
</script>
<template>
<div>
<label>
<span v-if="label">{{ label }}</span>
<span v-for="t in tags" :key="t" class="bg-gray-200 rounded-4xl px-2 py-1 mx-1 inline-flex items-center" dark="text-black">
{{ t }}
<button class="text-gray-400 ml-1 inline-flex" hover="text-gray-500" @click="remove(t)"><icon-mdi-close-circle /></button>
</span>
<!-- todo style the input -->
<input v-model="tag" type="text" dark="text-black" @keydown.enter="add" />
</div>
<span v-if="counter && maxlength">{{ tags?.length || 0 }}/{{ maxlength }}</span>
<span v-else-if="counter">{{ tags?.length || 0 }}</span>
</label>
</template>

View File

@ -2,7 +2,7 @@
import { computed } from "vue";
const emit = defineEmits<{
(e: "update:modelValue", value: string): void;
(e: "update:modelValue", value?: string): void;
}>();
const value = computed({
get: () => props.modelValue,

View File

@ -2,16 +2,187 @@
import { useHead } from "@vueuse/head";
import { useSeo } from "~/composables/useSeo";
import { projectIconUrl } from "~/composables/useUrlHelper";
import { useRoute } from "vue-router";
import { useRoute, useRouter } from "vue-router";
import { HangarProject } from "hangar-internal";
import { useI18n } from "vue-i18n";
import Card from "~/components/design/Card.vue";
import MemberList from "~/components/projects/MemberList.vue";
import VisibilityChangerModal from "~/components/modals/VisibilityChangerModal.vue";
import { hasPerms } from "~/composables/usePerm";
import { NamedPermission } from "~/types/enums";
import Button from "~/components/design/Button.vue";
import Tabs from "~/components/design/Tabs.vue";
import { computed, reactive, ref, watch } from "vue";
import InputSelect, { Option } from "~/components/ui/InputSelect.vue";
import { useBackendDataStore } from "~/store/backendData";
import InputText from "~/components/ui/InputText.vue";
import { cloneDeep } from "lodash-es";
import InputCheckbox from "~/components/ui/InputCheckbox.vue";
import InputFile from "~/components/ui/InputFile.vue";
import { useInternalApi } from "~/composables/useApi";
import { handleRequestError } from "~/composables/useErrorHandling";
import { useContext } from "vite-ssr/vue";
import { useNotificationStore } from "~/store/notification";
import InputTag from "~/components/ui/InputTag.vue";
import TextAreaModal from "~/components/modals/TextAreaModal.vue";
const route = useRoute();
const router = useRouter();
const i18n = useI18n();
const ctx = useContext();
const backendData = useBackendDataStore();
const props = defineProps<{
project: HangarProject;
}>();
const selectedTab = ref(route.hash.substring(1) || "general");
const tabs = [
{ value: "general", header: i18n.t("project.settings.tabs.general") },
{ value: "optional", header: i18n.t("project.settings.tabs.optional") },
{ value: "management", header: i18n.t("project.settings.tabs.management") },
{ value: "donation", header: i18n.t("project.settings.tabs.donation") },
];
const form = reactive({
settings: cloneDeep(props.project.settings),
description: props.project.description,
category: props.project.category,
});
const projectIcon = ref<File | null>(null);
const newName = ref<string | null>("");
const nameErrors = ref<string[]>([]);
const loading = reactive({
save: false,
uploadIcon: false,
resetIcon: false,
rename: false,
});
const isCustomLicense = computed(() => form.settings.license.type === "(custom)");
const licenses = computed<Option[]>(() =>
backendData.licenses.map<Option>((l) => {
return { value: l, text: l };
})
);
const categories = computed<Option[]>(() =>
backendData.visibleCategories.map<Option>((c) => {
return { value: c.apiName, text: i18n.t(c.title) };
})
);
watch(route, (val) => (selectedTab.value = val.hash.replace("#", "")), { deep: true });
watch(selectedTab, (val) => history.replaceState({}, "", route.path + "#" + val));
watch(newName, async (val) => {
if (!val) {
nameErrors.value = [];
} else {
try {
await useInternalApi("projects/validateName", false, "get", {
userId: props.project.owner.userId,
value: val,
});
nameErrors.value = [];
} catch (err) {
nameErrors.value = [];
if (!err.response?.data.isHangarApiException) {
return;
}
nameErrors.value.push(i18n.t(err.response.data.message));
}
}
});
async function save() {
loading.save = true;
try {
await useInternalApi(`projects/project/${route.params.user}/${route.params.project}/settings`, true, "post", {
...form,
});
await router.go(0);
} catch (e) {
handleRequestError(e, ctx, i18n);
}
loading.save = false;
}
async function rename() {
loading.rename = true;
try {
const newSlug = await useInternalApi<string>(`projects/project/${route.params.user}/${route.params.project}/rename`, true, "post", {
content: newName.value,
});
useNotificationStore().success(i18n.t("project.settings.success.rename", [newName.value]));
await router.push(route.params.user + "/" + newSlug);
} catch (e) {
handleRequestError(e, ctx, i18n);
}
loading.rename = false;
}
async function softDelete(comment: string) {
try {
await useInternalApi(`projects/project/${props.project.id}/manage/delete`, true, "post", {
content: comment,
});
useNotificationStore().success(i18n.t("project.settings.success.softDelete"));
await router.go(0);
} catch (e) {
handleRequestError(e, ctx, i18n);
}
}
async function hardDelete(comment: string) {
try {
await useInternalApi(`projects/project/${props.project.id}/manage/hardDelete`, true, "post", {
content: comment,
});
useNotificationStore().success(i18n.t("project.settings.success.hardDelete"));
await router.push("/");
} catch (e) {
handleRequestError(e, ctx, i18n);
}
}
async function uploadIcon() {
const data = new FormData();
data.append("projectIcon", projectIcon.value!);
loading.uploadIcon = true;
try {
await useInternalApi(`projects/project/${route.params.user}/${route.params.project}/saveIcon`, true, "post", data);
} catch (e) {
handleRequestError(e, ctx, i18n);
}
loading.uploadIcon = false;
}
async function resetIcon() {
loading.resetIcon = true;
try {
await useInternalApi(`projects/project/${route.params.user}/${route.params.project}/resetIcon`, true, "post");
useNotificationStore().success(i18n.t("project.settings.success.resetIcon"));
document
.getElementById("project-icon-preview")!
.setAttribute("src", `${projectIconUrl(props.project.namespace.owner, props.project.namespace.slug)}?noCache=${Math.random()}`);
await router.go(0);
} catch (e) {
handleRequestError(e, ctx, i18n);
}
loading.resetIcon = false;
}
function onFileChange() {
if (projectIcon.value) {
const reader = new FileReader();
reader.onload = (ev) => {
document.getElementById("project-icon-preview")!.setAttribute("src", ev.target!.result as string);
};
reader.readAsDataURL(projectIcon.value);
} else {
document.getElementById("project-icon-preview")!.setAttribute("src", projectIconUrl(props.project.namespace.owner, props.project.namespace.slug));
}
}
useHead(
useSeo(
i18n.t("project.settings.title") + " | " + props.project.name,
@ -23,7 +194,217 @@ useHead(
</script>
<template>
<h1>settings</h1>
<div class="flex gap-4">
<Card class="basis-full md:basis-9/12">
<template #header>
<div class="flex justify-between">
{{ i18n.t("project.settings.title") }}
<div class="text-lg">
<VisibilityChangerModal
v-if="hasPerms(NamedPermission.SEE_HIDDEN)"
type="project"
:prop-visibility="project.visibility"
:post-url="`projects/visibility/${project.id}`"
></VisibilityChangerModal>
<Button class="inline-flex items-center ml-2" @click="save">
<IconMdiCheck />
{{ i18n.t("project.settings.save") }}
</Button>
</div>
</div>
</template>
<!-- setting icons -->
<Tabs v-model="selectedTab" :tabs="tabs">
<template #general>
<div>
<h2 class="text-lg">{{ i18n.t("project.settings.category") }}</h2>
<p>{{ i18n.t("project.settings.categorySub") }}</p>
<InputSelect v-model="form.category" :values="categories" />
</div>
<hr class="my-1" />
<div>
<h2 class="text-lg">{{ i18n.t("project.settings.description") }}</h2>
<p>{{ i18n.t("project.settings.descriptionSub") }}</p>
<InputText v-model="form.description" counter :maxlength="backendData.validations?.project?.desc?.max" />
</div>
<hr class="my-1" />
<div>
<h2 class="text-lg">{{ i18n.t("project.settings.forum") }}</h2>
<InputCheckbox v-model="form.settings.forumSync" :label="i18n.t('project.settings.forumSub')" />
</div>
<hr class="my-1" />
<div>
<h2 class="text-lg">{{ i18n.t("project.settings.icon") }}</h2>
<div class="flex">
<div>
<p>{{ i18n.t("project.settings.iconSub") }}</p>
<InputFile v-model="projectIcon" accept="image/png, image/jpeg" show-size @change="onFileChange" />
<Button :disabled="!projectIcon || loading.uploadIcon" @click="uploadIcon">
<IconMdiUpload />
{{ i18n.t("project.settings.iconUpload") }}
</Button>
<Button :disabled="loading.resetIcon" @click="resetIcon">
<IconMdiUpload />
{{ i18n.t("project.settings.iconReset") }}
</Button>
</div>
<div>
<img
id="project-icon-preview"
width="150"
height="150"
alt="Project Icon"
:src="projectIconUrl(project.namespace.owner, project.namespace.slug)"
/>
</div>
</div>
</div>
</template>
<template #optional>
<div>
<h2 class="text-lg">
{{ i18n.t("project.settings.keywords") }}&nbsp;<small>{{ i18n.t("project.settings.optional") }}</small>
</h2>
<p>{{ i18n.t("project.settings.keywordsSub") }}</p>
<InputTag
v-model="form.settings.keywords"
counter
:maxlength="backendData.validations.project.keywords.max"
:label="i18n.t('project.new.step3.keywords')"
/>
</div>
<hr class="my-1" />
<div>
<h2 class="text-lg">
{{ i18n.t("project.settings.homepage") }}&nbsp;<small>{{ i18n.t("project.settings.optional") }}</small>
</h2>
<p>{{ i18n.t("project.settings.homepageSub") }}</p>
<InputText v-model.trim="form.settings.homepage" :label="i18n.t('project.new.step3.homepage')"></InputText>
</div>
<hr class="my-1" />
<div>
<h2 class="text-lg">
{{ i18n.t("project.settings.issues") }}&nbsp;<small>{{ i18n.t("project.settings.optional") }}</small>
</h2>
<p>{{ i18n.t("project.settings.issuesSub") }}</p>
<InputText v-model.trim="form.settings.issues" :label="i18n.t('project.new.step3.issues')" />
</div>
<hr class="my-1" />
<div>
<h2 class="text-lg">
{{ i18n.t("project.settings.source") }}&nbsp;<small>{{ i18n.t("project.settings.optional") }}</small>
</h2>
<p>{{ i18n.t("project.settings.sourceSub") }}</p>
<InputText v-model.trim="form.settings.source" :label="i18n.t('project.new.step3.source')" />
</div>
<hr class="my-1" />
<div>
<h2 class="text-lg">
{{ i18n.t("project.settings.support") }}&nbsp;<small>{{ i18n.t("project.settings.optional") }}</small>
</h2>
<p>{{ i18n.t("project.settings.supportSub") }}</p>
<InputText v-model.trim="form.settings.support" :label="i18n.t('project.new.step3.support')" />
</div>
<hr class="my-1" />
<div>
<h2 class="text-lg">
{{ i18n.t("project.settings.license") }}&nbsp;<small>{{ i18n.t("project.settings.optional") }}</small>
</h2>
<p>{{ i18n.t("project.settings.licenseSub") }}</p>
<div class="flex">
<div class="basis-full" :md="isCustomLicense ? 'basis-4/12' : 'basis-6/12'">
<InputSelect v-model="form.settings.license.type" :values="licenses" :label="i18n.t('project.settings.licenseType')" />
</div>
<div v-if="isCustomLicense" class="basis-full md:basis-8/12">
<InputText v-model.trim="form.settings.license.name" :label="i18n.t('project.settings.licenseCustom')" />
</div>
<div class="basis-full" :md="isCustomLicense ? 'basis-full' : 'basis-6/12'">
<InputText v-model.trim="form.settings.license.url" :label="i18n.t('project.settings.licenseUrl')" />
</div>
</div>
</div>
</template>
<template #management>
<div>
<h2 class="text-lg">{{ i18n.t("project.settings.rename") }}</h2>
<p>{{ i18n.t("project.settings.renameSub") }}</p>
<div class="flex">
<InputText v-model.trim="newName" :error-messages="nameErrors" />
<Button :disabled="!newName || loading.rename || nameErrors.length > 0" class="inline-flex items-center ml-2" @click="rename">
<IconMdiRenameBox class="mr-2" />
{{ i18n.t("project.settings.rename") }}
</Button>
</div>
</div>
<hr class="my-1" />
<div v-if="hasPerms(NamedPermission.DELETE_PROJECT)">
<div class="flex">
<div class="flex-shrink">
<h2 class="text-lg">{{ i18n.t("project.settings.delete") }}</h2>
<p>{{ i18n.t("project.settings.deleteSub") }}</p>
</div>
<div class="flex-grow">
<TextAreaModal :title="i18n.t('project.settings.delete')" :label="i18n.t('general.comment')" :submit="softDelete">
<template #activator="{ on }">
<Button v-on="on">{{ i18n.t("project.settings.delete") }}</Button>
</template>
</TextAreaModal>
</div>
</div>
</div>
<hr v-if="hasPerms(NamedPermission.HARD_DELETE_PROJECT)" class="my-1" />
<div v-if="hasPerms(NamedPermission.HARD_DELETE_PROJECT)" class="bg-red-500 p-4">
<div class="flex">
<div class="flex-shrink">
<h2 class="text-lg">{{ i18n.t("project.settings.hardDelete") }}</h2>
<p>{{ i18n.t("project.settings.hardDeleteSub") }}</p>
</div>
<div class="flex-grow">
<TextAreaModal :title="i18n.t('project.settings.hardDelete')" :label="i18n.t('general.comment')" :submit="hardDelete">
<template #activator="{ on }">
<Button v-on="on">{{ i18n.t("project.settings.hardDelete") }}</Button>
</template>
</TextAreaModal>
</div>
</div>
</div>
</template>
<template #donation>
<div>
<h2 class="text-lg">{{ i18n.t("project.settings.donation.enable") }}</h2>
<p>{{ i18n.t("project.settings.donation.enableSub") }}</p>
<InputCheckbox v-model="form.settings.donation.enable" />
</div>
<hr class="my-1" />
<div>
<h2 class="text-lg">{{ i18n.t("project.settings.donation.email") }}</h2>
<p>{{ i18n.t("project.settings.donation.emailSub") }}</p>
<InputText v-model="form.settings.donation.email" />
</div>
<hr class="my-1" />
<div>
<h2 class="text-lg">{{ i18n.t("project.settings.donation.defaultAmount") }}</h2>
<p>{{ i18n.t("project.settings.donation.defaultAmountSub") }}</p>
<InputText v-model.number="form.settings.donation.defaultAmount" type="number" />
</div>
<hr class="my-1" />
<div>
<h2 class="text-lg">{{ i18n.t("project.settings.donation.oneTimeAmounts") }}</h2>
<p>{{ i18n.t("project.settings.donation.oneTimeAmountsSub") }}</p>
<InputTag v-model="form.settings.donation.oneTimeAmounts" />
</div>
<hr class="my-1" />
<div>
<h2 class="text-lg">{{ i18n.t("project.settings.donation.monthlyAmounts") }}</h2>
<p>{{ i18n.t("project.settings.donation.monthlyAmountsSub") }}</p>
<InputTag v-model="form.settings.donation.monthlyAmounts" />
</div>
</template>
</Tabs>
</Card>
<MemberList :model-value="project.members" class="basis-full md:basis-3/12" />
</div>
</template>
<route lang="yaml">

View File

@ -73,15 +73,15 @@ export const useBackendDataStore = defineStore("backendData", () => {
return convertToMap<Prompt, IPrompt>(promptsResult, (value) => value.name);
}, prompts);
await fetchIfNeeded(async () => await useInternalApi<string[]>("data/licenses", false), licenses);
await fetchIfNeeded(async () => useInternalApi<string[]>("data/licenses", false), licenses);
await fetchIfNeeded(async () => await useInternalApi<AnnouncementObject[]>("data/announcements", false), announcements);
await fetchIfNeeded(async () => useInternalApi<AnnouncementObject[]>("data/announcements", false), announcements);
await fetchIfNeeded(async () => await useInternalApi<IVisibility[]>("data/visibilities", false), visibilities);
await fetchIfNeeded(async () => useInternalApi<IVisibility[]>("data/visibilities", false), visibilities);
await fetchIfNeeded(async () => await useInternalApi("data/validations", false), validations);
await fetchIfNeeded(async () => useInternalApi("data/validations", false), validations);
await fetchIfNeeded(async () => await useInternalApi("data/orgRoles", false), validations);
await fetchIfNeeded(async () => useInternalApi("data/orgRoles", false), orgRoles);
} catch (e) {
console.error("ERROR FETCHING BACKEND DATA");
console.error(e);

View File

@ -4,22 +4,15 @@
declare module "vue" {
export interface GlobalComponents {
IconMdiAccountArrowRight: typeof import("~icons/mdi/account-arrow-right")["default"];
IconMdiCalendar: typeof import("~icons/mdi/calendar")["default"];
IconMdiCheck: typeof import("~icons/mdi/check")["default"];
IconMdiCheckboxBlankCircleOutline: typeof import("~icons/mdi/checkbox-blank-circle-outline")["default"];
IconMdiCheckCircle: typeof import("~icons/mdi/check-circle")["default"];
IconMdiCheckCircleOutline: typeof import("~icons/mdi/check-circle-outline")["default"];
IconMdiClipboardOutline: typeof import("~icons/mdi/clipboard-outline")["default"];
IconMdiClose: typeof import("~icons/mdi/close")["default"];
IconMdiCloseCircle: typeof import("~icons/mdi/close-circle")["default"];
IconMdiDelete: typeof import("~icons/mdi/delete")["default"];
IconMdiDiamondStone: typeof import("~icons/mdi/diamond-stone")["default"];
IconMdiDownload: typeof import("~icons/mdi/download")["default"];
IconMdiEye: typeof import("~icons/mdi/eye")["default"];
IconMdiFile: typeof import("~icons/mdi/file")["default"];
IconMdiFileFind: typeof import("~icons/mdi/file-find")["default"];
IconMdiFormatListNumbered: typeof import("~icons/mdi/format-list-numbered")["default"];
IconMdiHome: typeof import("~icons/mdi/home")["default"];
IconMdiKeyOutline: typeof import("~icons/mdi/key-outline")["default"];
IconMdiListStatus: typeof import("~icons/mdi/list-status")["default"];
@ -29,9 +22,10 @@ declare module "vue" {
IconMdiPencil: typeof import("~icons/mdi/pencil")["default"];
IconMdiPlay: typeof import("~icons/mdi/play")["default"];
IconMdiPlus: typeof import("~icons/mdi/plus")["default"];
IconMdiRenameBox: typeof import("~icons/mdi/rename-box")["default"];
IconMdiSortVariant: typeof import("~icons/mdi/sort-variant")["default"];
IconMdiStar: typeof import("~icons/mdi/star")["default"];
IconMdiTag: typeof import("~icons/mdi/tag")["default"];
IconMdiUpload: typeof import("~icons/mdi/upload")["default"];
IconMdiWeatherNight: typeof import("~icons/mdi/weather-night")["default"];
IconMdiWhiteBalanceSunny: typeof import("~icons/mdi/white-balance-sunny")["default"];
}