Refactor theme list modal to support extend (#4505)

#### What type of PR is this?

/area console
/kind feature
/milestone 2.9.x

#### What this PR does / why we need it:

重构主题管理的界面,支持扩展选项卡,同时做了一些 UI 的变更。

https://github.com/halo-dev/halo/pull/4505/files#diff-e8824d75ad964eebf685ad33b915b77d3146cd04587ec53328bbb5b5602ff094

#### Which issue(s) this PR fixes:

Fixes https://github.com/halo-dev/halo/issues/4496

#### Special notes for your reviewer:

需要测试主题的本地上传和远程下载的安装的更新。

#### Does this PR introduce a user-facing change?

```release-note
重构 Console 端主题管理界面,支持通过插件扩展选项卡。
```
pull/4521/head
Ryan Wang 2023-08-29 23:54:15 -05:00 committed by GitHub
parent d0f223e4d2
commit a819296945
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 883 additions and 891 deletions

View File

@ -0,0 +1,54 @@
# 主题管理界面选项卡扩展点
## 原由
目前在 Halo 的主题管理中原生支持本地上传和远程下载的方式安装主题,此扩展点用于扩展主题管理界面的选项卡,以支持更多的安装方式。
## 定义方式
> 此示例为添加一个安装选项卡用于从 GitHub 上下载主题。
```ts
import { definePlugin } from "@halo-dev/console-shared";
import { markRaw } from "vue";
import GitHubDownloadTab from "./components/GitHubDownloadTab.vue";
export default definePlugin({
extensionPoints: {
"theme:list:tabs:create": () => {
return [
{
id: "github",
label: "GitHub",
component: markRaw(GitHubDownload),
props: {
foo: "bar",
},
priority: 11,
},
];
},
},
});
```
扩展点类型:
```ts
"theme:list:tabs:create"?: () =>
| ThemeListTab[]
| Promise<ThemeListTab[]>;
```
`ThemeListTab`:
```ts
export interface ThemeListTab {
id: string; // 选项卡的唯一标识
label: string; // 选项卡的名称
component: Raw<Component>; // 选项卡面板的组件
props?: Record<string, unknown>; // 选项卡组件的 props
permissions?: string[]; // 权限
priority: number; // 优先级
}
```

View File

@ -9,3 +9,4 @@ export * from "./states/comment-subject-ref";
export * from "./states/backup";
export * from "./states/plugin-installation-tabs";
export * from "./states/entity";
export * from "./states/theme-list-tabs";

View File

@ -0,0 +1,10 @@
import type { Component, Raw } from "vue";
export interface ThemeListTab {
id: string;
label: string;
component: Raw<Component>;
props?: Record<string, unknown>;
permissions?: string[];
priority: number;
}

View File

@ -8,6 +8,7 @@ import type { CommentSubjectRefProvider } from "@/states/comment-subject-ref";
import type { BackupTab } from "@/states/backup";
import type { PluginInstallationTab } from "@/states/plugin-installation-tabs";
import type { EntityDropdownItem } from "@/states/entity";
import type { ThemeListTab } from "@/states/theme-list-tabs";
import type { Backup, ListedPost, Plugin } from "@halo-dev/api-client";
export interface RouteRecordAppend {
@ -50,6 +51,8 @@ export interface ExtensionPoint {
"backup:list-item:operation:create"?: () =>
| EntityDropdownItem<Backup>[]
| Promise<EntityDropdownItem<Backup>[]>;
"theme:list:tabs:create"?: () => ThemeListTab[] | Promise<ThemeListTab[]>;
}
export interface PluginModule {

View File

@ -609,27 +609,18 @@ core:
remote_download:
title: Remote download address detected, do you want to download?
description: "Please carefully verify whether this address can be trusted: {url}"
upload_modal:
titles:
install: Install theme
upgrade: Upgrade theme ({display_name})
operations:
existed_during_installation:
title: The theme already exists.
description: The currently installed theme already exists, do you want to upgrade?
tabs:
local: Local
remote:
title: Remote
fields:
url: Remote URL
existed_during_installation:
title: The theme already exists.
description: The currently installed theme already exists, do you want to upgrade?
list_modal:
titles:
installed_themes: Installed Themes
not_installed_themes: Not installed Themes
tabs:
installed: Installed
not_installed: Not installed
local_upload: Local install / upgrade
remote_download:
title: Remote
fields:
url: Remote URL
empty:
title: There are no installed themes currently.
message: There are currently no installed themes, you can try refreshing or installing a new theme.
@ -1220,6 +1211,7 @@ core:
add: Add
submit: Submit
detail: Detail
select: Select
radio:
"yes": Yes
"no": No

View File

@ -609,27 +609,18 @@ core:
remote_download:
title: 检测到了远程下载地址,是否需要下载?
description: 请仔细鉴别此地址是否可信:{url}
upload_modal:
titles:
install: 安装主题
upgrade: 升级主题({display_name}
operations:
existed_during_installation:
title: 主题已存在
description: 当前安装的主题已存在,是否升级?
tabs:
local: 本地上传
remote:
title: 远程下载
fields:
url: 下载地址
existed_during_installation:
title: 主题已存在
description: 当前安装的主题已存在,是否升级?
list_modal:
titles:
installed_themes: 已安装的主题
not_installed_themes: 未安装的主题
tabs:
installed: 已安装
not_installed: 未安装
not_installed: 本地未安装
local_upload: 上传安装 / 升级
remote_download:
label: 远程下载
fields:
url: 下载地址
empty:
title: 当前没有已安装的主题
message: 当前没有已安装的主题,你可以尝试刷新或者安装新主题
@ -1220,6 +1211,7 @@ core:
add: 添加
submit: 提交
detail: 详情
select: 选择
radio:
"yes":
"no":

View File

@ -609,27 +609,18 @@ core:
remote_download:
title: 偵測到遠端下載地址,是否需要下載?
description: 請仔細鑑別此地址是否可信:{url}
upload_modal:
titles:
install: 安裝主題
upgrade: 升級主題({display_name}
operations:
existed_during_installation:
title: 主題已存在
description: 當前安裝的主題已存在,是否升級?
tabs:
local: 本地上傳
remote:
title: 遠端下載
fields:
url: 下載地址
existed_during_installation:
title: 主題已存在
description: 當前安裝的主題已存在,是否升級?
list_modal:
titles:
installed_themes: 已安裝的主題
not_installed_themes: 未安裝的主題
tabs:
installed: 已安裝
not_installed: 未安裝
not_installed: 本地未安裝
local_upload: 上傳安裝 / 升級
remote_download:
label: 遠端下載
fields:
url: 下載地址
empty:
title: 當前沒有已安裝的主題
message: 當前沒有已安裝的主題,你可以嘗試刷新或者安裝新主題
@ -1220,6 +1211,7 @@ core:
add: 添加
submit: 提交
detail: 詳情
select: 選擇
radio:
"yes":
"no":

View File

@ -17,7 +17,6 @@ import {
VDescription,
VDescriptionItem,
} from "@halo-dev/components";
import ThemeUploadModal from "./components/ThemeUploadModal.vue";
// types
import type { Ref } from "vue";
@ -29,7 +28,7 @@ import { useI18n } from "vue-i18n";
const { t } = useI18n();
const selectedTheme = inject<Ref<Theme | undefined>>("selectedTheme", ref());
const upgradeModal = ref(false);
const themesModal = inject<Ref<boolean>>("themesModal");
const { isActivated, getFailedMessage, handleResetSettingConfig } =
useThemeLifeCycle(selectedTheme);
@ -59,12 +58,6 @@ const handleReloadTheme = async () => {
},
});
};
const onUpgradeModalClose = () => {
setTimeout(() => {
window.location.reload();
}, 200);
};
</script>
<template>
@ -110,7 +103,7 @@ const onUpgradeModalClose = () => {
<IconMore />
</div>
<template #popper>
<VDropdownItem @click="upgradeModal = true">
<VDropdownItem @click="themesModal = true">
{{ $t("core.common.buttons.upgrade") }}
</VDropdownItem>
<VDropdownDivider />
@ -168,9 +161,4 @@ const onUpgradeModalClose = () => {
</div>
</div>
</Transition>
<ThemeUploadModal
v-model:visible="upgradeModal"
:upgrade-theme="selectedTheme"
@close="onUpgradeModalClose"
/>
</template>

View File

@ -0,0 +1,284 @@
<script lang="ts" setup>
import {
VTag,
VStatusDot,
Dialog,
Toast,
VDropdownItem,
VDropdown,
VDropdownDivider,
VButton,
VSpace,
IconMore,
} from "@halo-dev/components";
import type { Theme } from "@halo-dev/api-client";
import { apiClient } from "@/utils/api-client";
import { toRefs, ref, inject, type Ref } from "vue";
import { useThemeLifeCycle } from "../composables/use-theme";
import { usePermission } from "@/utils/permission";
import { useI18n } from "vue-i18n";
import { useQueryClient } from "@tanstack/vue-query";
const { currentUserHasPermission } = usePermission();
const { t } = useI18n();
const queryClient = useQueryClient();
const props = withDefaults(
defineProps<{
theme: Theme;
installed?: boolean;
isSelected?: boolean;
}>(),
{
installed: true,
isSelected: false,
}
);
const emit = defineEmits<{
(event: "upgrade"): void;
(event: "preview"): void;
(event: "select", theme: Theme): void;
}>();
const { theme } = toRefs(props);
const activeTabId = inject<Ref<string>>("activeTabId", ref(""));
const {
isActivated,
getFailedMessage,
handleActiveTheme,
handleResetSettingConfig,
} = useThemeLifeCycle(theme);
const handleUninstall = async (theme: Theme, deleteExtensions?: boolean) => {
Dialog.warning({
title: `${
deleteExtensions
? t("core.theme.operations.uninstall_and_delete_config.title")
: t("core.theme.operations.uninstall.title")
}`,
description: t("core.common.dialog.descriptions.cannot_be_recovered"),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
try {
await apiClient.extension.theme.deletethemeHaloRunV1alpha1Theme({
name: theme.metadata.name,
});
// delete theme setting and configMap
if (deleteExtensions) {
const { settingName, configMapName } = theme.spec;
if (settingName) {
await apiClient.extension.setting.deletev1alpha1Setting(
{
name: settingName,
},
{
mute: true,
}
);
}
if (configMapName) {
await apiClient.extension.configMap.deletev1alpha1ConfigMap(
{
name: configMapName,
},
{
mute: true,
}
);
}
}
Toast.success(t("core.common.toast.uninstall_success"));
} catch (e) {
console.error("Failed to uninstall theme", e);
} finally {
queryClient.invalidateQueries({ queryKey: ["installed-themes"] });
}
},
});
};
// Creating theme
const creating = ref(false);
const handleCreateTheme = async () => {
try {
creating.value = true;
const { data } =
await apiClient.extension.theme.createthemeHaloRunV1alpha1Theme({
theme: props.theme,
});
// create theme settings
apiClient.theme.reload({ name: data.metadata.name });
activeTabId.value = "installed";
Toast.success(t("core.common.toast.install_success"));
} catch (error) {
console.error("Failed to create theme", error);
} finally {
creating.value = false;
queryClient.invalidateQueries({ queryKey: ["installed-themes"] });
queryClient.invalidateQueries({ queryKey: ["not-installed-themes"] });
}
};
</script>
<template>
<div
class="group relative flex grid-cols-1 flex-col overflow-hidden rounded bg-white p-0 shadow-sm transition-all duration-500 hover:shadow-md hover:ring-1 sm:grid sm:grid-cols-7 sm:p-2"
:class="{ 'ring-1': isSelected }"
>
<div class="col-span-2">
<div class="relative block">
<div class="aspect-h-9 aspect-w-16">
<div
class="transform-gpu rounded-none bg-cover bg-center bg-no-repeat sm:rounded"
:style="{
backgroundImage: `url(${theme.spec.logo})`,
}"
>
<div
class="flex h-full w-full items-center justify-center rounded-none backdrop-blur-3xl sm:rounded"
>
<img class="h-16 w-16 rounded" :src="theme.spec.logo" />
</div>
</div>
</div>
</div>
</div>
<div
class="relative col-span-5 grid grid-cols-1 content-between p-2 sm:px-4 sm:py-1"
>
<div>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="inline-flex items-center gap-2">
<div
class="relative block cursor-pointer text-sm font-medium text-black transition-all hover:text-gray-600 hover:underline sm:text-base"
@click="emit('select', theme)"
>
{{ theme.spec.displayName }}
</div>
<span class="text-xs text-gray-500 sm:text-sm">
{{ theme.spec.version }}
</span>
<VTag v-if="isActivated" theme="primary">
{{ $t("core.common.status.activated") }}
</VTag>
</div>
<div>
<VStatusDot
v-if="getFailedMessage()"
v-tooltip="getFailedMessage()"
state="warning"
animate
/>
<VStatusDot
v-if="theme.metadata.deletionTimestamp"
v-tooltip="$t('core.common.status.deleting')"
state="warning"
animate
/>
</div>
</div>
<p
class="mt-2 line-clamp-1 text-xs font-normal text-gray-500 sm:text-sm"
>
{{ theme.spec.description }}
</p>
</div>
<div
class="mt-4 flex w-full flex-1 items-center justify-between gap-2 sm:mt-0"
>
<div v-if="theme.spec.author" class="inline-flex items-center gap-1.5">
<a
v-if="theme.spec.author.website"
class="text-xs text-gray-700 hover:text-gray-900"
:href="theme.spec.author.website"
target="_blank"
>
{{ theme.spec.author.name }}
</a>
<span v-else class="text-xs text-gray-700">
{{ theme.spec.author.name }}
</span>
</div>
<div>
<VSpace v-if="installed">
<VButton
v-if="
!isActivated &&
currentUserHasPermission(['system:themes:manage'])
"
size="sm"
@click="handleActiveTheme(true)"
>
{{ $t("core.common.buttons.activate") }}
</VButton>
<VButton size="sm" @click="emit('select', theme)">
{{ $t("core.common.buttons.select") }}
</VButton>
<VDropdown
v-if="currentUserHasPermission(['system:themes:manage'])"
>
<VButton size="sm">
<IconMore />
</VButton>
<template #popper>
<VDropdownItem @click="emit('preview')">
{{ $t("core.common.buttons.preview") }}
</VDropdownItem>
<VDropdownDivider />
<VDropdown placement="right" :triggers="['click']">
<VDropdownItem type="danger">
{{ $t("core.common.buttons.uninstall") }}
</VDropdownItem>
<template #popper>
<VDropdownItem
type="danger"
@click="handleUninstall(theme)"
>
{{ $t("core.common.buttons.uninstall") }}
</VDropdownItem>
<VDropdownItem
type="danger"
@click="handleUninstall(theme, true)"
>
{{
$t(
"core.theme.operations.uninstall_and_delete_config.button"
)
}}
</VDropdownItem>
</template>
</VDropdown>
<VDropdownItem type="danger" @click="handleResetSettingConfig">
{{ $t("core.common.buttons.reset") }}
</VDropdownItem>
</template>
</VDropdown>
</VSpace>
<VButton
v-if="
!installed && currentUserHasPermission(['system:themes:manage'])
"
size="sm"
:loading="creating"
@click="handleCreateTheme"
>
{{ $t("core.common.buttons.install") }}
</VButton>
</div>
</div>
</div>
</div>
</template>

View File

@ -1,111 +1,91 @@
<script lang="ts" setup>
import { VButton, VModal, VTabbar } from "@halo-dev/components";
import {
IconAddCircle,
IconGitHub,
VButton,
VEmpty,
VModal,
VSpace,
VEntity,
VEntityField,
VTabItem,
VTabs,
VLoading,
Toast,
} from "@halo-dev/components";
import LazyImage from "@/components/image/LazyImage.vue";
import ThemePreviewModal from "./preview/ThemePreviewModal.vue";
import ThemeUploadModal from "./ThemeUploadModal.vue";
import ThemeListItem from "./components/ThemeListItem.vue";
import { computed, ref, nextTick, watch } from "vue";
computed,
ref,
watch,
provide,
inject,
markRaw,
nextTick,
onMounted,
type Ref,
} from "vue";
import type { Theme } from "@halo-dev/api-client";
import { apiClient } from "@/utils/api-client";
import { useI18n } from "vue-i18n";
import { useQuery } from "@tanstack/vue-query";
import { useRouteQuery } from "@vueuse/router";
import InstalledThemes from "./list-tabs/InstalledThemes.vue";
import NotInstalledThemes from "./list-tabs/NotInstalledThemes.vue";
import LocalUpload from "./list-tabs/LocalUpload.vue";
import RemoteDownload from "./list-tabs/RemoteDownload.vue";
import { usePluginModuleStore } from "@/stores/plugin";
import type { PluginModule, ThemeListTab } from "@halo-dev/console-shared";
const { t } = useI18n();
const props = withDefaults(
defineProps<{
visible: boolean;
selectedTheme?: Theme;
}>(),
{
visible: false,
selectedTheme: undefined,
}
);
const selectedTheme = inject<Ref<Theme | undefined>>("selectedTheme", ref());
watch(
() => selectedTheme.value,
(value, oldValue) => {
if (value && oldValue) {
emit("select", value);
onVisibleChange(false);
}
}
);
const emit = defineEmits<{
(event: "update:visible", visible: boolean): void;
(event: "close"): void;
(event: "update:selectedTheme", theme?: Theme): void;
(event: "select", theme: Theme | null): void;
(event: "select", theme: Theme | undefined): void;
}>();
const activeTab = ref("installed");
const themeUploadVisible = ref(false);
const creating = ref(false);
const tabs = ref<ThemeListTab[]>([
{
id: "installed",
label: t("core.theme.list_modal.tabs.installed"),
component: markRaw(InstalledThemes),
priority: 10,
},
{
id: "local-upload",
label: t("core.theme.list_modal.tabs.local_upload"),
component: markRaw(LocalUpload),
priority: 20,
},
{
id: "remote-download",
label: t("core.theme.list_modal.tabs.remote_download.label"),
component: markRaw(RemoteDownload),
priority: 30,
},
{
id: "not_installed",
label: t("core.theme.list_modal.tabs.not_installed"),
component: markRaw(NotInstalledThemes),
priority: 40,
},
]);
const activeTabId = ref();
provide<Ref<string>>("activeTabId", activeTabId);
const modalTitle = computed(() => {
return activeTab.value === "installed"
? t("core.theme.list_modal.titles.installed_themes")
: t("core.theme.list_modal.titles.not_installed_themes");
const tab = tabs.value.find((tab) => tab.id === activeTabId.value);
return tab?.label;
});
const {
data: themes,
isLoading,
isFetching,
refetch,
} = useQuery<Theme[]>({
queryKey: ["themes", activeTab],
queryFn: async () => {
const { data } = await apiClient.theme.listThemes({
page: 0,
size: 0,
uninstalled: activeTab.value !== "installed",
});
return data.items;
},
refetchInterval(data) {
if (activeTab.value !== "installed") {
return false;
}
const deletingThemes = data?.filter(
(theme) => !!theme.metadata.deletionTimestamp
);
return deletingThemes?.length ? 1000 : false;
},
enabled: computed(() => props.visible),
});
const handleCreateTheme = async (theme: Theme) => {
try {
creating.value = true;
const { data } =
await apiClient.extension.theme.createthemeHaloRunV1alpha1Theme({
theme,
});
// create theme settings
apiClient.theme.reload({ name: data.metadata.name });
activeTab.value = "installed";
Toast.success(t("core.common.toast.install_success"));
} catch (error) {
console.error("Failed to create theme", error);
} finally {
creating.value = false;
refetch();
}
};
const onVisibleChange = (visible: boolean) => {
emit("update:visible", visible);
if (!visible) {
@ -113,38 +93,6 @@ const onVisibleChange = (visible: boolean) => {
}
};
const handleSelectTheme = (theme: Theme) => {
emit("update:selectedTheme", theme);
emit("select", theme);
onVisibleChange(false);
};
defineExpose({
handleFetchThemes: refetch,
});
// preview
const previewVisible = ref(false);
const selectedPreviewTheme = ref<Theme>();
const handleOpenPreview = (theme: Theme) => {
selectedPreviewTheme.value = theme;
previewVisible.value = true;
};
// upgrade
const themeToUpgrade = ref<Theme>();
const handleOpenUpgradeModal = (theme: Theme) => {
themeToUpgrade.value = theme;
themeUploadVisible.value = true;
};
const handleOpenInstallModal = () => {
themeToUpgrade.value = undefined;
themeUploadVisible.value = true;
};
// handle remote wordpress url from route
const remoteDownloadUrl = useRouteQuery<string>("remote-download-url");
watch(
@ -152,215 +100,64 @@ watch(
(visible) => {
if (visible && remoteDownloadUrl.value) {
nextTick(() => {
handleOpenInstallModal();
activeTabId.value = "remote-download";
});
}
}
);
const { pluginModules } = usePluginModuleStore();
onMounted(() => {
const tabsFromPlugins: ThemeListTab[] = [];
pluginModules.forEach((pluginModule: PluginModule) => {
const { extensionPoints } = pluginModule;
if (!extensionPoints?.["theme:list:tabs:create"]) {
return;
}
const items = extensionPoints["theme:list:tabs:create"]() as ThemeListTab[];
tabsFromPlugins.push(...items);
});
tabs.value = tabs.value.concat(tabsFromPlugins).sort((a, b) => {
return a.priority - b.priority;
});
activeTabId.value = tabs.value[0].id;
});
</script>
<template>
<VModal
:body-class="['!p-0']"
:visible="visible"
:width="888"
:width="920"
height="calc(100vh - 20px)"
:title="modalTitle"
@update:visible="onVisibleChange"
>
<VTabs
v-model:active-id="activeTab"
<VTabbar
v-model:active-id="activeTabId"
:items="
tabs.map((tab) => {
return { label: tab.label, id: tab.id };
})
"
type="outline"
class="mx-[16px] my-[12px]"
>
<VTabItem
id="installed"
:label="$t('core.theme.list_modal.tabs.installed')"
class="-mx-[16px]"
>
<VLoading v-if="isLoading" />
<Transition v-else-if="!themes?.length" appear name="fade">
<VEmpty
:message="$t('core.theme.list_modal.empty.message')"
:title="$t('core.theme.list_modal.empty.title')"
>
<template #actions>
<VSpace>
<VButton :loading="isFetching" @click="refetch()">
{{ $t("core.common.buttons.refresh") }}
</VButton>
<VButton
v-permission="['system:themes:manage']"
type="primary"
@click="handleOpenInstallModal()"
>
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
{{ $t("core.theme.common.buttons.install") }}
</VButton>
</VSpace>
</template>
</VEmpty>
</Transition>
<Transition v-else appear name="fade">
<ul
class="box-border h-full w-full divide-y divide-gray-100"
role="list"
>
<li
v-for="(theme, index) in themes"
:key="index"
@click="handleSelectTheme(theme)"
>
<ThemeListItem
:theme="theme"
:is-selected="
theme.metadata.name === selectedTheme?.metadata?.name
"
@reload="refetch"
@preview="handleOpenPreview(theme)"
@upgrade="handleOpenUpgradeModal(theme)"
/>
</li>
</ul>
</Transition>
</VTabItem>
<VTabItem
id="uninstalled"
:label="$t('core.theme.list_modal.tabs.not_installed')"
class="-mx-[16px]"
>
<VLoading v-if="isLoading" />
<Transition v-else-if="!themes?.length" appear name="fade">
<VEmpty
:title="$t('core.theme.list_modal.not_installed_empty.title')"
>
<template #actions>
<VSpace>
<VButton :loading="isFetching" @click="refetch">
{{ $t("core.common.buttons.refresh") }}
</VButton>
</VSpace>
</template>
</VEmpty>
</Transition>
<Transition v-else appear name="fade">
<ul
class="box-border h-full w-full divide-y divide-gray-100"
role="list"
>
<li v-for="(theme, index) in themes" :key="index">
<VEntity>
<template #start>
<VEntityField>
<template #description>
<div class="w-32">
<div
class="group aspect-h-3 aspect-w-4 block w-full overflow-hidden rounded border bg-gray-100"
>
<LazyImage
:key="theme.metadata.name"
:src="theme.spec.logo"
:alt="theme.spec.displayName"
classes="pointer-events-none object-cover group-hover:opacity-75"
>
<template #loading>
<div
class="flex h-full items-center justify-center object-cover"
>
<span class="text-xs text-gray-400">
{{ $t("core.common.status.loading") }}...
</span>
</div>
</template>
<template #error>
<div
class="flex h-full items-center justify-center object-cover"
>
<span class="text-xs text-red-400">
{{ $t("core.common.status.loading_error") }}
</span>
</div>
</template>
</LazyImage>
</div>
</div>
</template>
</VEntityField>
<VEntityField
:title="theme.spec.displayName"
:description="theme.spec.version"
>
</VEntityField>
</template>
<template #end>
<VEntityField>
<template #description>
<a
class="text-sm text-gray-400 hover:text-blue-600"
:href="theme.spec.author.website"
target="_blank"
@click.stop
>
{{ theme.spec.author.name }}
</a>
</template>
</VEntityField>
<VEntityField>
<template #description>
<a
:href="theme.spec.repo"
class="text-gray-900 hover:text-blue-600"
target="_blank"
>
<IconGitHub />
</a>
</template>
</VEntityField>
<VEntityField v-permission="['system:themes:manage']">
<template #description>
<VButton
size="sm"
:disabled="creating"
@click="handleCreateTheme(theme)"
>
{{ $t("core.common.buttons.install") }}
</VButton>
</template>
</VEntityField>
</template>
</VEntity>
</li>
</ul>
</Transition>
</VTabItem>
</VTabs>
/>
<div class="mt-2">
<template v-for="tab in tabs" :key="tab.id">
<component
:is="tab.component"
v-bind="tab.props"
v-if="tab.id === activeTabId"
/>
</template>
</div>
<template #footer>
<VSpace>
<VButton
v-permission="['system:themes:manage']"
type="secondary"
@click="handleOpenInstallModal()"
>
{{ $t("core.theme.common.buttons.install") }}
</VButton>
<VButton @click="onVisibleChange(false)">
{{ $t("core.common.buttons.close") }}
</VButton>
</VSpace>
<VButton @click="onVisibleChange(false)">
{{ $t("core.common.buttons.close") }}
</VButton>
</template>
</VModal>
<ThemeUploadModal
v-if="visible"
v-model:visible="themeUploadVisible"
:upgrade-theme="themeToUpgrade"
/>
<ThemePreviewModal
v-if="visible"
v-model:visible="previewVisible"
:theme="selectedPreviewTheme"
/>
</template>

View File

@ -1,283 +0,0 @@
<script lang="ts" setup>
import {
Dialog,
Toast,
VButton,
VModal,
VTabItem,
VTabs,
} from "@halo-dev/components";
import UppyUpload from "@/components/upload/UppyUpload.vue";
import { computed, ref, watch, nextTick } from "vue";
import type { Theme } from "@halo-dev/api-client";
import { useI18n } from "vue-i18n";
import { useQueryClient } from "@tanstack/vue-query";
import { useThemeStore } from "@/stores/theme";
import { apiClient } from "@/utils/api-client";
import { useRouteQuery } from "@vueuse/router";
import { submitForm } from "@formkit/core";
import type { ErrorResponse, UppyFile } from "@uppy/core";
import AppDownloadAlert from "@/components/common/AppDownloadAlert.vue";
const { t } = useI18n();
const queryClient = useQueryClient();
const themeStore = useThemeStore();
const props = withDefaults(
defineProps<{
visible: boolean;
upgradeTheme?: Theme;
}>(),
{
visible: false,
upgradeTheme: undefined,
}
);
const emit = defineEmits<{
(event: "update:visible", visible: boolean): void;
(event: "close"): void;
}>();
const uploadVisible = ref(false);
const modalTitle = computed(() => {
return props.upgradeTheme
? t("core.theme.upload_modal.titles.upgrade", {
display_name: props.upgradeTheme.spec.displayName,
})
: t("core.theme.upload_modal.titles.install");
});
const handleVisibleChange = (visible: boolean) => {
emit("update:visible", visible);
if (!visible) {
emit("close");
}
};
const endpoint = computed(() => {
if (props.upgradeTheme) {
return `/apis/api.console.halo.run/v1alpha1/themes/${props.upgradeTheme.metadata.name}/upgrade`;
}
return "/apis/api.console.halo.run/v1alpha1/themes/install";
});
watch(
() => props.visible,
(newValue) => {
if (newValue) {
uploadVisible.value = true;
} else {
const uploadVisibleTimer = setTimeout(() => {
uploadVisible.value = false;
clearTimeout(uploadVisibleTimer);
}, 200);
}
}
);
const onUploaded = () => {
Toast.success(
t(
props.upgradeTheme
? "core.common.toast.upgrade_success"
: "core.common.toast.install_success"
)
);
queryClient.invalidateQueries({ queryKey: ["themes"] });
themeStore.fetchActivatedTheme();
handleVisibleChange(false);
};
interface ThemeInstallationErrorResponse {
detail: string;
instance: string;
themeName: string;
requestId: string;
status: number;
timestamp: string;
title: string;
type: string;
}
const THEME_ALREADY_EXISTS_TYPE = "https://halo.run/probs/theme-alreay-exists";
const onError = (file: UppyFile<unknown>, response: ErrorResponse) => {
const body = response.body as ThemeInstallationErrorResponse;
if (body.type === THEME_ALREADY_EXISTS_TYPE) {
handleCatchExistsException(body, file.data as File);
}
};
const handleCatchExistsException = async (
error: ThemeInstallationErrorResponse,
file?: File
) => {
Dialog.info({
title: t(
"core.theme.upload_modal.operations.existed_during_installation.title"
),
description: t(
"core.theme.upload_modal.operations.existed_during_installation.description"
),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
if (activeTabId.value === "local") {
if (!file) {
throw new Error("File is required");
}
await apiClient.theme.upgradeTheme({
name: error.themeName,
file: file,
});
} else if (activeTabId.value === "remote") {
await apiClient.theme.upgradeThemeFromUri({
name: error.themeName,
upgradeFromUriRequest: {
uri: remoteDownloadUrl.value,
},
});
} else {
throw new Error("Unknown tab id");
}
Toast.success(t("core.common.toast.upgrade_success"));
queryClient.invalidateQueries({ queryKey: ["themes"] });
themeStore.fetchActivatedTheme();
handleVisibleChange(false);
},
});
};
// remote download
const activeTabId = ref("local");
const remoteDownloadUrl = ref("");
const downloading = ref(false);
const handleDownloadTheme = async () => {
try {
downloading.value = true;
if (props.upgradeTheme) {
await apiClient.theme.upgradeThemeFromUri({
name: props.upgradeTheme.metadata.name,
upgradeFromUriRequest: {
uri: remoteDownloadUrl.value,
},
});
} else {
await apiClient.theme.installThemeFromUri({
installFromUriRequest: {
uri: remoteDownloadUrl.value,
},
});
}
Toast.success(
t(
props.upgradeTheme
? "core.common.toast.upgrade_success"
: "core.common.toast.install_success"
)
);
queryClient.invalidateQueries({ queryKey: ["themes"] });
themeStore.fetchActivatedTheme();
handleVisibleChange(false);
// eslint-disable-next-line
} catch (error: any) {
const data = error?.response.data as ThemeInstallationErrorResponse;
if (data?.type === THEME_ALREADY_EXISTS_TYPE) {
handleCatchExistsException(data);
}
console.error("Failed to download theme", error);
} finally {
routeRemoteDownloadUrl.value = null;
downloading.value = false;
}
};
// handle remote download url from route
const routeRemoteDownloadUrl = useRouteQuery<string | null>(
"remote-download-url"
);
watch(
() => props.visible,
(visible) => {
if (routeRemoteDownloadUrl.value && visible) {
activeTabId.value = "remote";
remoteDownloadUrl.value = routeRemoteDownloadUrl.value as string;
nextTick(() => {
submitForm("theme-remote-download-form");
});
}
}
);
</script>
<template>
<VModal
:visible="visible"
:width="600"
:title="modalTitle"
:centered="false"
@update:visible="handleVisibleChange"
>
<VTabs v-model:active-id="activeTabId" type="outline" class="!rounded-none">
<VTabItem id="local" :label="$t('core.theme.upload_modal.tabs.local')">
<div class="pb-3">
<AppDownloadAlert />
</div>
<UppyUpload
v-if="uploadVisible"
:restrictions="{
maxNumberOfFiles: 1,
allowedFileTypes: ['.zip'],
}"
:endpoint="endpoint"
auto-proceed
@uploaded="onUploaded"
@error="onError"
/>
</VTabItem>
<VTabItem
id="remote"
:label="$t('core.theme.upload_modal.tabs.remote.title')"
>
<FormKit
id="theme-remote-download-form"
name="theme-remote-download-form"
type="form"
:preserve="true"
@submit="handleDownloadTheme"
>
<FormKit
v-model="remoteDownloadUrl"
:label="$t('core.theme.upload_modal.tabs.remote.fields.url')"
type="text"
></FormKit>
</FormKit>
<div class="pt-5">
<VButton
:loading="downloading"
type="secondary"
@click="$formkit.submit('theme-remote-download-form')"
>
{{ $t("core.common.buttons.download") }}
</VButton>
</div>
</VTabItem>
</VTabs>
</VModal>
</template>

View File

@ -1,224 +0,0 @@
<script lang="ts" setup>
import {
IconGitHub,
VTag,
VEntity,
VEntityField,
VStatusDot,
Dialog,
Toast,
VDropdownItem,
VDropdown,
VDropdownDivider,
} from "@halo-dev/components";
import LazyImage from "@/components/image/LazyImage.vue";
import type { Theme } from "@halo-dev/api-client";
import { apiClient } from "@/utils/api-client";
import { toRefs } from "vue";
import { useThemeLifeCycle } from "../../composables/use-theme";
import { usePermission } from "@/utils/permission";
import { useI18n } from "vue-i18n";
const { currentUserHasPermission } = usePermission();
const { t } = useI18n();
const props = withDefaults(
defineProps<{
theme: Theme;
isSelected?: boolean;
}>(),
{
isSelected: false,
}
);
const emit = defineEmits<{
(event: "reload"): void;
(event: "upgrade"): void;
(event: "preview"): void;
}>();
const { theme } = toRefs(props);
const {
isActivated,
getFailedMessage,
handleActiveTheme,
handleResetSettingConfig,
} = useThemeLifeCycle(theme);
const handleUninstall = async (theme: Theme, deleteExtensions?: boolean) => {
Dialog.warning({
title: `${
deleteExtensions
? t("core.theme.operations.uninstall_and_delete_config.title")
: t("core.theme.operations.uninstall.title")
}`,
description: t("core.common.dialog.descriptions.cannot_be_recovered"),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
try {
await apiClient.extension.theme.deletethemeHaloRunV1alpha1Theme({
name: theme.metadata.name,
});
// delete theme setting and configMap
if (deleteExtensions) {
const { settingName, configMapName } = theme.spec;
if (settingName) {
await apiClient.extension.setting.deletev1alpha1Setting(
{
name: settingName,
},
{
mute: true,
}
);
}
if (configMapName) {
await apiClient.extension.configMap.deletev1alpha1ConfigMap(
{
name: configMapName,
},
{
mute: true,
}
);
}
}
Toast.success(t("core.common.toast.uninstall_success"));
} catch (e) {
console.error("Failed to uninstall theme", e);
} finally {
emit("reload");
}
},
});
};
</script>
<template>
<VEntity :is-selected="isSelected">
<template #start>
<VEntityField>
<template #description>
<div class="w-32">
<div
class="group aspect-h-3 aspect-w-4 block w-full overflow-hidden rounded border bg-gray-100"
>
<LazyImage
:key="theme.metadata.name"
:src="theme.spec.logo"
:alt="theme.spec.displayName"
classes="pointer-events-none object-cover group-hover:opacity-75"
>
<template #loading>
<div
class="flex h-full items-center justify-center object-cover"
>
<span class="text-xs text-gray-400">
{{ $t("core.common.status.loading") }}...
</span>
</div>
</template>
<template #error>
<div
class="flex h-full items-center justify-center object-cover"
>
<span class="text-xs text-red-400">
{{ $t("core.common.status.loading_error") }}
</span>
</div>
</template>
</LazyImage>
</div>
</div>
</template>
</VEntityField>
<VEntityField
:title="theme.spec.displayName"
:description="theme.spec.version"
>
<template #extra>
<VTag v-if="isActivated">
{{ $t("core.common.status.activated") }}
</VTag>
</template>
</VEntityField>
</template>
<template #end>
<VEntityField v-if="getFailedMessage()">
<template #description>
<VStatusDot v-tooltip="getFailedMessage()" state="warning" animate />
</template>
</VEntityField>
<VEntityField v-if="theme.metadata.deletionTimestamp">
<template #description>
<VStatusDot
v-tooltip="$t('core.common.status.deleting')"
state="warning"
animate
/>
</template>
</VEntityField>
<VEntityField>
<template #description>
<a
class="text-sm text-gray-400 hover:text-blue-600"
:href="theme.spec.author.website"
target="_blank"
@click.stop
>
{{ theme.spec.author.name }}
</a>
</template>
</VEntityField>
<VEntityField>
<template #description>
<a
:href="theme.spec.repo"
class="text-gray-900 hover:text-blue-600"
target="_blank"
>
<IconGitHub />
</a>
</template>
</VEntityField>
</template>
<template
v-if="currentUserHasPermission(['system:themes:manage'])"
#dropdownItems
>
<VDropdownItem v-if="!isActivated" @click="handleActiveTheme(true)">
{{ $t("core.common.buttons.activate") }}
</VDropdownItem>
<VDropdownItem @click="emit('upgrade')">
{{ $t("core.common.buttons.upgrade") }}
</VDropdownItem>
<VDropdownItem @click="emit('preview')">
{{ $t("core.common.buttons.preview") }}
</VDropdownItem>
<VDropdownDivider />
<VDropdown placement="right" :triggers="['click']">
<VDropdownItem type="danger">
{{ $t("core.common.buttons.uninstall") }}
</VDropdownItem>
<template #popper>
<VDropdownItem type="danger" @click="handleUninstall(theme)">
{{ $t("core.common.buttons.uninstall") }}
</VDropdownItem>
<VDropdownItem type="danger" @click="handleUninstall(theme, true)">
{{ $t("core.theme.operations.uninstall_and_delete_config.button") }}
</VDropdownItem>
</template>
</VDropdown>
<VDropdownItem type="danger" @click="handleResetSettingConfig">
{{ $t("core.common.buttons.reset") }}
</VDropdownItem>
</template>
</VEntity>
</template>

