diff --git a/.gitignore b/.gitignore
index cf251f07..58e46ad8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
docs/claude-relay-service/
.codex
+.playwright-mcp/
# ===================
# Go 后端
@@ -13,7 +14,10 @@ docs/claude-relay-service/
backend/bin/
backend/server
backend/sub2api
+backend/sub2api.*
backend/main
+sub2api
+sub2api.*
# Go 测试二进制
*.test
@@ -104,6 +108,8 @@ backend/internal/web/dist/*
# 后端运行时缓存数据
backend/data/
+data/
+runtime-data/
# ===================
# 本地配置文件(包含敏感信息)
@@ -136,3 +142,4 @@ docs/*
frontend/coverage/
aicodex
output/
+docs-*.png
diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION
index eac5392b..0693ceee 100644
--- a/backend/cmd/server/VERSION
+++ b/backend/cmd/server/VERSION
@@ -1 +1 @@
-0.1.139
+0.1.140
diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go
index de074ccd..4b5669c0 100644
--- a/backend/internal/handler/admin/setting_handler.go
+++ b/backend/internal/handler/admin/setting_handler.go
@@ -1151,6 +1151,14 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
response.BadRequest(c, "Custom menu item placement must be 'sidebar', 'home_header' or 'both'")
return
}
+ switch item.LinkOpenMode {
+ case "", "internal":
+ items[i].LinkOpenMode = "internal"
+ case "external":
+ default:
+ response.BadRequest(c, "Custom menu item link open mode must be 'internal' or 'external'")
+ return
+ }
if len(item.IconSVG) > maxMenuItemIconSVGLen {
response.BadRequest(c, "Custom menu item icon SVG is too large (max 10KB)")
return
diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go
index 7f0987af..e36c0759 100644
--- a/backend/internal/handler/dto/settings.go
+++ b/backend/internal/handler/dto/settings.go
@@ -7,14 +7,15 @@ import (
// CustomMenuItem represents a user-configured custom menu entry.
type CustomMenuItem struct {
- ID string `json:"id"`
- Label string `json:"label"`
- IconSVG string `json:"icon_svg"`
- URL string `json:"url"`
- PageSlug string `json:"page_slug,omitempty"`
- Visibility string `json:"visibility"` // "user" or "admin"
- Placement string `json:"placement,omitempty"`
- SortOrder int `json:"sort_order"`
+ ID string `json:"id"`
+ Label string `json:"label"`
+ IconSVG string `json:"icon_svg"`
+ URL string `json:"url"`
+ PageSlug string `json:"page_slug,omitempty"`
+ Visibility string `json:"visibility"` // "user" or "admin"
+ Placement string `json:"placement,omitempty"`
+ LinkOpenMode string `json:"link_open_mode,omitempty"`
+ SortOrder int `json:"sort_order"`
}
// CustomEndpoint represents an admin-configured API endpoint for quick copy.
@@ -371,6 +372,7 @@ func ParseCustomMenuItems(raw string) []CustomMenuItem {
}
for i := range items {
items[i].Placement = normalizeCustomMenuPlacement(items[i].Placement)
+ items[i].LinkOpenMode = normalizeCustomMenuLinkOpenMode(items[i].LinkOpenMode)
}
return items
}
@@ -388,6 +390,17 @@ func normalizeCustomMenuPlacement(raw string) string {
}
}
+func normalizeCustomMenuLinkOpenMode(raw string) string {
+ switch strings.TrimSpace(raw) {
+ case "", "internal":
+ return "internal"
+ case "external":
+ return "external"
+ default:
+ return "internal"
+ }
+}
+
// ParseUserVisibleMenuItems parses custom menu items and filters out admin-only entries.
func ParseUserVisibleMenuItems(raw string) []CustomMenuItem {
items := ParseCustomMenuItems(raw)
diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go
index 6f7b9a8c..1d0d74b5 100644
--- a/backend/internal/service/setting_service.go
+++ b/backend/internal/service/setting_service.go
@@ -1218,6 +1218,14 @@ func normalizeCustomMenuItemsRaw(raw string) json.RawMessage {
default:
item["placement"] = "sidebar"
}
+ linkOpenMode, _ := item["link_open_mode"].(string)
+ switch strings.TrimSpace(linkOpenMode) {
+ case "", "internal":
+ item["link_open_mode"] = "internal"
+ case "external":
+ default:
+ item["link_open_mode"] = "internal"
+ }
}
normalized, err := json.Marshal(items)
if err != nil {
diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue
index 1e9ab4bb..48bcb8d8 100644
--- a/frontend/src/components/layout/AppSidebar.vue
+++ b/frontend/src/components/layout/AppSidebar.vue
@@ -69,11 +69,12 @@
-
+
@@ -101,12 +102,13 @@
-
+
@@ -187,13 +190,22 @@ import { useAdminSettingsStore, useAppStore, useAuthStore, useOnboardingStore }
import VersionBadge from '@/components/common/VersionBadge.vue'
import { sanitizeSvg } from '@/utils/sanitize'
import { FeatureFlags, makeSidebarFlag } from '@/utils/featureFlags'
-import { getCustomMenuRoute, isSidebarMenuPlacement, normalizeCustomMenuItems } from '@/utils/custom-menu'
+import { useTheme } from '@/utils/theme'
+import {
+ getCustomMenuHref,
+ getCustomMenuRoute,
+ isExternalCustomMenuLink,
+ isSidebarMenuPlacement,
+ normalizeCustomMenuItems,
+} from '@/utils/custom-menu'
+import type { CustomMenuItem } from '@/types'
interface NavItem {
path: string
label: string
icon: unknown
iconSvg?: string
+ externalHref?: string
hideInSimpleMode?: boolean
requiresSuperAdmin?: boolean
children?: NavItem[]
@@ -254,7 +266,7 @@ const adminSettingsStore = useAdminSettingsStore()
const sidebarCollapsed = computed(() => appStore.sidebarCollapsed)
const mobileOpen = computed(() => appStore.mobileOpen)
const isAdmin = computed(() => authStore.isAdmin)
-const isDark = ref(document.documentElement.classList.contains('dark'))
+const { isDarkTheme: isDark, toggleTheme } = useTheme()
// Track which parent nav groups are expanded
const expandedGroups = ref>(new Set())
@@ -693,16 +705,33 @@ function buildSelfNavItems(withDashboard: boolean): NavItem[] {
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true },
{ path: '/affiliate', label: t('nav.affiliate'), icon: UsersIcon, hideInSimpleMode: true, featureFlag: flagAffiliate },
{ path: '/profile', label: t('nav.profile'), icon: UserIcon },
- ...customMenuItemsForUser.value.map((item): NavItem => ({
- path: getCustomMenuRoute(item.id),
- label: item.label,
- icon: null,
- iconSvg: item.icon_svg,
- })),
+ ...customMenuItemsForUser.value.map(customMenuItemToNavItem),
)
return items
}
+function customMenuItemToNavItem(item: CustomMenuItem): NavItem {
+ const path = getCustomMenuRoute(item.id)
+ return {
+ path,
+ label: item.label,
+ icon: null,
+ iconSvg: item.icon_svg,
+ externalHref: isExternalCustomMenuLink(item) ? getCustomMenuHref(item) : undefined,
+ }
+}
+
+function sidebarItemLinkProps(item: NavItem) {
+ if (item.externalHref) {
+ return {
+ href: item.externalHref,
+ target: '_blank',
+ rel: 'noopener noreferrer',
+ }
+ }
+ return { to: item.path }
+}
+
// finalizeNav 合并三重过滤:featureFlag 过滤 + simple 模式过滤。
function finalizeNav(items: NavItem[]): NavItem[] {
const visible = applyFeatureFlags(items)
@@ -796,7 +825,7 @@ const adminNavItems = computed((): NavItem[] => {
filtered.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon })
}
for (const cm of customMenuItemsForAdmin.value) {
- filtered.push({ path: getCustomMenuRoute(cm.id), label: cm.label, icon: null, iconSvg: cm.icon_svg })
+ filtered.push(customMenuItemToNavItem(cm))
}
return filtered
}
@@ -805,7 +834,7 @@ const adminNavItems = computed((): NavItem[] => {
visible.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon })
}
for (const cm of customMenuItemsForAdmin.value) {
- visible.push({ path: getCustomMenuRoute(cm.id), label: cm.label, icon: null, iconSvg: cm.icon_svg })
+ visible.push(customMenuItemToNavItem(cm))
}
return visible
})
@@ -814,12 +843,6 @@ function toggleSidebar() {
appStore.toggleSidebar()
}
-function toggleTheme() {
- isDark.value = !isDark.value
- document.documentElement.classList.toggle('dark', isDark.value)
- localStorage.setItem('theme', isDark.value ? 'dark' : 'light')
-}
-
function closeMobile() {
appStore.setMobileOpen(false)
}
@@ -887,16 +910,6 @@ function handleGroupClick(item: NavItem) {
}
}
-// Initialize theme
-const savedTheme = localStorage.getItem('theme')
-if (
- savedTheme === 'dark' ||
- (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)
-) {
- isDark.value = true
- document.documentElement.classList.add('dark')
-}
-
// Fetch admin settings (for feature-gated nav items like Ops).
watch(
isAdmin,
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index ce17412b..735ac0c5 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -5553,6 +5553,12 @@ export default {
iconSvg: 'SVG Icon',
iconSvgPlaceholder: '',
iconPreview: 'Icon Preview',
+ iconPreset: 'Existing Icons',
+ iconPresetPlaceholder: 'Select an icon',
+ iconPresetCustom: 'Custom / Uploaded SVG',
+ iconPresetBuiltIn: 'Built-in Icons',
+ iconPresetNavigation: 'Navigation Icons',
+ iconPresetAssets: 'SVG Assets',
uploadSvg: 'Upload SVG',
removeSvg: 'Remove',
visibility: 'Visible To',
@@ -5562,6 +5568,9 @@ export default {
placementSidebar: 'Sidebar only',
placementHomeHeader: 'Home header only',
placementBoth: 'Sidebar + Home header',
+ linkOpenMode: 'Link Open Mode',
+ linkOpenInternal: 'Internal',
+ linkOpenExternal: 'External',
add: 'Add Menu Item',
remove: 'Remove',
moveUp: 'Move Up',
diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts
index eefa522e..a1ac0713 100644
--- a/frontend/src/i18n/locales/zh.ts
+++ b/frontend/src/i18n/locales/zh.ts
@@ -5713,6 +5713,12 @@ export default {
iconSvg: 'SVG 图标',
iconSvgPlaceholder: '',
iconPreview: '图标预览',
+ iconPreset: '已有素材',
+ iconPresetPlaceholder: '选择图标',
+ iconPresetCustom: '自定义 / 上传 SVG',
+ iconPresetBuiltIn: '内置图标',
+ iconPresetNavigation: '导航图标',
+ iconPresetAssets: 'SVG 素材',
uploadSvg: '上传 SVG',
removeSvg: '清除',
visibility: '可见角色',
@@ -5722,6 +5728,9 @@ export default {
placementSidebar: '仅侧边栏',
placementHomeHeader: '仅首页头部',
placementBoth: '侧边栏 + 首页头部',
+ linkOpenMode: '链接打开方式',
+ linkOpenInternal: '内部',
+ linkOpenExternal: '外部',
add: '添加菜单项',
remove: '删除',
moveUp: '上移',
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
index 68ace885..26a4a14d 100644
--- a/frontend/src/main.ts
+++ b/frontend/src/main.ts
@@ -4,19 +4,12 @@ import App from './App.vue'
import router from './router'
import i18n, { initI18n } from './i18n'
import { useAppStore } from '@/stores/app'
+import { initTheme } from '@/utils/theme'
import './style.css'
-function initThemeClass() {
- const savedTheme = localStorage.getItem('theme')
- const shouldUseDark =
- savedTheme === 'dark' ||
- (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)
- document.documentElement.classList.toggle('dark', shouldUseDark)
-}
-
async function bootstrap() {
- // Apply theme class globally before app mount to keep all routes consistent.
- initThemeClass()
+ // Apply theme globally before app mount to keep all routes consistent.
+ initTheme()
const app = createApp(App)
const pinia = createPinia()
diff --git a/frontend/src/style.css b/frontend/src/style.css
index 1bd13b5f..3dbab742 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -3,6 +3,107 @@
@tailwind utilities;
@layer base {
+ :root,
+ [data-theme='default-light'] {
+ --color-primary-50: 240 253 250;
+ --color-primary-100: 204 251 241;
+ --color-primary-200: 153 246 228;
+ --color-primary-300: 94 234 212;
+ --color-primary-400: 45 212 191;
+ --color-primary-500: 20 184 166;
+ --color-primary-600: 13 148 136;
+ --color-primary-700: 15 118 110;
+ --color-primary-800: 17 94 89;
+ --color-primary-900: 19 78 74;
+ --color-primary-950: 4 47 46;
+
+ --color-gray-50: 249 250 251;
+ --color-gray-100: 243 244 246;
+ --color-gray-200: 229 231 235;
+ --color-gray-300: 209 213 219;
+ --color-gray-400: 156 163 175;
+ --color-gray-500: 107 114 128;
+ --color-gray-600: 75 85 99;
+ --color-gray-700: 55 65 81;
+ --color-gray-800: 31 41 55;
+ --color-gray-900: 17 24 39;
+ --color-gray-950: 3 7 18;
+
+ --color-accent-50: 248 250 252;
+ --color-accent-100: 241 245 249;
+ --color-accent-200: 226 232 240;
+ --color-accent-300: 203 213 225;
+ --color-accent-400: 148 163 184;
+ --color-accent-500: 100 116 139;
+ --color-accent-600: 71 85 105;
+ --color-accent-700: 51 65 85;
+ --color-accent-800: 30 41 59;
+ --color-accent-900: 15 23 42;
+ --color-accent-950: 2 6 23;
+
+ --color-dark-50: 248 250 252;
+ --color-dark-100: 241 245 249;
+ --color-dark-200: 226 232 240;
+ --color-dark-300: 203 213 225;
+ --color-dark-400: 148 163 184;
+ --color-dark-500: 100 116 139;
+ --color-dark-600: 71 85 105;
+ --color-dark-700: 51 65 85;
+ --color-dark-800: 30 41 59;
+ --color-dark-900: 15 23 42;
+ --color-dark-950: 2 6 23;
+ }
+
+ [data-theme='default-dark'] {
+ --color-primary-50: 240 253 250;
+ --color-primary-100: 204 251 241;
+ --color-primary-200: 153 246 228;
+ --color-primary-300: 94 234 212;
+ --color-primary-400: 45 212 191;
+ --color-primary-500: 20 184 166;
+ --color-primary-600: 13 148 136;
+ --color-primary-700: 15 118 110;
+ --color-primary-800: 17 94 89;
+ --color-primary-900: 19 78 74;
+ --color-primary-950: 4 47 46;
+
+ --color-gray-50: 249 250 251;
+ --color-gray-100: 243 244 246;
+ --color-gray-200: 229 231 235;
+ --color-gray-300: 209 213 219;
+ --color-gray-400: 156 163 175;
+ --color-gray-500: 107 114 128;
+ --color-gray-600: 75 85 99;
+ --color-gray-700: 55 65 81;
+ --color-gray-800: 31 41 55;
+ --color-gray-900: 17 24 39;
+ --color-gray-950: 3 7 18;
+
+ --color-accent-50: 248 250 252;
+ --color-accent-100: 241 245 249;
+ --color-accent-200: 226 232 240;
+ --color-accent-300: 203 213 225;
+ --color-accent-400: 148 163 184;
+ --color-accent-500: 100 116 139;
+ --color-accent-600: 71 85 105;
+ --color-accent-700: 51 65 85;
+ --color-accent-800: 30 41 59;
+ --color-accent-900: 15 23 42;
+ --color-accent-950: 2 6 23;
+
+ --color-dark-50: 248 250 252;
+ --color-dark-100: 241 245 249;
+ --color-dark-200: 226 232 240;
+ --color-dark-300: 203 213 225;
+ --color-dark-400: 148 163 184;
+ --color-dark-500: 100 116 139;
+ --color-dark-600: 71 85 105;
+ --color-dark-700: 51 65 85;
+ --color-dark-800: 30 41 59;
+ --color-dark-900: 15 23 42;
+ --color-dark-950: 2 6 23;
+ }
+
* {
@apply border-gray-200 dark:border-dark-700;
}
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index f8f69d45..09c93309 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -172,6 +172,7 @@ export interface SendVerifyCodeResponse {
}
export type CustomMenuPlacement = 'sidebar' | 'home_header' | 'both'
+export type CustomMenuLinkOpenMode = 'internal' | 'external'
export interface CustomMenuItem {
id: string
@@ -181,6 +182,7 @@ export interface CustomMenuItem {
page_slug?: string
visibility: 'user' | 'admin'
placement?: CustomMenuPlacement
+ link_open_mode?: CustomMenuLinkOpenMode
sort_order: number
}
diff --git a/frontend/src/utils/custom-menu.ts b/frontend/src/utils/custom-menu.ts
index 05765bfa..cbe2341d 100644
--- a/frontend/src/utils/custom-menu.ts
+++ b/frontend/src/utils/custom-menu.ts
@@ -1,6 +1,7 @@
-import type { CustomMenuItem, CustomMenuPlacement } from '@/types'
+import type { CustomMenuItem, CustomMenuLinkOpenMode, CustomMenuPlacement } from '@/types'
export const DEFAULT_CUSTOM_MENU_PLACEMENT: CustomMenuPlacement = 'sidebar'
+export const DEFAULT_CUSTOM_MENU_LINK_OPEN_MODE: CustomMenuLinkOpenMode = 'internal'
export function normalizeCustomMenuPlacement(
value: unknown,
@@ -11,10 +12,20 @@ export function normalizeCustomMenuPlacement(
return DEFAULT_CUSTOM_MENU_PLACEMENT
}
+export function normalizeCustomMenuLinkOpenMode(
+ value: unknown,
+): CustomMenuLinkOpenMode {
+ if (value === 'external') {
+ return value
+ }
+ return DEFAULT_CUSTOM_MENU_LINK_OPEN_MODE
+}
+
export function normalizeCustomMenuItem(item: CustomMenuItem): CustomMenuItem {
return {
...item,
placement: normalizeCustomMenuPlacement(item.placement),
+ link_open_mode: normalizeCustomMenuLinkOpenMode(item.link_open_mode),
}
}
@@ -45,7 +56,7 @@ export function getCustomMenuRoute(id: string): string {
return `/custom/${encodeURIComponent(id)}`
}
-export function getHomeHeaderMenuHref(
+export function getCustomMenuHref(
item: Pick,
): string {
if (item.page_slug || item.url?.startsWith('md:')) {
@@ -59,3 +70,11 @@ export function getHomeHeaderMenuHref(
return getCustomMenuRoute(item.id)
}
+
+export function isExternalCustomMenuLink(
+ item: { link_open_mode?: unknown },
+): boolean {
+ return normalizeCustomMenuLinkOpenMode(item.link_open_mode) === 'external'
+}
+
+export const getHomeHeaderMenuHref = getCustomMenuHref
diff --git a/frontend/src/utils/theme.ts b/frontend/src/utils/theme.ts
new file mode 100644
index 00000000..fa817e36
--- /dev/null
+++ b/frontend/src/utils/theme.ts
@@ -0,0 +1,111 @@
+import { computed, ref } from 'vue'
+
+export type ThemePreference = 'light' | 'dark' | 'system'
+export type ThemeColorScheme = 'light' | 'dark'
+export type ThemeTemplate = 'default'
+
+const THEME_STORAGE_KEY = 'theme'
+const THEME_TEMPLATE_STORAGE_KEY = 'theme-template'
+const DEFAULT_THEME_TEMPLATE: ThemeTemplate = 'default'
+const THEME_TEMPLATES = new Set([DEFAULT_THEME_TEMPLATE])
+
+const themePreference = ref('system')
+const themeTemplate = ref(DEFAULT_THEME_TEMPLATE)
+const activeColorScheme = ref('light')
+const isDarkTheme = computed(() => activeColorScheme.value === 'dark')
+
+let mediaQuery: MediaQueryList | null = null
+let mediaQueryListenerReady = false
+
+function getStoredThemePreference(): ThemePreference {
+ if (typeof window === 'undefined') return 'system'
+ const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY)
+ if (storedTheme === 'light' || storedTheme === 'dark' || storedTheme === 'system') {
+ return storedTheme
+ }
+ return 'system'
+}
+
+function getStoredThemeTemplate(): ThemeTemplate {
+ if (typeof window === 'undefined') return DEFAULT_THEME_TEMPLATE
+ const storedTemplate = window.localStorage.getItem(THEME_TEMPLATE_STORAGE_KEY)
+ if (storedTemplate && THEME_TEMPLATES.has(storedTemplate as ThemeTemplate)) {
+ return storedTemplate as ThemeTemplate
+ }
+ return DEFAULT_THEME_TEMPLATE
+}
+
+function getSystemColorScheme(): ThemeColorScheme {
+ if (typeof window === 'undefined') return 'light'
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
+}
+
+function resolveColorScheme(preference: ThemePreference): ThemeColorScheme {
+ return preference === 'system' ? getSystemColorScheme() : preference
+}
+
+function applyThemeToDocument(): void {
+ if (typeof document === 'undefined') return
+
+ const scheme = resolveColorScheme(themePreference.value)
+ const docEl = document.documentElement
+
+ activeColorScheme.value = scheme
+ docEl.classList.toggle('dark', scheme === 'dark')
+ docEl.dataset.theme = `${themeTemplate.value}-${scheme}`
+ docEl.dataset.themeTemplate = themeTemplate.value
+ docEl.dataset.themeMode = scheme
+ docEl.style.colorScheme = scheme
+}
+
+function ensureSystemThemeListener(): void {
+ if (typeof window === 'undefined' || mediaQueryListenerReady) return
+
+ mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
+ mediaQuery.addEventListener('change', () => {
+ if (themePreference.value === 'system') {
+ applyThemeToDocument()
+ }
+ })
+ mediaQueryListenerReady = true
+}
+
+export function initTheme(): void {
+ themePreference.value = getStoredThemePreference()
+ themeTemplate.value = getStoredThemeTemplate()
+ ensureSystemThemeListener()
+ applyThemeToDocument()
+}
+
+export function setThemePreference(preference: ThemePreference): void {
+ themePreference.value = preference
+ if (typeof window !== 'undefined') {
+ window.localStorage.setItem(THEME_STORAGE_KEY, preference)
+ }
+ applyThemeToDocument()
+}
+
+export function setThemeTemplate(template: ThemeTemplate): void {
+ themeTemplate.value = THEME_TEMPLATES.has(template) ? template : DEFAULT_THEME_TEMPLATE
+ if (typeof window !== 'undefined') {
+ window.localStorage.setItem(THEME_TEMPLATE_STORAGE_KEY, themeTemplate.value)
+ }
+ applyThemeToDocument()
+}
+
+export function toggleTheme(): void {
+ setThemePreference(activeColorScheme.value === 'dark' ? 'light' : 'dark')
+}
+
+export function useTheme() {
+ return {
+ activeColorScheme,
+ isDarkTheme,
+ themePreference,
+ themeTemplate,
+ initTheme,
+ setThemePreference,
+ setThemeTemplate,
+ toggleTheme
+ }
+}
diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue
index 692be3c7..ebeb9fe2 100644
--- a/frontend/src/views/HomeView.vue
+++ b/frontend/src/views/HomeView.vue
@@ -56,6 +56,8 @@
v-for="item in homeHeaderMenuItems"
:key="item.id"
:href="customMenuHref(item)"
+ :target="customMenuTarget(item)"
+ :rel="customMenuRel(item)"
class="inline-flex items-center gap-2 rounded-full border border-gray-200/70 bg-white/80 px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:border-primary-300 hover:text-primary-700 dark:border-dark-700/80 dark:bg-dark-900/70 dark:text-dark-100 dark:hover:border-primary-500/50 dark:hover:text-white"
>