Initial commit

This commit is contained in:
2026-02-02 22:47:52 +03:00
committed by GitHub
commit f53016aeda
239 changed files with 84360 additions and 0 deletions

View File

@@ -0,0 +1,139 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import { onMount } from "svelte";
import { BREAKPOINT_LG } from "@constants/breakpoints";
import { getDefaultHue, getHue, setHue } from "@utils/hue";
import { onClickOutside } from "@utils/widget";
import { i18n } from "@i18n/translation";
import I18nKey from "@i18n/i18nKey";
let hue = $state(getDefaultHue());
const defaultHue = getDefaultHue();
let isOpen = $state(false);
function resetHue() {
hue = getDefaultHue();
}
function togglePanel() {
isOpen = !isOpen;
}
function openPanel() {
isOpen = true;
}
function closePanel() {
isOpen = false;
}
// 点击外部关闭面板
function handleClickOutside(event: MouseEvent) {
if (!isOpen) return;
onClickOutside(event, "display-setting", "display-settings-switch", () => {
isOpen = false;
});
}
onMount(() => {
hue = getHue();
document.addEventListener("click", handleClickOutside);
return () => {
document.removeEventListener("click", handleClickOutside);
};
});
$effect(() => {
if (hue || hue === 0) {
setHue(hue);
}
});
</script>
<div class="relative z-50" onmouseleave={closePanel}>
<button aria-label="Display Settings" class="btn-plain scale-animation rounded-lg h-11 w-11 active:scale-90 flex items-center justify-center"
id="display-settings-switch"
onclick={() => { if (window.innerWidth < BREAKPOINT_LG) { openPanel(); } else { togglePanel(); } }}
onmouseenter={openPanel}
>
<Icon icon="material-symbols:palette-outline" class="text-[1.25rem]"></Icon>
</button>
<div id="display-setting-wrapper" class="fixed top-14.5 pt-5 right-4 w-[calc(100vw-2rem)] max-w-80 md:absolute md:top-11 md:right-0 md:w-80 md:pt-5 transition-all z-50" class:float-panel-closed={!isOpen}>
<div id="display-setting" class="card-base float-panel px-4 py-4 w-full">
<div class="flex flex-row gap-2 mb-3 items-center justify-between">
<div class="flex gap-2 font-bold text-lg text-neutral-900 dark:text-neutral-100 transition relative ml-3
before:w-1 before:h-4 before:rounded-md before:bg-(--primary)
before:absolute before:-left-3 before:top-[0.33rem]"
>
{i18n(I18nKey.themeColor)}
<button aria-label="Reset to Default" class="btn-regular w-7 h-7 rounded-md active:scale-90"
class:opacity-0={hue === defaultHue} class:pointer-events-none={hue === defaultHue} onclick={resetHue}>
<div class="text-(--btn-content)">
<Icon icon="fa6-solid:arrow-rotate-left" class="text-[0.875rem]"></Icon>
</div>
</button>
</div>
<div class="flex gap-1">
<div id="hueValue" class="transition bg-(--btn-regular-bg) w-10 h-7 rounded-md flex justify-center
font-bold text-sm items-center text-(--btn-content)">
{hue}
</div>
</div>
</div>
<div class="w-full h-6 px-1 bg-[oklch(0.80_0.10_0)] dark:bg-[oklch(0.70_0.10_0)] rounded-sm select-none">
<input aria-label={i18n(I18nKey.themeColor)} type="range" min="0" max="360" bind:value={hue}
class="slider" id="colorSlider" step="5" style="width: 100%">
</div>
</div>
</div>
</div>
<style lang="stylus">
#display-setting
input[type="range"]
-webkit-appearance none
height 1.5rem
background-image var(--color-selection-bar)
transition background-image 0.15s ease-in-out
/* Input Thumb */
&::-webkit-slider-thumb
-webkit-appearance none
height 1rem
width 0.5rem
border-radius 0.125rem
background rgba(255, 255, 255, 0.7)
box-shadow none
&:hover
background rgba(255, 255, 255, 0.8)
&:active
background rgba(255, 255, 255, 0.6)
&::-moz-range-thumb
-webkit-appearance none
height 1rem
width 0.5rem
border-radius 0.125rem
border-width 0
background rgba(255, 255, 255, 0.7)
box-shadow none
&:hover
background rgba(255, 255, 255, 0.8)
&:active
background rgba(255, 255, 255, 0.6)
&::-ms-thumb
-webkit-appearance none
height 1rem
width 0.5rem
border-radius 0.125rem
background rgba(255, 255, 255, 0.7)
box-shadow none
&:hover
background rgba(255, 255, 255, 0.8)
&:active
background rgba(255, 255, 255, 0.6)
</style>