View File

@ -0,0 +1,113 @@
<script lang="ts" setup>
import {
IconAddCircle,
VButton,
VEmpty,
VSpace,
VLoading,
} from "@halo-dev/components";
import ThemePreviewModal from "../preview/ThemePreviewModal.vue";
import ThemeListItem from "../ThemeListItem.vue";
import { ref, inject, type Ref } from "vue";
import type { Theme } from "@halo-dev/api-client";
import { apiClient } from "@/utils/api-client";
import { useQuery } from "@tanstack/vue-query";
import { useThemeStore } from "@/stores/theme";
const themeStore = useThemeStore();
const selectedTheme = inject<Ref<Theme | undefined>>("selectedTheme", ref());
const activeTabId = inject<Ref<string>>("activeTabId", ref(""));
function handleSelectTheme(theme: Theme) {
selectedTheme.value = theme;
}
const {
data: themes,
isLoading,
isFetching,
refetch,
} = useQuery<Theme[]>({
queryKey: ["installed-themes"],
queryFn: async () => {
const { data } = await apiClient.theme.listThemes({
page: 0,
size: 0,
uninstalled: false,
});
return data.items.sort((a, b) => {
const activatedThemeName = themeStore.activatedTheme?.metadata.name;
if (a.metadata.name === activatedThemeName) {
return -1;
}
if (b.metadata.name === activatedThemeName) {
return 1;
}
return 0;
});
},
refetchInterval(data) {
const deletingThemes = data?.filter(
(theme) => !!theme.metadata.deletionTimestamp
);
return deletingThemes?.length ? 1000 : false;
},
});
// preview
const previewVisible = ref(false);
const selectedPreviewTheme = ref<Theme>();
const handleOpenPreview = (theme: Theme) => {
selectedPreviewTheme.value = theme;
previewVisible.value = true;
};
</script>
<template>
<div id="installed-themes-wrapper">
<VLoading v-if="isLoading" />
<Transition v-else-if="!themes?.length" appear name="fade">
<VEmpty
:message="$t('core.theme.list_modal.empty.message')"
:title="$t('core.theme.list_modal.empty.title')"
>
<template #actions>
<VSpace>
<VButton :loading="isFetching" @click="refetch()">
{{ $t("core.common.buttons.refresh") }}
</VButton>
<VButton
v-permission="['system:themes:manage']"
type="primary"
@click="activeTabId = 'local-upload'"
>
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
{{ $t("core.theme.common.buttons.install") }}
</VButton>
</VSpace>
</template>
</VEmpty>
</Transition>
<Transition v-else appear name="fade">
<ul class="box-border h-full w-full space-y-3" role="list">
<li v-for="(theme, index) in themes" :key="index">
<ThemeListItem
:theme="theme"
:is-selected="theme.metadata.name === selectedTheme?.metadata?.name"
@select="handleSelectTheme"
@preview="handleOpenPreview(theme)"
/>
</li>
</ul>
</Transition>
<ThemePreviewModal
v-model:visible="previewVisible"
:theme="selectedPreviewTheme"
/>
</div>
</template>

