mirror of
https://github.com/StepanovPlaton/AboutMe.git
synced 2026-04-06 05:40:52 +04:00
Initial commit
This commit is contained in:
139
src/components/navbar/displaySettings.svelte
Normal file
139
src/components/navbar/displaySettings.svelte
Normal 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>
|
||||
106
src/components/navbar/lightDarkSwitch.svelte
Normal file
106
src/components/navbar/lightDarkSwitch.svelte
Normal 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>
|
||||
30
src/components/navbar/navLinks.astro
Normal file
30
src/components/navbar/navLinks.astro
Normal 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>
|
||||
69
src/components/navbar/navMenu.svelte
Normal file
69
src/components/navbar/navMenu.svelte
Normal 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>
|
||||
285
src/components/navbar/search.svelte
Normal file
285
src/components/navbar/search.svelte
Normal 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>
|
||||
153
src/components/navbar/translator.svelte
Normal file
153
src/components/navbar/translator.svelte
Normal 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>
|
||||
106
src/components/navbar/wallpaperSwitch.svelte
Normal file
106
src/components/navbar/wallpaperSwitch.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user