View File

@@ -0,0 +1,106 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import { onMount } from "svelte";
import { BREAKPOINT_LG } from "@constants/breakpoints";
import { SYSTEM_MODE, DARK_MODE, LIGHT_MODE } from "@constants/constants";
import {
getStoredTheme,
setTheme,
} from "@utils/theme";
import { onClickOutside } from "@utils/widget";
import type { LIGHT_DARK_MODE } from "@/types/config";
import { siteConfig } from "@/config";
import { i18n } from "@i18n/translation";
import I18nKey from "@i18n/i18nKey";
import DropdownItem from "@/components/common/DropdownItem.svelte";
import DropdownPanel from "@/components/common/DropdownPanel.svelte";
const seq: LIGHT_DARK_MODE[] = [LIGHT_MODE, DARK_MODE, SYSTEM_MODE];
let mode: LIGHT_DARK_MODE = $state(siteConfig.defaultTheme || SYSTEM_MODE);
let isOpen = $state(false);
function switchScheme(newMode: LIGHT_DARK_MODE) {
mode = newMode;
setTheme(newMode);
}
function toggleScheme() {
let i = 0;
for (; i < seq.length; i++) {
if (seq[i] === mode) {
break;
}
}
switchScheme(seq[(i + 1) % seq.length]);
}
function openPanel() {
isOpen = true;
}
function closePanel() {
isOpen = false;
}
// 点击外部关闭面板
function handleClickOutside(event: MouseEvent) {
if (!isOpen) return;
onClickOutside(event, "light-dark-panel", "scheme-switch", () => {
isOpen = false;
});
}
onMount(() => {
mode = getStoredTheme();
document.addEventListener("click", handleClickOutside);
return () => {
document.removeEventListener("click", handleClickOutside);
};
});
</script>
<!-- z-50 make the panel higher than other float panels -->
<div class="relative z-50" role="menu" tabindex="-1" onmouseleave={closePanel}>
<button aria-label="Light/Dark/System Mode" role="menuitem" class="relative btn-plain scale-animation rounded-lg h-11 w-11 active:scale-90" id="scheme-switch" onmouseenter={openPanel} onclick={() => { if (window.innerWidth < BREAKPOINT_LG) { openPanel(); } else { toggleScheme(); } }}>
<div class="absolute" class:opacity-0={mode !== LIGHT_MODE}>
<Icon icon="material-symbols:wb-sunny-outline-rounded" class="text-[1.25rem]"></Icon>
</div>
<div class="absolute" class:opacity-0={mode !== DARK_MODE}>
<Icon icon="material-symbols:dark-mode-outline-rounded" class="text-[1.25rem]"></Icon>
</div>
<div class="absolute" class:opacity-0={mode !== SYSTEM_MODE}>
<Icon icon="material-symbols:radio-button-partial-outline" class="text-[1.25rem]"></Icon>
</div>
</button>
<div id="light-dark-panel" class="absolute transition top-11 -right-2 pt-5" class:float-panel-closed={!isOpen}>
<DropdownPanel>
<DropdownItem
isActive={mode === LIGHT_MODE}
isLast={false}
onclick={() => switchScheme(LIGHT_MODE)}
>
<Icon icon="material-symbols:wb-sunny-outline-rounded" class="text-[1.25rem] mr-3"></Icon>
{i18n(I18nKey.lightMode)}
</DropdownItem>
<DropdownItem
isActive={mode === DARK_MODE}
isLast={false}
onclick={() => switchScheme(DARK_MODE)}
>
<Icon icon="material-symbols:dark-mode-outline-rounded" class="text-[1.25rem] mr-3"></Icon>
{i18n(I18nKey.darkMode)}
</DropdownItem>
<DropdownItem
isActive={mode === SYSTEM_MODE}
isLast={true}
onclick={() => switchScheme(SYSTEM_MODE)}
>
<Icon icon="material-symbols:radio-button-partial-outline" class="text-[1.25rem] mr-3"></Icon>
{i18n(I18nKey.systemMode)}
</DropdownItem>
</DropdownPanel>
</div>
</div>