View File

@ -0,0 +1,89 @@
<script lang="ts" setup>
import AppDownloadAlert from "@/components/common/AppDownloadAlert.vue";
import { Dialog, Toast } from "@halo-dev/components";
import type { ErrorResponse } from "@uppy/core";
import type { UppyFile } from "@uppy/core";
import { THEME_ALREADY_EXISTS_TYPE } from "../../constants";
import type { ThemeInstallationErrorResponse } from "../../types";
import { useQueryClient } from "@tanstack/vue-query";
import { useI18n } from "vue-i18n";
import { inject } from "vue";
import type { Ref } from "vue";
import { ref } from "vue";
import { useThemeStore } from "@/stores/theme";
import { apiClient } from "@/utils/api-client";
import UppyUpload from "@/components/upload/UppyUpload.vue";
const { t } = useI18n();
const queryClient = useQueryClient();
const themeStore = useThemeStore();
const activeTabId = inject<Ref<string>>("activeTabId", ref(""));
const endpoint = "/apis/api.console.halo.run/v1alpha1/themes/install";
const onUploaded = () => {
Toast.success(t("core.common.toast.install_success"));
queryClient.invalidateQueries({ queryKey: ["themes"] });
themeStore.fetchActivatedTheme();
activeTabId.value = "installed";
};
const onError = (file: UppyFile<unknown>, response: ErrorResponse) => {
const body = response.body as ThemeInstallationErrorResponse;
if (body.type === THEME_ALREADY_EXISTS_TYPE) {
handleCatchExistsException(body, file.data as File);
}
};
const handleCatchExistsException = async (
error: ThemeInstallationErrorResponse,
file?: File
) => {
Dialog.info({
title: t("core.theme.operations.existed_during_installation.title"),
description: t(
"core.theme.operations.existed_during_installation.description"
),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
if (!file) {
throw new Error("File is required");
}
await apiClient.theme.upgradeTheme({
name: error.themeName,
file: file,
});
Toast.success(t("core.common.toast.upgrade_success"));
queryClient.invalidateQueries({ queryKey: ["themes"] });
themeStore.fetchActivatedTheme();
activeTabId.value = "installed";
},
});
};
</script>
<template>
<div class="pb-3">
<AppDownloadAlert />
</div>
<UppyUpload
:restrictions="{
maxNumberOfFiles: 1,
allowedFileTypes: ['.zip'],
}"
:endpoint="endpoint"
width="100%"
auto-proceed
@uploaded="onUploaded"
@error="onError"
/>
</template>

