mirror of
https://github.com/StepanovPlaton/AboutMe.git
synced 2026-07-28 19:35:51 +04:00
Fix preloader
This commit is contained in:
@@ -1,29 +1,45 @@
|
||||
---
|
||||
// 全局Iconify加载器组件
|
||||
// 在页面头部加载,确保图标库尽早可用
|
||||
import { url } from "@utils/url";
|
||||
|
||||
export interface Props {
|
||||
preloadIcons?: string[]; // 需要预加载的图标列表
|
||||
timeout?: number; // 加载超时时间
|
||||
retryCount?: number; // 重试次数
|
||||
preloadIcons?: string[];
|
||||
}
|
||||
|
||||
const { preloadIcons = [], timeout = 10000, retryCount = 3 } = Astro.props;
|
||||
const { preloadIcons = [] } = Astro.props;
|
||||
const iconifyScriptUrl = url("/assets/iconify/iconify-icon.min.js");
|
||||
---
|
||||
|
||||
<!-- Iconify图标库加载器 -->
|
||||
<script define:vars={{ preloadIcons, timeout, retryCount }}>
|
||||
// 全局图标加载逻辑
|
||||
(function() {
|
||||
'use strict';
|
||||
<div
|
||||
id="iconify-loader-config"
|
||||
data-script-url={iconifyScriptUrl}
|
||||
data-preload-icons={JSON.stringify(preloadIcons)}
|
||||
hidden
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
|
||||
<script>
|
||||
import {
|
||||
registerSvelteIconifyCollections,
|
||||
registerWebComponentIconifyCollections,
|
||||
} from "@/utils/iconify-offline";
|
||||
|
||||
const LOAD_TIMEOUT_MS = 3000;
|
||||
const RETRY_COUNT = 1;
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
// 避免重复加载
|
||||
if (window.__iconifyLoaderInitialized) {
|
||||
return;
|
||||
}
|
||||
window.__iconifyLoaderInitialized = true;
|
||||
|
||||
// 图标加载器类
|
||||
registerSvelteIconifyCollections();
|
||||
|
||||
const configEl = document.getElementById("iconify-loader-config");
|
||||
const iconifyScriptUrl = configEl?.dataset.scriptUrl ?? "/assets/iconify/iconify-icon.min.js";
|
||||
const preloadIcons = JSON.parse(configEl?.dataset.preloadIcons ?? "[]");
|
||||
|
||||
class IconifyLoader {
|
||||
constructor() {
|
||||
this.isLoaded = false;
|
||||
@@ -34,7 +50,10 @@ const { preloadIcons = [], timeout = 10000, retryCount = 3 } = Astro.props;
|
||||
}
|
||||
|
||||
async load(options = {}) {
|
||||
const { timeout: loadTimeout = timeout, retryCount: maxRetries = retryCount } = options;
|
||||
const {
|
||||
timeout: loadTimeout = LOAD_TIMEOUT_MS,
|
||||
retryCount: maxRetries = RETRY_COUNT,
|
||||
} = options;
|
||||
|
||||
if (this.isLoaded) {
|
||||
return Promise.resolve();
|
||||
@@ -53,61 +72,74 @@ const { preloadIcons = [], timeout = 10000, retryCount = 3 } = Astro.props;
|
||||
this.notifyObservers();
|
||||
await this.processPreloadQueue();
|
||||
} catch (error) {
|
||||
console.error('Failed to load Iconify:', error);
|
||||
console.error("Failed to load Iconify:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async loadWithRetry(timeout, retryCount) {
|
||||
for (let attempt = 1; attempt <= retryCount; attempt++) {
|
||||
async loadWithRetry(timeoutMs, maxRetries) {
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
await this.loadScript(timeout);
|
||||
await this.loadScript(timeoutMs);
|
||||
return;
|
||||
} catch (error) {
|
||||
console.warn(`Iconify load attempt ${attempt} failed:`, error);
|
||||
if (attempt === retryCount) {
|
||||
throw new Error(`Failed to load Iconify after ${retryCount} attempts`);
|
||||
if (attempt === maxRetries) {
|
||||
throw new Error(
|
||||
`Failed to load Iconify after ${maxRetries} attempts`,
|
||||
);
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadScript(timeout) {
|
||||
loadScript(timeoutMs) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 检查是否已经存在
|
||||
if (this.isIconifyReady()) {
|
||||
registerWebComponentIconifyCollections();
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const existingScript = document.querySelector('script[src*="iconify-icon"]');
|
||||
const existingScript = document.querySelector(
|
||||
'script[src*="iconify-icon"]',
|
||||
);
|
||||
if (existingScript) {
|
||||
this.waitForIconifyReady().then(resolve).catch(reject);
|
||||
this.waitForIconifyReady()
|
||||
.then(() => {
|
||||
registerWebComponentIconifyCollections();
|
||||
resolve();
|
||||
})
|
||||
.catch(reject);
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://code.iconify.design/iconify-icon/1.0.7/iconify-icon.min.js';
|
||||
const script = document.createElement("script");
|
||||
script.src = iconifyScriptUrl;
|
||||
script.async = true;
|
||||
script.crossOrigin = 'anonymous';
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
script.remove();
|
||||
reject(new Error('Script load timeout'));
|
||||
}, timeout);
|
||||
reject(new Error("Script load timeout"));
|
||||
}, timeoutMs);
|
||||
|
||||
script.onload = () => {
|
||||
clearTimeout(timeoutId);
|
||||
this.waitForIconifyReady().then(resolve).catch(reject);
|
||||
this.waitForIconifyReady()
|
||||
.then(() => {
|
||||
registerWebComponentIconifyCollections();
|
||||
resolve();
|
||||
})
|
||||
.catch(reject);
|
||||
};
|
||||
|
||||
script.onerror = () => {
|
||||
clearTimeout(timeoutId);
|
||||
script.remove();
|
||||
reject(new Error('Script load error'));
|
||||
reject(new Error("Script load error"));
|
||||
};
|
||||
|
||||
document.head.appendChild(script);
|
||||
@@ -125,7 +157,7 @@ const { preloadIcons = [], timeout = 10000, retryCount = 3 } = Astro.props;
|
||||
}
|
||||
|
||||
if (Date.now() - startTime > maxWait) {
|
||||
reject(new Error('Iconify initialization timeout'));
|
||||
reject(new Error("Iconify initialization timeout"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -137,7 +169,11 @@ const { preloadIcons = [], timeout = 10000, retryCount = 3 } = Astro.props;
|
||||
}
|
||||
|
||||
isIconifyReady() {
|
||||
return typeof window !== 'undefined' && 'customElements' in window && customElements.get('iconify-icon') !== undefined;
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
"customElements" in window &&
|
||||
customElements.get("iconify-icon") !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
onLoad(callback) {
|
||||
@@ -149,11 +185,11 @@ const { preloadIcons = [], timeout = 10000, retryCount = 3 } = Astro.props;
|
||||
}
|
||||
|
||||
notifyObservers() {
|
||||
this.observers.forEach(callback => {
|
||||
this.observers.forEach((callback) => {
|
||||
try {
|
||||
callback();
|
||||
} catch (error) {
|
||||
console.error('Error in icon load observer:', error);
|
||||
console.error("Error in icon load observer:", error);
|
||||
}
|
||||
});
|
||||
this.observers.clear();
|
||||
@@ -161,7 +197,7 @@ const { preloadIcons = [], timeout = 10000, retryCount = 3 } = Astro.props;
|
||||
|
||||
addToPreloadQueue(icons) {
|
||||
if (Array.isArray(icons)) {
|
||||
icons.forEach(icon => this.preloadQueue.add(icon));
|
||||
icons.forEach((icon) => this.preloadQueue.add(icon));
|
||||
} else {
|
||||
this.preloadQueue.add(icons);
|
||||
}
|
||||
@@ -172,88 +208,34 @@ const { preloadIcons = [], timeout = 10000, retryCount = 3 } = Astro.props;
|
||||
}
|
||||
|
||||
async processPreloadQueue() {
|
||||
if (this.preloadQueue.size === 0) return;
|
||||
|
||||
const iconsToLoad = Array.from(this.preloadQueue);
|
||||
this.preloadQueue.clear();
|
||||
|
||||
await this.preloadIcons(iconsToLoad);
|
||||
}
|
||||
|
||||
async preloadIcons(icons) {
|
||||
if (!this.isLoaded || icons.length === 0) return;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let loadedCount = 0;
|
||||
const totalIcons = icons.length;
|
||||
const tempElements = [];
|
||||
|
||||
const cleanup = () => {
|
||||
tempElements.forEach(el => {
|
||||
if (el.parentNode) {
|
||||
el.parentNode.removeChild(el);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const checkComplete = () => {
|
||||
loadedCount++;
|
||||
if (loadedCount >= totalIcons) {
|
||||
cleanup();
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
icons.forEach(icon => {
|
||||
const tempIcon = document.createElement('iconify-icon');
|
||||
tempIcon.setAttribute('icon', icon);
|
||||
tempIcon.style.cssText = 'position:absolute;top:-9999px;left:-9999px;width:1px;height:1px;opacity:0;pointer-events:none;';
|
||||
|
||||
tempIcon.addEventListener('load', checkComplete);
|
||||
tempIcon.addEventListener('error', checkComplete);
|
||||
|
||||
tempElements.push(tempIcon);
|
||||
document.body.appendChild(tempIcon);
|
||||
});
|
||||
|
||||
// 设置超时清理
|
||||
setTimeout(() => {
|
||||
cleanup();
|
||||
resolve();
|
||||
}, 3000);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 创建全局实例
|
||||
window.__iconifyLoader = new IconifyLoader();
|
||||
|
||||
// 立即开始加载
|
||||
window.__iconifyLoader.load().catch(error => {
|
||||
console.error('Failed to initialize Iconify:', error);
|
||||
window.__iconifyLoader.load().catch((error) => {
|
||||
console.error("Failed to initialize Iconify:", error);
|
||||
});
|
||||
|
||||
// 如果有预加载图标,添加到队列
|
||||
if (preloadIcons && preloadIcons.length > 0) {
|
||||
if (preloadIcons.length > 0) {
|
||||
window.__iconifyLoader.addToPreloadQueue(preloadIcons);
|
||||
}
|
||||
|
||||
// 导出便捷函数到全局
|
||||
window.loadIconify = () => window.__iconifyLoader.load();
|
||||
window.preloadIcons = (icons) => window.__iconifyLoader.addToPreloadQueue(icons);
|
||||
window.onIconifyReady = (callback) => window.__iconifyLoader.onLoad(callback);
|
||||
window.preloadIcons = (icons) =>
|
||||
window.__iconifyLoader.addToPreloadQueue(icons);
|
||||
window.onIconifyReady = (callback) =>
|
||||
window.__iconifyLoader.onLoad(callback);
|
||||
|
||||
// 页面可见性变化时重新检查
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!document.hidden && !window.__iconifyLoader.isLoaded) {
|
||||
window.__iconifyLoader.load().catch(console.error);
|
||||
}
|
||||
});
|
||||
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- 为不支持JavaScript的情况提供备用方案 -->
|
||||
<noscript>
|
||||
<style>
|
||||
iconify-icon {
|
||||
@@ -263,4 +245,4 @@ const { preloadIcons = [], timeout = 10000, retryCount = 3 } = Astro.props;
|
||||
display: inline-block;
|
||||
}
|
||||
</style>
|
||||
</noscript>
|
||||
</noscript>
|
||||
|
||||
@@ -9,7 +9,7 @@ interface Props {
|
||||
const className = Astro.props.class;
|
||||
---
|
||||
|
||||
<div data-pagefind-body class={`prose dark:prose-invert prose-base max-w-none! custom-md ${className}`}>
|
||||
<div class={`prose dark:prose-invert prose-base max-w-none! custom-md ${className}`}>
|
||||
<!--<div class="prose dark:prose-invert max-w-none custom-md">-->
|
||||
<!--<div class="max-w-none custom-md">-->
|
||||
<slot/>
|
||||
|
||||
@@ -8,7 +8,6 @@ import { url } from "@utils/url";
|
||||
import { getNavbarTransparentModeForWallpaperMode, getDefaultWallpaperMode } from "@utils/wallpaper";
|
||||
import NavLinks from "@components/navbar/navLinks.astro";
|
||||
import NavMenu from "@components/navbar/navMenu.svelte";
|
||||
import Search from "@components/navbar/search.svelte";
|
||||
import Translator from "@components/navbar/translator.svelte";
|
||||
import DisplaySettings from "@components/navbar/displaySettings.svelte";
|
||||
import LightDarkSwitch from "@components/navbar/lightDarkSwitch.svelte";
|
||||
@@ -51,7 +50,6 @@ let links: NavbarLink[] = navbarConfig.links.map(
|
||||
})}
|
||||
</div>
|
||||
<div class="flex items-center navbar-buttons" id="navbar-buttons">
|
||||
<!-- <Search client:load></Search> -->
|
||||
<Translator client:load></Translator>
|
||||
<!-- <DisplaySettings client:load></DisplaySettings> -->
|
||||
<LightDarkSwitch client:load></LightDarkSwitch>
|
||||
@@ -133,61 +131,4 @@ if (document.readyState === 'loading') {
|
||||
} else {
|
||||
initSemifullScrollDetection();
|
||||
}
|
||||
</script>
|
||||
|
||||
{import.meta.env.PROD && <script is:inline define:vars={{scriptUrl: url('/pagefind/pagefind.js')}}>
|
||||
async function loadPagefind() {
|
||||
try {
|
||||
const response = await fetch(scriptUrl, { method: 'HEAD' });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Pagefind script not found: ${response.status}`);
|
||||
}
|
||||
|
||||
const pagefind = await import(scriptUrl);
|
||||
|
||||
await pagefind.options({
|
||||
excerptLength: 20
|
||||
});
|
||||
|
||||
window.pagefind = pagefind;
|
||||
|
||||
document.dispatchEvent(new CustomEvent('pagefindready'));
|
||||
console.log('Pagefind loaded and initialized successfully, event dispatched.');
|
||||
} catch (error) {
|
||||
console.error('Failed to load Pagefind:', error);
|
||||
window.pagefind = {
|
||||
search: () => Promise.resolve({ results: [] }),
|
||||
options: () => Promise.resolve(),
|
||||
};
|
||||
document.dispatchEvent(new CustomEvent('pagefindloaderror'));
|
||||
console.log('Pagefind load error, event dispatched.');
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', loadPagefind);
|
||||
} else {
|
||||
loadPagefind();
|
||||
}
|
||||
|
||||
// Переинициализация Pagefind после навигации Swup
|
||||
if (typeof window !== 'undefined' && window.swup) {
|
||||
window.swup.hooks.on('page:view', () => {
|
||||
// Переинициализируем Pagefind после навигации, если он еще не загружен
|
||||
if (!window.pagefind || typeof window.pagefind.search !== 'function') {
|
||||
loadPagefind();
|
||||
}
|
||||
});
|
||||
} else if (typeof window !== 'undefined') {
|
||||
// Если Swup еще не загружен, ждем события
|
||||
document.addEventListener('swup:enable', () => {
|
||||
if (window.swup) {
|
||||
window.swup.hooks.on('page:view', () => {
|
||||
if (!window.pagefind || typeof window.pagefind.search !== 'function') {
|
||||
loadPagefind();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>}
|
||||
</script>
|
||||
@@ -1,285 +0,0 @@
|
||||
<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>
|
||||
37
src/global.d.ts
vendored
37
src/global.d.ts
vendored
@@ -14,13 +14,6 @@ declare global {
|
||||
__iconifyLoader: {
|
||||
load: () => Promise<void>;
|
||||
};
|
||||
pagefind: {
|
||||
search: (query: string) => Promise<{
|
||||
results: Array<{
|
||||
data: () => Promise<SearchResult>;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
translate?: {
|
||||
service: {
|
||||
use: (service: string) => void;
|
||||
@@ -51,32 +44,4 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
interface SearchResult {
|
||||
url: string;
|
||||
meta: {
|
||||
title: string;
|
||||
};
|
||||
excerpt: string;
|
||||
content?: string;
|
||||
word_count?: number;
|
||||
filters?: Record<string, unknown>;
|
||||
anchors?: Array<{
|
||||
element: string;
|
||||
id: string;
|
||||
text: string;
|
||||
location: number;
|
||||
}>;
|
||||
weighted_locations?: Array<{
|
||||
weight: number;
|
||||
balanced_score: number;
|
||||
location: number;
|
||||
}>;
|
||||
locations?: number[];
|
||||
raw_content?: string;
|
||||
raw_url?: string;
|
||||
sub_results?: SearchResult[];
|
||||
}
|
||||
|
||||
|
||||
export { SearchResult };
|
||||
export {};
|
||||
|
||||
@@ -127,7 +127,6 @@ const umamiScripts = umamiConfig.scripts || ""; // 获取Umami scripts配置
|
||||
<!-- Resource Preconnect -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="preconnect" href="https://code.iconify.design">
|
||||
<!-- Font Loader -->
|
||||
<FontLoader />
|
||||
<!-- title -->
|
||||
@@ -481,42 +480,41 @@ loadAndInitTranslate();
|
||||
// Initialize particle effects
|
||||
setupParticleEffects();
|
||||
|
||||
// Remove is-loading class and trigger initial-animation after page load
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('load', () => {
|
||||
const overlay = document.getElementById('loading-overlay');
|
||||
const enableBanner = document.body.classList.contains('enable-banner');
|
||||
// 触发 LoadingOverlay 淡出
|
||||
function hideLoadingOverlay() {
|
||||
const overlay = document.getElementById('loading-overlay');
|
||||
const enableBanner = document.body.classList.contains('enable-banner');
|
||||
if (overlay) {
|
||||
overlay.classList.add('fade-out');
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (overlay) {
|
||||
overlay.classList.add('fade-out');
|
||||
overlay.style.display = 'none';
|
||||
}
|
||||
// 等待 LoadingOverlay 淡出(600ms)
|
||||
setTimeout(() => {
|
||||
if (overlay) {
|
||||
overlay.style.display = 'none';
|
||||
}
|
||||
if (enableBanner) {
|
||||
// banner 开始恢复过渡:添加 banner-restoring 类(解除 CSS 中的 height: 0)
|
||||
document.documentElement.classList.add('banner-restoring');
|
||||
// 等待 banner 恢复过渡完成(600ms)
|
||||
setTimeout(() => {
|
||||
// 触发各容器淡入动画
|
||||
document.documentElement.classList.remove('is-loading');
|
||||
document.documentElement.classList.add('show-initial-animation');
|
||||
setTimeout(() => {
|
||||
document.documentElement.classList.remove('show-initial-animation');
|
||||
document.documentElement.classList.remove('banner-restoring');
|
||||
}, 1200);
|
||||
}, 600);
|
||||
} else {
|
||||
// 非 banner 模式,直接触发淡入
|
||||
if (enableBanner) {
|
||||
document.documentElement.classList.add('banner-restoring');
|
||||
setTimeout(() => {
|
||||
document.documentElement.classList.remove('is-loading');
|
||||
document.documentElement.classList.add('show-initial-animation');
|
||||
setTimeout(() => {
|
||||
document.documentElement.classList.remove('show-initial-animation');
|
||||
document.documentElement.classList.remove('banner-restoring');
|
||||
}, 1200);
|
||||
}
|
||||
}, 600);
|
||||
});
|
||||
}, 600);
|
||||
} else {
|
||||
document.documentElement.classList.remove('is-loading');
|
||||
document.documentElement.classList.add('show-initial-animation');
|
||||
setTimeout(() => {
|
||||
document.documentElement.classList.remove('show-initial-animation');
|
||||
}, 1200);
|
||||
}
|
||||
}, 600);
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', hideLoadingOverlay);
|
||||
} else {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -153,9 +153,6 @@ const jsonLd = {
|
||||
<!-- title -->
|
||||
<div class="relative onload-animation-up">
|
||||
<div
|
||||
data-pagefind-body
|
||||
data-pagefind-weight="10"
|
||||
data-pagefind-meta="title"
|
||||
class="transition w-full block font-bold mb-3
|
||||
text-3xl md:text-[2.25rem]/[2.75rem]
|
||||
text-black/90 dark:text-white/90
|
||||
|
||||
78
src/utils/iconify-offline.ts
Normal file
78
src/utils/iconify-offline.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { addCollection } from "@iconify/svelte/dist/functions.js";
|
||||
import type { IconifyJSON } from "@iconify/types";
|
||||
import devicon from "@iconify-json/devicon/icons.json";
|
||||
import gameIcons from "@iconify-json/game-icons/icons.json";
|
||||
import hugeicons from "@iconify-json/hugeicons/icons.json";
|
||||
import iconoir from "@iconify-json/iconoir/icons.json";
|
||||
import logos from "@iconify-json/logos/icons.json";
|
||||
import materialIconTheme from "@iconify-json/material-icon-theme/icons.json";
|
||||
import materialSymbols from "@iconify-json/material-symbols/icons.json";
|
||||
import mdi from "@iconify-json/mdi/icons.json";
|
||||
import mingcute from "@iconify-json/mingcute/icons.json";
|
||||
import skillIcons from "@iconify-json/skill-icons/icons.json";
|
||||
import vscodeIcons from "@iconify-json/vscode-icons/icons.json";
|
||||
|
||||
const collections: IconifyJSON[] = [
|
||||
materialSymbols,
|
||||
logos,
|
||||
devicon,
|
||||
hugeicons,
|
||||
mingcute,
|
||||
mdi,
|
||||
skillIcons,
|
||||
vscodeIcons,
|
||||
materialIconTheme,
|
||||
gameIcons,
|
||||
iconoir,
|
||||
];
|
||||
|
||||
let svelteRegistered = false;
|
||||
let webComponentRegistered = false;
|
||||
|
||||
function addCollectionsToWebComponent(): void {
|
||||
if (typeof window === "undefined" || webComponentRegistered) {
|
||||
return;
|
||||
}
|
||||
|
||||
const IconifyIcon = customElements.get("iconify-icon") as
|
||||
| (CustomElementConstructor & {
|
||||
addCollection?: (data: IconifyJSON, provider?: string) => boolean;
|
||||
disableCache?: (storage: string) => void;
|
||||
})
|
||||
| undefined;
|
||||
|
||||
if (!IconifyIcon?.addCollection) {
|
||||
return;
|
||||
}
|
||||
|
||||
IconifyIcon.disableCache?.("all");
|
||||
|
||||
for (const collection of collections) {
|
||||
IconifyIcon.addCollection(collection);
|
||||
}
|
||||
|
||||
webComponentRegistered = true;
|
||||
}
|
||||
|
||||
/** Register offline collections for @iconify/svelte (navbar, player, etc.). */
|
||||
export function registerSvelteIconifyCollections(): void {
|
||||
if (svelteRegistered) {
|
||||
return;
|
||||
}
|
||||
svelteRegistered = true;
|
||||
|
||||
for (const collection of collections) {
|
||||
addCollection(collection);
|
||||
}
|
||||
}
|
||||
|
||||
/** Register offline collections for iconify-icon web component (icon.astro). */
|
||||
export function registerWebComponentIconifyCollections(): void {
|
||||
addCollectionsToWebComponent();
|
||||
}
|
||||
|
||||
/** Register all offline icon sets (Svelte + web component when available). */
|
||||
export function registerIconifyCollections(): void {
|
||||
registerSvelteIconifyCollections();
|
||||
registerWebComponentIconifyCollections();
|
||||
}
|
||||
Reference in New Issue
Block a user