View File

@@ -0,0 +1,30 @@
---
import { Icon } from "astro-icon/components";
import { type NavbarLink } from "@/types/config";
import { url } from "@utils/url";
interface Props {
link: NavbarLink;
class?: string;
}
const { link, class: className } = Astro.props;
---
<div class:list={["nav-link-container", className]}>
<a
aria-label={link.name}
href={link.external ? link.url : url(link.url)}
target={link.external ? "_blank" : null}
class="btn-plain scale-animation rounded-lg h-11 font-bold px-3 active:scale-95 nav-link-item"
data-link-name={link.name}
>
<div class="flex items-center">
{link.icon && <Icon name={link.icon} class="text-[1.1rem] nav-link-icon" />}
<span class="nav-link-text ml-2 hidden lg:inline">{link.name}</span>
{link.external && <Icon name="fa6-solid:arrow-up-right-from-square" class="text-[0.875rem] transition -translate-y-px ml-1 text-black/20 dark:text-white/20" />}
</div>
</a>
</div>

View File

@@ -0,0 +1,69 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import { onMount } from "svelte";
import type { NavbarLink } from "@/types/config";
import { url } from "@utils/url";
import { onClickOutside } from "@utils/widget";
interface Props {
links: NavbarLink[];
}
let { links }: Props = $props();
let isOpen = $state(false);
function togglePanel() {
isOpen = !isOpen;
}
// 点击外部关闭面板
function handleClickOutside(event: MouseEvent) {
if (!isOpen) return;
onClickOutside(event, "nav-menu-panel", "nav-menu-switch", () => {
isOpen = false;
});
}
onMount(() => {
document.addEventListener("click", handleClickOutside);
return () => {
document.removeEventListener("click", handleClickOutside);
};
});
</script>
<div class="relative md:hidden">
<button aria-label="Menu" name="Nav Menu" class="btn-plain scale-animation rounded-lg w-11 h-11 active:scale-90"
id="nav-menu-switch"
onclick={togglePanel}
>
<Icon icon="material-symbols:menu-rounded" class="text-[1.25rem]"></Icon>
</button>
<div id="nav-menu-panel"
class="float-panel fixed transition-all right-4 px-2 py-2 max-h-[80vh] overflow-y-auto"
class:float-panel-closed={!isOpen}
>
{#each links as link}
<div class="mobile-menu-item">
<a href={link.external ? link.url : url(link.url)}
class="group flex justify-between items-center py-2 pl-3 pr-1 rounded-lg gap-8 hover:bg-(--btn-plain-bg-hover) active:bg-(--btn-plain-bg-active) transition"
target={link.external ? "_blank" : null}
>
<div class="flex items-center transition text-black/75 dark:text-white/75 font-bold group-hover:text-(--primary) group-active:text-(--primary)">
{#if link.icon}
<Icon icon={link.icon} class="text-[1.1rem] mr-2" />
{/if}
{link.name}
</div>
{#if !link.external}
<Icon icon="material-symbols:chevron-right-rounded" class="transition text-[1.25rem] text-(--primary)" />
{:else}
<Icon icon="fa6-solid:arrow-up-right-from-square" class="transition text-[0.75rem] text-black/25 dark:text-white/25 -translate-x-1" />
{/if}
</a>
</div>
{/each}
</div>
</div>

View File

@@ -0,0 +1,285 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import Icon from "@iconify/svelte";
import type { SearchResult } from "@/global";
import { url } from "@utils/url";
import { navigateToPage } from "@utils/navigation";
import { onClickOutside } from "@utils/widget";
import { i18n } from "@i18n/translation";
import I18nKey from "@i18n/i18nKey";
import DropdownPanel from "@/components/common/DropdownPanel.svelte";
let keywordDesktop = $state("");
let keywordMobile = $state("");
let result: SearchResult[] = $state([]);
let isSearching = $state(false);
let pagefindLoaded = false;
let initialized = $state(false);
let isDesktopSearchExpanded = $state(false);
let debounceTimer: NodeJS.Timeout;
const fakeResult: SearchResult[] = [
{
url: url("/"),
meta: {
title: "This Is a Fake Search Result",
},
excerpt:
"Because the search cannot work in the <mark>dev</mark> environment.",
},
{
url: url("/"),
meta: {
title: "If You Want to Test the Search",
},
excerpt: "Try running <mark>npm build && npm preview</mark> instead.",
},
];
const togglePanel = () => {
const panel = document.getElementById("search-panel");
panel?.classList.toggle("float-panel-closed");
};
const toggleDesktopSearch = () => {
isDesktopSearchExpanded = !isDesktopSearchExpanded;
if (isDesktopSearchExpanded) {
setTimeout(() => {
const input = document.getElementById("search-input-desktop") as HTMLInputElement;
input?.focus();
}, 0);
}
};
const collapseDesktopSearch = () => {
if (!keywordDesktop) {
isDesktopSearchExpanded = false;
}
};
const handleBlur = () => {
// 延迟处理以允许搜索结果的点击事件先于折叠逻辑执行
setTimeout(() => {
isDesktopSearchExpanded = false;
// 仅隐藏面板并折叠,保留搜索关键词和结果以便下次展开时查看
setPanelVisibility(false, true);
}, 200);
};
const setPanelVisibility = (show: boolean, isDesktop: boolean): void => {
const panel = document.getElementById("search-panel");
if (!panel || !isDesktop) return;
if (show) {
panel.classList.remove("float-panel-closed");
} else {
panel.classList.add("float-panel-closed");
}
};
const closeSearchPanel = (): void => {
const panel = document.getElementById("search-panel");
if (panel) {
panel.classList.add("float-panel-closed");
}
// 清空搜索关键词和结果
keywordDesktop = "";
keywordMobile = "";
result = [];
};
const handleResultClick = (event: Event, url: string): void => {
event.preventDefault();
closeSearchPanel();
navigateToPage(url);
};
const search = async (keyword: string, isDesktop: boolean): Promise<void> => {
if (!keyword) {
setPanelVisibility(false, isDesktop);
result = [];
return;
}
if (!initialized) {
return;
}
isSearching = true;
try {
let searchResults: SearchResult[] = [];
if (import.meta.env.PROD && pagefindLoaded && window.pagefind) {
const response = await window.pagefind.search(keyword);
searchResults = await Promise.all(
response.results.map((item) => item.data()),
);
} else if (import.meta.env.DEV) {
searchResults = fakeResult;
} else {
searchResults = [];
console.error("Pagefind is not available in production environment.");
}
result = searchResults;
setPanelVisibility(result.length > 0, isDesktop);
} catch (error) {
console.error("Search error:", error);
result = [];
setPanelVisibility(false, isDesktop);
} finally {
isSearching = false;
}
};
const handleClickOutside = (event: MouseEvent) => {
const panel = document.getElementById("search-panel");
if (!panel || panel.classList.contains("float-panel-closed")) {
return;
}
onClickOutside(event, "search-panel", ["search-switch", "search-bar"], () => {
const panel = document.getElementById("search-panel");
panel?.classList.add("float-panel-closed");
isDesktopSearchExpanded = false;
});
};
onMount(() => {
document.addEventListener("click", handleClickOutside);
const initializeSearch = () => {
initialized = true;
pagefindLoaded =
typeof window !== "undefined" &&
!!window.pagefind &&
typeof window.pagefind.search === "function";
console.log("Pagefind status on init:", pagefindLoaded);
};
if (import.meta.env.DEV) {
console.log(
"Pagefind is not available in development mode. Using mock data.",
);
initializeSearch();
} else {
document.addEventListener("pagefindready", () => {
console.log("Pagefind ready event received.");
initializeSearch();
});
document.addEventListener("pagefindloaderror", () => {
console.warn(
"Pagefind load error event received. Search functionality will be limited.",
);
initializeSearch(); // Initialize with pagefindLoaded as false
});
// Fallback in case events are not caught or pagefind is already loaded by the time this script runs
setTimeout(() => {
if (!initialized) {
console.log("Fallback: Initializing search after timeout.");
initializeSearch();
}
}, 2000); // Adjust timeout as needed
}
});
$effect(() => {
if (initialized) {
const keyword = keywordDesktop || keywordMobile;
const isDesktop = !!keywordDesktop || isDesktopSearchExpanded;
clearTimeout(debounceTimer);
if (keyword) {
debounceTimer = setTimeout(() => {
search(keyword, isDesktop);
}, 300);
} else {
result = [];
setPanelVisibility(false, isDesktop);
}
}
});
$effect(() => {
if (typeof document !== 'undefined') {
const navbar = document.getElementById('navbar');
if (isDesktopSearchExpanded) {
navbar?.classList.add('is-searching');
} else {
navbar?.classList.remove('is-searching');
}
}
});
onDestroy(() => {
if (typeof document !== 'undefined') {
document.removeEventListener("click", handleClickOutside);
const navbar = document.getElementById('navbar');
navbar?.classList.remove('is-searching');
}
clearTimeout(debounceTimer);
});
</script>
<!-- search bar for desktop view (collapsed by default) -->
<div
id="search-bar"
class="hidden lg:flex transition-all items-center h-11 rounded-lg
{isDesktopSearchExpanded ? 'bg-black/4 hover:bg-black/6 focus-within:bg-black/6 dark:bg-white/5 dark:hover:bg-white/10 dark:focus-within:bg-white/10' : 'btn-plain scale-animation active:scale-90'}
{isDesktopSearchExpanded ? 'w-48' : 'w-11'}"
role="button"
tabindex="0"
aria-label="Search"
onmouseenter={() => {if (!isDesktopSearchExpanded) toggleDesktopSearch()}}
onmouseleave={collapseDesktopSearch}
>
<Icon icon="material-symbols:search" class="absolute text-[1.25rem] pointer-events-none {isDesktopSearchExpanded ? 'ml-3' : 'left-1/2 -translate-x-1/2'} transition my-auto {isDesktopSearchExpanded ? 'text-black/30 dark:text-white/30' : ''}"></Icon>
<input id="search-input-desktop" placeholder="{i18n(I18nKey.search)}" bind:value={keywordDesktop}
onfocus={() => {if (!isDesktopSearchExpanded) toggleDesktopSearch(); search(keywordDesktop, true)}}
onblur={handleBlur}
class="transition-all pl-10 text-sm bg-transparent outline-0
h-full {isDesktopSearchExpanded ? 'w-36' : 'w-0'} text-black/50 dark:text-white/50"
>
</div>
<!-- toggle btn for phone/tablet view -->
<button onclick={togglePanel} aria-label="Search Panel" id="search-switch"
class="btn-plain scale-animation lg:hidden! rounded-lg w-11 h-11 active:scale-90">
<Icon icon="material-symbols:search" class="text-[1.25rem]"></Icon>
</button>
<!-- search panel -->
<DropdownPanel
id="search-panel"
class="float-panel-closed absolute md:w-120 top-20 left-4 md:left-[unset] right-4 z-50 search-panel"
>
<!-- search bar inside panel for phone/tablet -->
<div id="search-bar-inside" class="flex relative lg:hidden transition-all items-center h-11 rounded-xl
bg-black/4 hover:bg-black/6 focus-within:bg-black/6
dark:bg-white/5 dark:hover:bg-white/10 dark:focus-within:bg-white/10
">
<Icon icon="material-symbols:search" class="absolute text-[1.25rem] pointer-events-none ml-3 transition my-auto text-black/30 dark:text-white/30"></Icon>
<input placeholder="Search" bind:value={keywordMobile}
class="pl-10 absolute inset-0 text-sm bg-transparent outline-0
focus:w-60 text-black/50 dark:text-white/50"
>
</div>
<!-- search results -->
{#each result as item}
<a href={item.url}
onclick={(e) => handleResultClick(e, item.url)}
class="transition first-of-type:mt-2 lg:first-of-type:mt-0 group block
rounded-xl text-lg px-3 py-2 hover:bg-(--btn-plain-bg-hover) active:bg-(--btn-plain-bg-active)">
<div class="transition text-90 inline-flex font-bold group-hover:text-(--primary)">
{item.meta.title}<Icon icon="fa6-solid:chevron-right" class="transition text-[0.75rem] translate-x-1 my-auto text-(--primary)"></Icon>
</div>
<div class="transition text-sm text-50">
{@html item.excerpt}
</div>
</a>
{/each}
</DropdownPanel>
<style>
input:focus {
outline: 0;
}
:global(.search-panel) {
max-height: calc(100vh - 100px);
overflow-y: auto;
}
</style>

View File

@@ -0,0 +1,153 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import Icon from "@iconify/svelte";
import { BREAKPOINT_LG } from "@constants/breakpoints";
import { getTranslateLanguageFromConfig, getSiteLanguage, setStoredLanguage, getDefaultLanguage } from "@/utils/language";
import { onClickOutside } from "@utils/widget";
import { siteConfig } from "@/config";
import { getSupportedTranslateLanguages } from "@/i18n/language";
import DropdownItem from "@/components/common/DropdownItem.svelte";
import DropdownPanel from "@/components/common/DropdownPanel.svelte";
let isOpen = $state(false);
let translatePanel: HTMLElement | undefined = $state();
let currentLanguage = $state("");
// 从统一配置动态获取支持的语言列表
const languages = getSupportedTranslateLanguages();
// 根据配置文件的语言设置获取源语言
const sourceLanguage = getTranslateLanguageFromConfig(
getDefaultLanguage(),
);
function togglePanel() {
isOpen = !isOpen;
}
function openPanel() {
isOpen = true;
}
function closePanel() {
isOpen = false;
}
async function changeLanguage(languageCode: string) {
try {
// 如果翻译脚本未加载,先加载
if (!(window as any).translateScriptLoaded && typeof (window as any).loadTranslateScript === "function") {
await (window as any).loadTranslateScript();
}
// 确认翻译脚本已加载
if (!(window as any).translate) {
console.warn("translate.js is not loaded");
return;
}
// 获取翻译实例
const translate = (window as any).translate;
// 检查是否切换回源语言
const localLang = translate.language.getLocal();
// 统一使用 changeLanguage 方法
translate.changeLanguage(languageCode);
// 如果是切换回源语言,额外执行一次 reset 以确保在不刷新的情况下也能还原
if (languageCode === localLang) {
translate.reset();
}
// 同步保存到我们的缓存中
setStoredLanguage(languageCode);
// 更新当前 UI 状态
currentLanguage = languageCode;
} catch (error) {
console.error("Failed to execute translation:", error);
}
// 关闭面板
isOpen = false;
}
// 点击外部关闭面板
function handleClickOutside(event: MouseEvent) {
if (!isOpen) return;
onClickOutside(event, "translate-panel", "translate-switch", () => {
isOpen = false;
});
}
// 组件挂载时添加事件监听和初始化默认语言
onMount(() => {
document.addEventListener("click", handleClickOutside);
// 初始化当前语言为站点语言(优先缓存)
currentLanguage = getSiteLanguage();
});
onDestroy(() => {
if (typeof document !== "undefined") {
document.removeEventListener("click", handleClickOutside);
}
});
</script>
{#if siteConfig.translate?.enable}
<div class="relative z-50" onmouseleave={closePanel}>
<!-- 翻译按钮 -->
<button
aria-label="Language Translation"
class="btn-plain scale-animation rounded-lg h-11 w-11 active:scale-90"
id="translate-switch"
onclick={() => { if (window.innerWidth < BREAKPOINT_LG) { openPanel(); } else { togglePanel(); } }}
onmouseenter={openPanel}
>
<Icon icon="material-symbols:translate" class="text-[1.25rem] transition" />
</button>
<!-- 翻译面板 -->
<div id="translate-panel-wrapper" class="fixed top-14.5 pt-5 right-4 w-[calc(100vw-2rem)] max-w-64 md:absolute md:top-11 md:right-0 md:w-64 md:pt-5 transition-all z-50" class:float-panel-closed={!isOpen}>
<DropdownPanel
bind:element={translatePanel}
id="translate-panel"
class="p-4 w-full"
>
<div class="text-sm font-medium text-(--primary) mb-3 px-1">
选择语言 / Select Language
</div>
<div class="grid grid-cols-1 gap-2 max-h-64 overflow-y-auto">
{#each languages as lang}
<DropdownItem
isActive={currentLanguage === lang.code}
onclick={() => changeLanguage(lang.code)}
class="gap-3 p-2! h-auto!"
isLast={false}
>
<span class="text-lg transition">{lang.icon}</span>
<span class="text-sm transition grow text-left">{lang.name}</span>
{#if currentLanguage === lang.code}
<span class="ml-auto text-(--primary)"></span>
{/if}
</DropdownItem>
{/each}
</div>
</DropdownPanel>
</div>
</div>
{/if}
<style>
/* 滚动条样式 */
.overflow-y-auto::-webkit-scrollbar {
width: 4px;
}
.overflow-y-auto::-webkit-scrollbar-track {
background: transparent;
}
.overflow-y-auto::-webkit-scrollbar-thumb {
background: var(--scrollbar-bg);
border-radius: 2px;
}
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-bg-hover);
}
</style>

View File

@@ -0,0 +1,106 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import { onMount } from "svelte";
import { BREAKPOINT_LG } from "@/constants/breakpoints";
import { WALLPAPER_FULLSCREEN, WALLPAPER_BANNER, WALLPAPER_NONE } from "@constants/constants";
import {
getStoredWallpaperMode,
setWallpaperMode,
} from "@utils/wallpaper";
import { onClickOutside } from "@utils/widget";
import type { WALLPAPER_MODE } from "@/types/config";
import { siteConfig } from "@/config";
import { i18n } from "@i18n/translation";
import I18nKey from "@i18n/i18nKey";
import DropdownItem from "@/components/common/DropdownItem.svelte";
import DropdownPanel from "@/components/common/DropdownPanel.svelte";
const seq: WALLPAPER_MODE[] = [WALLPAPER_BANNER, WALLPAPER_FULLSCREEN, WALLPAPER_NONE];
let mode: WALLPAPER_MODE = $state(siteConfig.wallpaper.mode || WALLPAPER_BANNER);
let isOpen = $state(false);
function switchWallpaperMode(newMode: WALLPAPER_MODE) {
mode = newMode;
setWallpaperMode(newMode);
}
function toggleWallpaperMode() {
let i = 0;
for (; i < seq.length; i++) {
if (seq[i] === mode) {
break;
}
}
switchWallpaperMode(seq[(i + 1) % seq.length]);
}
function openPanel() {
isOpen = true;
}
function closePanel() {
isOpen = false;
}
// 点击外部关闭面板
function handleClickOutside(event: MouseEvent) {
if (!isOpen) return;
onClickOutside(event, "wallpaper-mode-panel", "wallpaper-mode-switch", () => {
isOpen = false;
});
}
onMount(() => {
mode = getStoredWallpaperMode();
document.addEventListener("click", handleClickOutside);
return () => {
document.removeEventListener("click", handleClickOutside);
};
});
</script>
<!-- z-50 make the panel higher than other float panels -->
<div class="relative z-50" role="menu" tabindex="-1" onmouseleave={closePanel}>
<button aria-label="Wallpaper Mode" role="menuitem" class="relative btn-plain scale-animation rounded-lg h-11 w-11 active:scale-90" id="wallpaper-mode-switch" onmouseenter={openPanel} onclick={() => { if (window.innerWidth < BREAKPOINT_LG) { openPanel(); } else { toggleWallpaperMode(); } }}>
<div class="absolute" class:opacity-0={mode !== WALLPAPER_BANNER}>
<Icon icon="material-symbols:image-outline" class="text-[1.25rem]"></Icon>
</div>
<div class="absolute" class:opacity-0={mode !== WALLPAPER_FULLSCREEN}>
<Icon icon="material-symbols:wallpaper" class="text-[1.25rem]"></Icon>
</div>
<div class="absolute" class:opacity-0={mode !== WALLPAPER_NONE}>
<Icon icon="material-symbols:hide-image-outline" class="text-[1.25rem]"></Icon>
</div>
</button>
<div id="wallpaper-mode-panel" class="absolute transition top-11 -right-2 pt-5" class:float-panel-closed={!isOpen}>
<DropdownPanel>
<DropdownItem
isActive={mode === WALLPAPER_BANNER}
isLast={false}
onclick={() => switchWallpaperMode(WALLPAPER_BANNER)}
>
<Icon icon="material-symbols:image-outline" class="text-[1.25rem] mr-3"></Icon>
{i18n(I18nKey.wallpaperBanner)}
</DropdownItem>
<DropdownItem
isActive={mode === WALLPAPER_FULLSCREEN}
isLast={false}
onclick={() => switchWallpaperMode(WALLPAPER_FULLSCREEN)}
>
<Icon icon="material-symbols:wallpaper" class="text-[1.25rem] mr-3"></Icon>
{i18n(I18nKey.wallpaperFullscreen)}
</DropdownItem>
<DropdownItem
isActive={mode === WALLPAPER_NONE}
isLast={true}
onclick={() => switchWallpaperMode(WALLPAPER_NONE)}
>
<Icon icon="material-symbols:hide-image-outline" class="text-[1.25rem] mr-3"></Icon>
{i18n(I18nKey.wallpaperNone)}
</DropdownItem>
</DropdownPanel>
</div>
</div>