View File

@ -0,0 +1,48 @@
<script lang="ts" setup>
import { VButton, VEmpty, VSpace, VLoading } from "@halo-dev/components";
import ThemeListItem from "../ThemeListItem.vue";
import type { Theme } from "@halo-dev/api-client";
import { apiClient } from "@/utils/api-client";
import { useQuery } from "@tanstack/vue-query";
const {
data: themes,
isLoading,
isFetching,
refetch,
} = useQuery<Theme[]>({
queryKey: ["not-installed-themes"],
queryFn: async () => {
const { data } = await apiClient.theme.listThemes({
page: 0,
size: 0,
uninstalled: true,
});
return data.items;
},
});
</script>
<template>
<div id="not-installed-themes-wrapper">
<VLoading v-if="isLoading" />
<Transition v-else-if="!themes?.length" appear name="fade">
<VEmpty :title="$t('core.theme.list_modal.not_installed_empty.title')">
<template #actions>
<VSpace>
<VButton :loading="isFetching" @click="refetch">
{{ $t("core.common.buttons.refresh") }}
</VButton>
</VSpace>
</template>
</VEmpty>
</Transition>
<Transition v-else appear name="fade">
<ul class="box-border h-full w-full space-y-3" role="list">
<li v-for="(theme, index) in themes" :key="index">
<ThemeListItem :theme="theme" :installed="false" />
</li>
</ul>
</Transition>
</div>
</template>

View File

@ -0,0 +1,124 @@
<script lang="ts" setup>
import { apiClient } from "@/utils/api-client";
import { Dialog, Toast, VButton } from "@halo-dev/components";
import { useQueryClient } from "@tanstack/vue-query";
import type { Ref } from "vue";
import { inject } from "vue";
import { ref } from "vue";
import { useI18n } from "vue-i18n";
import type { ThemeInstallationErrorResponse } from "../../types";
import { useThemeStore } from "@/stores/theme";
import { THEME_ALREADY_EXISTS_TYPE } from "../../constants";
import { useRouteQuery } from "@vueuse/router";
import { onMounted } from "vue";
import { nextTick } from "vue";
import { submitForm } from "@formkit/core";
const { t } = useI18n();
const queryClient = useQueryClient();
const themeStore = useThemeStore();
const activeTabId = inject<Ref<string>>("activeTabId", ref(""));
const remoteDownloadUrl = ref("");
const downloading = ref(false);
const handleDownloadTheme = async () => {
try {
downloading.value = true;
await apiClient.theme.installThemeFromUri({
installFromUriRequest: {
uri: remoteDownloadUrl.value,
},
});
Toast.success(t("core.common.toast.install_success"));
queryClient.invalidateQueries({ queryKey: ["themes"] });
themeStore.fetchActivatedTheme();
activeTabId.value = "installed";
// eslint-disable-next-line
} catch (error: any) {
const data = error?.response.data as ThemeInstallationErrorResponse;
if (data?.type === THEME_ALREADY_EXISTS_TYPE) {
handleCatchExistsException(data);
}
console.error("Failed to download theme", error);
} finally {
routeRemoteDownloadUrl.value = null;
downloading.value = false;
}
};
const handleCatchExistsException = async (
error: ThemeInstallationErrorResponse
) => {
Dialog.info({
title: t("core.theme.operations.existed_during_installation.title"),
description: t(
"core.theme.operations.existed_during_installation.description"
),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
await apiClient.theme.upgradeThemeFromUri({
name: error.themeName,
upgradeFromUriRequest: {
uri: remoteDownloadUrl.value,
},
});
Toast.success(t("core.common.toast.upgrade_success"));
queryClient.invalidateQueries({ queryKey: ["themes"] });
themeStore.fetchActivatedTheme();
activeTabId.value = "installed";
},
});
};
// handle remote download url from route
const routeRemoteDownloadUrl = useRouteQuery<string | null>(
"remote-download-url"
);
onMounted(() => {
if (routeRemoteDownloadUrl.value) {
remoteDownloadUrl.value = routeRemoteDownloadUrl.value;
nextTick(() => {
submitForm("theme-remote-download-form");
});
}
});
</script>
<template>
<FormKit
id="theme-remote-download-form"
name="theme-remote-download-form"
type="form"
:preserve="true"
@submit="handleDownloadTheme"
>
<FormKit
v-model="remoteDownloadUrl"
:label="$t('core.theme.list_modal.tabs.remote_download.fields.url')"
type="text"
validation="required"
></FormKit>
</FormKit>
<div class="pt-5">
<VButton
:loading="downloading"
type="secondary"
@click="$formkit.submit('theme-remote-download-form')"
>
{{ $t("core.common.buttons.download") }}
</VButton>
</div>
</template>

View File

@ -0,0 +1,2 @@
export const THEME_ALREADY_EXISTS_TYPE =
"https://halo.run/probs/theme-alreay-exists";

View File

@ -25,6 +25,7 @@ import {
VTabbar,
VLoading,
Dialog,
IconListSettings,
} from "@halo-dev/components";
import ThemeListModal from "../components/ThemeListModal.vue";
import ThemePreviewModal from "../components/preview/ThemePreviewModal.vue";
@ -67,6 +68,7 @@ const themesModal = ref(false);
const previewModal = ref(false);
const activeTab = ref(tabs.value[0].id);
provide<Ref<string>>("activeTab", activeTab);
provide<Ref<boolean>>("themesModal", themesModal);
const { loading, isActivated, handleActiveTheme } =
useThemeLifeCycle(selectedTheme);
@ -184,11 +186,6 @@ onMounted(() => {
</script>
<template>
<BasicLayout>
<ThemeListModal
v-model:selected-theme="selectedTheme"
v-model:visible="themesModal"
@select="onSelectTheme"
/>
<VPageHeader :title="selectedTheme?.spec.displayName">
<template #icon>
<IconPalette class="mr-2 self-center" />
@ -204,15 +201,15 @@ onMounted(() => {
>
{{ $t("core.common.buttons.activate") }}
</VButton>
<VButton type="secondary" size="sm" @click="previewModal = true">
<VButton type="default" size="sm" @click="previewModal = true">
<template #icon>
<IconEye class="h-full w-full" />
</template>
{{ $t("core.common.buttons.preview") }}
</VButton>
<VButton size="sm" type="default" @click="themesModal = true">
<VButton type="secondary" @click="themesModal = true">
<template #icon>
<IconExchange class="h-full w-full" />
<IconListSettings class="h-full w-full" />
</template>
{{ $t("core.theme.actions.management") }}
</VButton>
@ -271,6 +268,7 @@ onMounted(() => {
</div>
</div>
<ThemeListModal v-model:visible="themesModal" @select="onSelectTheme" />
<ThemePreviewModal v-model:visible="previewModal" :theme="selectedTheme" />
</BasicLayout>
</template>

View File

@ -0,0 +1,10 @@
export interface ThemeInstallationErrorResponse {
detail: string;
instance: string;
themeName: string;
requestId: string;
status: number;
timestamp: string;
title: string;
type: string;
}

View File

@ -135,7 +135,9 @@ onMounted(() => {
</template>
</div>
<template #footer>
<VButton @click="handleVisibleChange(false)"></VButton>
<VButton @click="handleVisibleChange(false)">
{{ $t("core.common.buttons.close") }}
</VButton>
</template>
</VModal>
</template>