mirror of
https://github.com/Ad-closeNN/blog-fuwari.git
synced 2026-05-31 02:20:05 -04:00
Initial commit
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
|
||||
import I18nKey from "../i18n/i18nKey";
|
||||
import { i18n } from "../i18n/translation";
|
||||
import { getPostUrlBySlug } from "../utils/url-utils";
|
||||
|
||||
export let tags: string[];
|
||||
export let categories: string[];
|
||||
export let sortedPosts: Post[] = [];
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
tags = params.has("tag") ? params.getAll("tag") : [];
|
||||
categories = params.has("category") ? params.getAll("category") : [];
|
||||
const uncategorized = params.get("uncategorized");
|
||||
|
||||
interface Post {
|
||||
slug: string;
|
||||
data: {
|
||||
title: string;
|
||||
tags: string[];
|
||||
category?: string;
|
||||
published: Date;
|
||||
};
|
||||
}
|
||||
|
||||
interface Group {
|
||||
year: number;
|
||||
posts: Post[];
|
||||
}
|
||||
|
||||
let groups: Group[] = [];
|
||||
|
||||
function formatDate(date: Date) {
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, "0");
|
||||
const day = date.getDate().toString().padStart(2, "0");
|
||||
return `${month}-${day}`;
|
||||
}
|
||||
|
||||
function formatTag(tagList: string[]) {
|
||||
return tagList.map((t) => `#${t}`).join(" ");
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
let filteredPosts: Post[] = sortedPosts;
|
||||
|
||||
if (tags.length > 0) {
|
||||
filteredPosts = filteredPosts.filter(
|
||||
(post) =>
|
||||
Array.isArray(post.data.tags) &&
|
||||
post.data.tags.some((tag) => tags.includes(tag)),
|
||||
);
|
||||
}
|
||||
|
||||
if (categories.length > 0) {
|
||||
filteredPosts = filteredPosts.filter(
|
||||
(post) => post.data.category && categories.includes(post.data.category),
|
||||
);
|
||||
}
|
||||
|
||||
if (uncategorized) {
|
||||
filteredPosts = filteredPosts.filter((post) => !post.data.category);
|
||||
}
|
||||
|
||||
const grouped = filteredPosts.reduce(
|
||||
(acc, post) => {
|
||||
const year = post.data.published.getFullYear();
|
||||
if (!acc[year]) {
|
||||
acc[year] = [];
|
||||
}
|
||||
acc[year].push(post);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<number, Post[]>,
|
||||
);
|
||||
|
||||
const groupedPostsArray = Object.keys(grouped).map((yearStr) => ({
|
||||
year: Number.parseInt(yearStr),
|
||||
posts: grouped[Number.parseInt(yearStr)],
|
||||
}));
|
||||
|
||||
groupedPostsArray.sort((a, b) => b.year - a.year);
|
||||
|
||||
groups = groupedPostsArray;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="card-base px-8 py-6">
|
||||
{#each groups as group}
|
||||
<div>
|
||||
<div class="flex flex-row w-full items-center h-[3.75rem]">
|
||||
<div class="w-[15%] md:w-[10%] transition text-2xl font-bold text-right text-75">
|
||||
{group.year}
|
||||
</div>
|
||||
<div class="w-[15%] md:w-[10%]">
|
||||
<div
|
||||
class="h-3 w-3 bg-none rounded-full outline outline-[var(--primary)] mx-auto
|
||||
-outline-offset-[2px] z-50 outline-3"
|
||||
></div>
|
||||
</div>
|
||||
<div class="w-[70%] md:w-[80%] transition text-left text-50">
|
||||
{group.posts.length} {i18n(group.posts.length === 1 ? I18nKey.postCount : I18nKey.postsCount)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#each group.posts as post}
|
||||
<a
|
||||
href={getPostUrlBySlug(post.slug)}
|
||||
aria-label={post.data.title}
|
||||
class="group btn-plain !block h-10 w-full rounded-lg hover:text-[initial]"
|
||||
>
|
||||
<div class="flex flex-row justify-start items-center h-full">
|
||||
<!-- date -->
|
||||
<div class="w-[15%] md:w-[10%] transition text-sm text-right text-50">
|
||||
{formatDate(post.data.published)}
|
||||
</div>
|
||||
|
||||
<!-- dot and line -->
|
||||
<div class="w-[15%] md:w-[10%] relative dash-line h-full flex items-center">
|
||||
<div
|
||||
class="transition-all mx-auto w-1 h-1 rounded group-hover:h-5
|
||||
bg-[oklch(0.5_0.05_var(--hue))] group-hover:bg-[var(--primary)]
|
||||
outline outline-4 z-50
|
||||
outline-[var(--card-bg)]
|
||||
group-hover:outline-[var(--btn-plain-bg-hover)]
|
||||
group-active:outline-[var(--btn-plain-bg-active)]"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- post title -->
|
||||
<div
|
||||
class="w-[70%] md:max-w-[65%] md:w-[65%] text-left font-bold
|
||||
group-hover:translate-x-1 transition-all group-hover:text-[var(--primary)]
|
||||
text-75 pr-8 whitespace-nowrap overflow-ellipsis overflow-hidden"
|
||||
>
|
||||
{post.data.title}
|
||||
</div>
|
||||
|
||||
<!-- tag list -->
|
||||
<div
|
||||
class="hidden md:block md:w-[15%] text-left text-sm transition
|
||||
whitespace-nowrap overflow-ellipsis overflow-hidden text-30"
|
||||
>
|
||||
{formatTag(post.data.tags)}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
|
||||
import { siteConfig } from "../config";
|
||||
---
|
||||
|
||||
<div id="config-carrier" data-hue={siteConfig.themeColor.hue}>
|
||||
</div>
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
|
||||
import { profileConfig } from "../config";
|
||||
import { url } from "../utils/url-utils";
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
---
|
||||
|
||||
<!--<div class="border-t border-[var(--primary)] mx-16 border-dashed py-8 max-w-[var(--page-width)] flex flex-col items-center justify-center px-6">-->
|
||||
<div class="transition border-t border-black/10 dark:border-white/15 my-10 border-dashed mx-32"></div>
|
||||
<!--<div class="transition bg-[oklch(92%_0.01_var(--hue))] dark:bg-black rounded-2xl py-8 mt-4 mb-8 flex flex-col items-center justify-center px-6">-->
|
||||
<div class="transition border-dashed border-[oklch(85%_0.01_var(--hue))] dark:border-white/15 rounded-2xl mb-12 flex flex-col items-center justify-center px-6">
|
||||
<div class="transition text-50 text-sm text-center">
|
||||
© <span id="copyright-year">{currentYear}</span> {profileConfig.name}. All Rights Reserved. /
|
||||
<a class="transition link text-[var(--primary)] font-medium" target="_blank" href={url('rss.xml')}>RSS</a> /
|
||||
<a class="transition link text-[var(--primary)] font-medium" target="_blank" href={url('sitemap-index.xml')}>Sitemap</a><br>
|
||||
Powered by
|
||||
<a class="transition link text-[var(--primary)] font-medium" target="_blank" href="https://astro.build">Astro</a> &
|
||||
<a class="transition link text-[var(--primary)] font-medium" target="_blank" href="https://github.com/saicaca/fuwari">Fuwari</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,3 @@
|
||||
---
|
||||
|
||||
---
|
||||
@@ -0,0 +1,99 @@
|
||||
<script lang="ts">
|
||||
import { AUTO_MODE, DARK_MODE, LIGHT_MODE } from "@constants/constants.ts";
|
||||
import I18nKey from "@i18n/i18nKey";
|
||||
import { i18n } from "@i18n/translation";
|
||||
import Icon from "@iconify/svelte";
|
||||
import {
|
||||
applyThemeToDocument,
|
||||
getStoredTheme,
|
||||
setTheme,
|
||||
} from "@utils/setting-utils.ts";
|
||||
import { onMount } from "svelte";
|
||||
import type { LIGHT_DARK_MODE } from "@/types/config.ts";
|
||||
|
||||
const seq: LIGHT_DARK_MODE[] = [LIGHT_MODE, DARK_MODE, AUTO_MODE];
|
||||
let mode: LIGHT_DARK_MODE = $state(AUTO_MODE);
|
||||
|
||||
onMount(() => {
|
||||
mode = getStoredTheme();
|
||||
const darkModePreference = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const changeThemeWhenSchemeChanged: Parameters<
|
||||
typeof darkModePreference.addEventListener<"change">
|
||||
>[1] = (_e) => {
|
||||
applyThemeToDocument(mode);
|
||||
};
|
||||
darkModePreference.addEventListener("change", changeThemeWhenSchemeChanged);
|
||||
return () => {
|
||||
darkModePreference.removeEventListener(
|
||||
"change",
|
||||
changeThemeWhenSchemeChanged,
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
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 showPanel() {
|
||||
const panel = document.querySelector("#light-dark-panel");
|
||||
panel.classList.remove("float-panel-closed");
|
||||
}
|
||||
|
||||
function hidePanel() {
|
||||
const panel = document.querySelector("#light-dark-panel");
|
||||
panel.classList.add("float-panel-closed");
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- z-50 make the panel higher than other float panels -->
|
||||
<div class="relative z-50" role="menu" tabindex="-1" onmouseleave={hidePanel}>
|
||||
<button aria-label="Light/Dark Mode" role="menuitem" class="relative btn-plain scale-animation rounded-lg h-11 w-11 active:scale-90" id="scheme-switch" onclick={toggleScheme} onmouseenter={showPanel}>
|
||||
<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 !== AUTO_MODE}>
|
||||
<Icon icon="material-symbols:radio-button-partial-outline" class="text-[1.25rem]"></Icon>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div id="light-dark-panel" class="hidden lg:block absolute transition float-panel-closed top-11 -right-2 pt-5" >
|
||||
<div class="card-base float-panel p-2">
|
||||
<button class="flex transition whitespace-nowrap items-center !justify-start w-full btn-plain scale-animation rounded-lg h-9 px-3 font-medium active:scale-95 mb-0.5"
|
||||
class:current-theme-btn={mode === LIGHT_MODE}
|
||||
onclick={() => switchScheme(LIGHT_MODE)}
|
||||
>
|
||||
<Icon icon="material-symbols:wb-sunny-outline-rounded" class="text-[1.25rem] mr-3"></Icon>
|
||||
{i18n(I18nKey.lightMode)}
|
||||
</button>
|
||||
<button class="flex transition whitespace-nowrap items-center !justify-start w-full btn-plain scale-animation rounded-lg h-9 px-3 font-medium active:scale-95 mb-0.5"
|
||||
class:current-theme-btn={mode === DARK_MODE}
|
||||
onclick={() => switchScheme(DARK_MODE)}
|
||||
>
|
||||
<Icon icon="material-symbols:dark-mode-outline-rounded" class="text-[1.25rem] mr-3"></Icon>
|
||||
{i18n(I18nKey.darkMode)}
|
||||
</button>
|
||||
<button class="flex transition whitespace-nowrap items-center !justify-start w-full btn-plain scale-animation rounded-lg h-9 px-3 font-medium active:scale-95"
|
||||
class:current-theme-btn={mode === AUTO_MODE}
|
||||
onclick={() => switchScheme(AUTO_MODE)}
|
||||
>
|
||||
<Icon icon="material-symbols:radio-button-partial-outline" class="text-[1.25rem] mr-3"></Icon>
|
||||
{i18n(I18nKey.systemMode)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,141 @@
|
||||
---
|
||||
import { Icon } from "astro-icon/components";
|
||||
import { navBarConfig, siteConfig } from "../config";
|
||||
import { LinkPresets } from "../constants/link-presets";
|
||||
import { LinkPreset, type NavBarLink } from "../types/config";
|
||||
import { url } from "../utils/url-utils";
|
||||
import LightDarkSwitch from "./LightDarkSwitch.svelte";
|
||||
import Search from "./Search.svelte";
|
||||
import DisplaySettings from "./widget/DisplaySettings.svelte";
|
||||
import NavMenuPanel from "./widget/NavMenuPanel.astro";
|
||||
|
||||
const className = Astro.props.class;
|
||||
|
||||
let links: NavBarLink[] = navBarConfig.links.map(
|
||||
(item: NavBarLink | LinkPreset): NavBarLink => {
|
||||
if (typeof item === "number") {
|
||||
return LinkPresets[item];
|
||||
}
|
||||
return item;
|
||||
},
|
||||
);
|
||||
---
|
||||
<div id="navbar" class="z-50 onload-animation">
|
||||
<div class="absolute h-8 left-0 right-0 -top-8 bg-[var(--card-bg)] transition"></div> <!-- used for onload animation -->
|
||||
<div class:list={[
|
||||
className,
|
||||
"card-base !overflow-visible max-w-[var(--page-width)] h-[4.5rem] !rounded-t-none mx-auto flex items-center justify-between px-4"]}>
|
||||
<a href={url('/')} class="btn-plain scale-animation rounded-lg h-[3.25rem] px-5 font-bold active:scale-95">
|
||||
<div class="flex flex-row text-[var(--primary)] items-center text-md">
|
||||
<Icon name="material-symbols:home-outline-rounded" class="text-[1.75rem] mb-1 mr-2" />
|
||||
{siteConfig.title}
|
||||
</div>
|
||||
</a>
|
||||
<div class="hidden md:flex">
|
||||
{links.map((l) => {
|
||||
return <a aria-label={l.name} href={l.external ? l.url : url(l.url)} target={l.external ? "_blank" : null}
|
||||
class="btn-plain scale-animation rounded-lg h-11 font-bold px-5 active:scale-95"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
{l.name}
|
||||
{l.external && <Icon name="fa6-solid:arrow-up-right-from-square" class="text-[0.875rem] transition -translate-y-[1px] ml-1 text-black/[0.2] dark:text-white/[0.2]"></Icon>}
|
||||
</div>
|
||||
</a>;
|
||||
})}
|
||||
</div>
|
||||
<div class="flex">
|
||||
<!--<SearchPanel client:load>-->
|
||||
<Search client:only="svelte"></Search>
|
||||
{!siteConfig.themeColor.fixed && (
|
||||
<button aria-label="Display Settings" class="btn-plain scale-animation rounded-lg h-11 w-11 active:scale-90" id="display-settings-switch">
|
||||
<Icon name="material-symbols:palette-outline" class="text-[1.25rem]"></Icon>
|
||||
</button>
|
||||
)}
|
||||
<LightDarkSwitch client:only="svelte"></LightDarkSwitch>
|
||||
<button aria-label="Menu" name="Nav Menu" class="btn-plain scale-animation rounded-lg w-11 h-11 active:scale-90 md:!hidden" id="nav-menu-switch">
|
||||
<Icon name="material-symbols:menu-rounded" class="text-[1.25rem]"></Icon>
|
||||
</button>
|
||||
</div>
|
||||
<NavMenuPanel links={links}></NavMenuPanel>
|
||||
<DisplaySettings client:only="svelte"></DisplaySettings>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function switchTheme() {
|
||||
if (localStorage.theme === 'dark') {
|
||||
document.documentElement.classList.remove('dark');
|
||||
localStorage.theme = 'light';
|
||||
} else {
|
||||
document.documentElement.classList.add('dark');
|
||||
localStorage.theme = 'dark';
|
||||
}
|
||||
}
|
||||
|
||||
function loadButtonScript() {
|
||||
let switchBtn = document.getElementById("scheme-switch");
|
||||
if (switchBtn) {
|
||||
switchBtn.onclick = function () {
|
||||
switchTheme()
|
||||
};
|
||||
}
|
||||
|
||||
let settingBtn = document.getElementById("display-settings-switch");
|
||||
if (settingBtn) {
|
||||
settingBtn.onclick = function () {
|
||||
let settingPanel = document.getElementById("display-setting");
|
||||
if (settingPanel) {
|
||||
settingPanel.classList.toggle("float-panel-closed");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let menuBtn = document.getElementById("nav-menu-switch");
|
||||
if (menuBtn) {
|
||||
menuBtn.onclick = function () {
|
||||
let menuPanel = document.getElementById("nav-menu-panel");
|
||||
if (menuPanel) {
|
||||
menuPanel.classList.toggle("float-panel-closed");
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
loadButtonScript();
|
||||
</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();
|
||||
}
|
||||
</script>}
|
||||
@@ -0,0 +1,110 @@
|
||||
---
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
import path from "node:path";
|
||||
import { Icon } from "astro-icon/components";
|
||||
import I18nKey from "../i18n/i18nKey";
|
||||
import { i18n } from "../i18n/translation";
|
||||
import { getDir } from "../utils/url-utils";
|
||||
import ImageWrapper from "./misc/ImageWrapper.astro";
|
||||
import PostMetadata from "./PostMeta.astro";
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
entry: CollectionEntry<"posts">;
|
||||
title: string;
|
||||
url: string;
|
||||
published: Date;
|
||||
updated?: Date;
|
||||
tags: string[];
|
||||
category: string | null;
|
||||
image: string;
|
||||
description: string;
|
||||
draft: boolean;
|
||||
style: string;
|
||||
}
|
||||
const {
|
||||
entry,
|
||||
title,
|
||||
url,
|
||||
published,
|
||||
updated,
|
||||
tags,
|
||||
category,
|
||||
image,
|
||||
description,
|
||||
style,
|
||||
} = Astro.props;
|
||||
const className = Astro.props.class;
|
||||
|
||||
const hasCover = image !== undefined && image !== null && image !== "";
|
||||
|
||||
const coverWidth = "28%";
|
||||
|
||||
const { remarkPluginFrontmatter } = await entry.render();
|
||||
---
|
||||
<div class:list={["card-base flex flex-col-reverse md:flex-col w-full rounded-[var(--radius-large)] overflow-hidden relative", className]} style={style}>
|
||||
<div class:list={["pl-6 md:pl-9 pr-6 md:pr-2 pt-6 md:pt-7 pb-6 relative", {"w-full md:w-[calc(100%_-_52px_-_12px)]": !hasCover, "w-full md:w-[calc(100%_-_var(--coverWidth)_-_12px)]": hasCover}]}>
|
||||
<a href={url}
|
||||
class="transition group w-full block font-bold mb-3 text-3xl text-90
|
||||
hover:text-[var(--primary)] dark:hover:text-[var(--primary)]
|
||||
active:text-[var(--title-active)] dark:active:text-[var(--title-active)]
|
||||
before:w-1 before:h-5 before:rounded-md before:bg-[var(--primary)]
|
||||
before:absolute before:top-[35px] before:left-[18px] before:hidden md:before:block
|
||||
">
|
||||
{title}
|
||||
<Icon class="inline text-[2rem] text-[var(--primary)] md:hidden translate-y-0.5 absolute" name="material-symbols:chevron-right-rounded" ></Icon>
|
||||
<Icon class="text-[var(--primary)] text-[2rem] transition hidden md:inline absolute translate-y-0.5 opacity-0 group-hover:opacity-100 -translate-x-1 group-hover:translate-x-0" name="material-symbols:chevron-right-rounded"></Icon>
|
||||
</a>
|
||||
|
||||
<!-- metadata -->
|
||||
<PostMetadata published={published} updated={updated} tags={tags} category={category} hideTagsForMobile={true} hideUpdateDate={true} class="mb-4"></PostMetadata>
|
||||
|
||||
<!-- description -->
|
||||
<div class:list={["transition text-75 mb-3.5 pr-4", {"line-clamp-2 md:line-clamp-1": !description}]}>
|
||||
{ description || remarkPluginFrontmatter.excerpt }
|
||||
</div>
|
||||
|
||||
<!-- word count and read time -->
|
||||
<div class="text-sm text-black/30 dark:text-white/30 flex gap-4 transition">
|
||||
<div>
|
||||
{remarkPluginFrontmatter.words} {" " + i18n(remarkPluginFrontmatter.words === 1 ? I18nKey.wordCount : I18nKey.wordsCount)}
|
||||
</div>
|
||||
<div>|</div>
|
||||
<div>
|
||||
{remarkPluginFrontmatter.minutes} {" " + i18n(remarkPluginFrontmatter.minutes === 1 ? I18nKey.minuteCount : I18nKey.minutesCount)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{hasCover && <a href={url} aria-label={title}
|
||||
class:list={["group",
|
||||
"max-h-[20vh] md:max-h-none mx-4 mt-4 -mb-2 md:mb-0 md:mx-0 md:mt-0",
|
||||
"md:w-[var(--coverWidth)] relative md:absolute md:top-3 md:bottom-3 md:right-3 rounded-xl overflow-hidden active:scale-95"
|
||||
]} >
|
||||
<div class="absolute pointer-events-none z-10 w-full h-full group-hover:bg-black/30 group-active:bg-black/50 transition"></div>
|
||||
<div class="absolute pointer-events-none z-20 w-full h-full flex items-center justify-center ">
|
||||
<Icon name="material-symbols:chevron-right-rounded"
|
||||
class="transition opacity-0 group-hover:opacity-100 scale-50 group-hover:scale-100 text-white text-5xl">
|
||||
</Icon>
|
||||
</div>
|
||||
<ImageWrapper src={image} basePath={path.join("content/posts/", getDir(entry.id))} alt="Cover Image of the Post"
|
||||
class="w-full h-full">
|
||||
</ImageWrapper>
|
||||
</a>}
|
||||
|
||||
{!hasCover &&
|
||||
<a href={url} aria-label={title} class="!hidden md:!flex btn-regular w-[3.25rem]
|
||||
absolute right-3 top-3 bottom-3 rounded-xl bg-[var(--enter-btn-bg)]
|
||||
hover:bg-[var(--enter-btn-bg-hover)] active:bg-[var(--enter-btn-bg-active)] active:scale-95
|
||||
">
|
||||
<Icon name="material-symbols:chevron-right-rounded"
|
||||
class="transition text-[var(--primary)] text-4xl mx-auto">
|
||||
</Icon>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
<div class="transition border-t-[1px] border-dashed mx-6 border-black/10 dark:border-white/[0.15] last:border-t-0 md:hidden"></div>
|
||||
|
||||
<style define:vars={{coverWidth}}>
|
||||
</style>
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
import { Icon } from "astro-icon/components";
|
||||
import I18nKey from "../i18n/i18nKey";
|
||||
import { i18n } from "../i18n/translation";
|
||||
import { formatDateToYYYYMMDD } from "../utils/date-utils";
|
||||
import { getCategoryUrl, getTagUrl } from "../utils/url-utils";
|
||||
|
||||
interface Props {
|
||||
class: string;
|
||||
published: Date;
|
||||
updated?: Date;
|
||||
tags: string[];
|
||||
category: string | null;
|
||||
hideTagsForMobile?: boolean;
|
||||
hideUpdateDate?: boolean;
|
||||
}
|
||||
const {
|
||||
published,
|
||||
updated,
|
||||
tags,
|
||||
category,
|
||||
hideTagsForMobile = false,
|
||||
hideUpdateDate = false,
|
||||
} = Astro.props;
|
||||
const className = Astro.props.class;
|
||||
---
|
||||
|
||||
<div class:list={["flex flex-wrap text-neutral-500 dark:text-neutral-400 items-center gap-4 gap-x-4 gap-y-2", className]}>
|
||||
<!-- publish date -->
|
||||
<div class="flex items-center">
|
||||
<div class="meta-icon"
|
||||
>
|
||||
<Icon name="material-symbols:calendar-today-outline-rounded" class="text-xl"></Icon>
|
||||
</div>
|
||||
<span class="text-50 text-sm font-medium">{formatDateToYYYYMMDD(published)}</span>
|
||||
</div>
|
||||
|
||||
<!-- update date -->
|
||||
{!hideUpdateDate && updated && updated.getTime() !== published.getTime() && (
|
||||
<div class="flex items-center">
|
||||
<div class="meta-icon"
|
||||
>
|
||||
<Icon name="material-symbols:edit-calendar-outline-rounded" class="text-xl"></Icon>
|
||||
</div>
|
||||
<span class="text-50 text-sm font-medium">{formatDateToYYYYMMDD(updated)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- categories -->
|
||||
<div class="flex items-center">
|
||||
<div class="meta-icon"
|
||||
>
|
||||
<Icon name="material-symbols:book-2-outline-rounded" class="text-xl"></Icon>
|
||||
</div>
|
||||
<div class="flex flex-row flex-nowrap items-center">
|
||||
<a href={getCategoryUrl(category)} aria-label={`View all posts in the ${category} category`}
|
||||
class="link-lg transition text-50 text-sm font-medium
|
||||
hover:text-[var(--primary)] dark:hover:text-[var(--primary)] whitespace-nowrap">
|
||||
{category || i18n(I18nKey.uncategorized)}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- tags -->
|
||||
<div class:list={["items-center", {"flex": !hideTagsForMobile, "hidden md:flex": hideTagsForMobile}]}>
|
||||
<div class="meta-icon"
|
||||
>
|
||||
<Icon name="material-symbols:tag-rounded" class="text-xl"></Icon>
|
||||
</div>
|
||||
<div class="flex flex-row flex-nowrap items-center">
|
||||
{(tags && tags.length > 0) && tags.map((tag, i) => (
|
||||
<div class:list={[{"hidden": i == 0}, "mx-1.5 text-[var(--meta-divider)] text-sm"]}>/</div>
|
||||
<a href={getTagUrl(tag)} aria-label={`View all posts with the ${tag.trim()} tag`}
|
||||
class="link-lg transition text-50 text-sm font-medium
|
||||
hover:text-[var(--primary)] dark:hover:text-[var(--primary)] whitespace-nowrap">
|
||||
{tag.trim()}
|
||||
</a>
|
||||
))}
|
||||
{!(tags && tags.length > 0) && <div class="transition text-50 text-sm font-medium">{i18n(I18nKey.noTags)}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
import { getPostUrlBySlug } from "@utils/url-utils";
|
||||
import PostCard from "./PostCard.astro";
|
||||
|
||||
const { page } = Astro.props;
|
||||
|
||||
let delay = 0;
|
||||
const interval = 50;
|
||||
---
|
||||
<div class="transition flex flex-col rounded-[var(--radius-large)] bg-[var(--card-bg)] py-1 md:py-0 md:bg-transparent md:gap-4 mb-4">
|
||||
{page.data.map((entry: CollectionEntry<"posts">) => (
|
||||
<PostCard
|
||||
entry={entry}
|
||||
title={entry.data.title}
|
||||
tags={entry.data.tags}
|
||||
category={entry.data.category}
|
||||
published={entry.data.published}
|
||||
updated={entry.data.updated}
|
||||
url={getPostUrlBySlug(entry.slug)}
|
||||
image={entry.data.image}
|
||||
description={entry.data.description}
|
||||
draft={entry.data.draft}
|
||||
class:list="onload-animation"
|
||||
style={`animation-delay: calc(var(--content-delay) + ${delay++ * interval}ms);`}
|
||||
></PostCard>
|
||||
))}
|
||||
</div>
|
||||
@@ -0,0 +1,198 @@
|
||||
<script lang="ts">
|
||||
import I18nKey from "@i18n/i18nKey";
|
||||
import { i18n } from "@i18n/translation";
|
||||
import Icon from "@iconify/svelte";
|
||||
import { url } from "@utils/url-utils.ts";
|
||||
import { onMount } from "svelte";
|
||||
import type { SearchResult } from "@/global";
|
||||
|
||||
let keywordDesktop = "";
|
||||
let keywordMobile = "";
|
||||
let result: SearchResult[] = [];
|
||||
let isSearching = false;
|
||||
let pagefindLoaded = false;
|
||||
let initialized = false;
|
||||
|
||||
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 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 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;
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
const initializeSearch = () => {
|
||||
initialized = true;
|
||||
pagefindLoaded =
|
||||
typeof window !== "undefined" &&
|
||||
!!window.pagefind &&
|
||||
typeof window.pagefind.search === "function";
|
||||
console.log("Pagefind status on init:", pagefindLoaded);
|
||||
if (keywordDesktop) search(keywordDesktop, true);
|
||||
if (keywordMobile) search(keywordMobile, false);
|
||||
};
|
||||
|
||||
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
|
||||
}
|
||||
});
|
||||
|
||||
$: if (initialized && keywordDesktop) {
|
||||
(async () => {
|
||||
await search(keywordDesktop, true);
|
||||
})();
|
||||
}
|
||||
|
||||
$: if (initialized && keywordMobile) {
|
||||
(async () => {
|
||||
await search(keywordMobile, false);
|
||||
})();
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- search bar for desktop view -->
|
||||
<div id="search-bar" class="hidden lg:flex transition-all items-center h-11 mr-2 rounded-lg
|
||||
bg-black/[0.04] hover:bg-black/[0.06] focus-within:bg-black/[0.06]
|
||||
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="{i18n(I18nKey.search)}" bind:value={keywordDesktop} on:focus={() => search(keywordDesktop, true)}
|
||||
class="transition-all pl-10 text-sm bg-transparent outline-0
|
||||
h-full w-40 active:w-60 focus:w-60 text-black/50 dark:text-white/50"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- toggle btn for phone/tablet view -->
|
||||
<button on:click={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 -->
|
||||
<div id="search-panel" class="float-panel float-panel-closed search-panel absolute md:w-[30rem]
|
||||
top-20 left-4 md:left-[unset] right-4 shadow-2xl rounded-2xl p-2">
|
||||
|
||||
<!-- 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/[0.04] hover:bg-black/[0.06] focus-within:bg-black/[0.06]
|
||||
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}
|
||||
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-[var(--btn-plain-bg-hover)] active:bg-[var(--btn-plain-bg-active)]">
|
||||
<div class="transition text-90 inline-flex font-bold group-hover:text-[var(--primary)]">
|
||||
{item.meta.title}<Icon icon="fa6-solid:chevron-right" class="transition text-[0.75rem] translate-x-1 my-auto text-[var(--primary)]"></Icon>
|
||||
</div>
|
||||
<div class="transition text-sm text-50">
|
||||
{@html item.excerpt}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
input:focus {
|
||||
outline: 0;
|
||||
}
|
||||
.search-panel {
|
||||
max-height: calc(100vh - 100px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
import { Icon } from "astro-icon/components";
|
||||
---
|
||||
|
||||
<!-- There can't be a filter on parent element, or it will break `fixed` -->
|
||||
<div class="back-to-top-wrapper hidden lg:block">
|
||||
<div id="back-to-top-btn" class="back-to-top-btn hide flex items-center rounded-2xl overflow-hidden transition" onclick="backToTop()">
|
||||
<button aria-label="Back to Top" class="btn-card h-[3.75rem] w-[3.75rem]">
|
||||
<Icon name="material-symbols:keyboard-arrow-up-rounded" class="mx-auto"></Icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="stylus">
|
||||
.back-to-top-wrapper
|
||||
width: 3.75rem
|
||||
height: 3.75rem
|
||||
position: absolute
|
||||
right: 0
|
||||
top: 0
|
||||
pointer-events: none
|
||||
|
||||
.back-to-top-btn
|
||||
color: var(--primary)
|
||||
font-size: 2.25rem
|
||||
font-weight: bold
|
||||
border: none
|
||||
position: fixed
|
||||
bottom: 10rem
|
||||
opacity: 1
|
||||
cursor: pointer
|
||||
transform: translateX(5rem)
|
||||
pointer-events: auto
|
||||
i
|
||||
font-size: 1.75rem
|
||||
&.hide
|
||||
transform: translateX(5rem) scale(0.9)
|
||||
opacity: 0
|
||||
pointer-events: none
|
||||
&:active
|
||||
transform: translateX(5rem) scale(0.9)
|
||||
|
||||
</style>
|
||||
|
||||
<script is:raw is:inline>
|
||||
function backToTop() {
|
||||
window.scroll({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
interface Props {
|
||||
badge?: string;
|
||||
url?: string;
|
||||
label?: string;
|
||||
}
|
||||
const { badge, url, label } = Astro.props;
|
||||
---
|
||||
<a href={url} aria-label={label}>
|
||||
<button
|
||||
class:list={`
|
||||
w-full
|
||||
h-10
|
||||
rounded-lg
|
||||
bg-none
|
||||
hover:bg-[var(--btn-plain-bg-hover)]
|
||||
active:bg-[var(--btn-plain-bg-active)]
|
||||
transition-all
|
||||
pl-2
|
||||
hover:pl-3
|
||||
|
||||
text-neutral-700
|
||||
hover:text-[var(--primary)]
|
||||
dark:text-neutral-300
|
||||
dark:hover:text-[var(--primary)]
|
||||
`
|
||||
}
|
||||
>
|
||||
<div class="flex items-center justify-between relative mr-2">
|
||||
<div class="overflow-hidden text-left whitespace-nowrap overflow-ellipsis ">
|
||||
<slot></slot>
|
||||
</div>
|
||||
{ badge !== undefined && badge !== null && badge !== '' &&
|
||||
<div class="transition px-2 h-7 ml-4 min-w-[2rem] rounded-lg text-sm font-bold
|
||||
text-[var(--btn-content)] dark:text-[var(--deep-text)]
|
||||
bg-[oklch(0.95_0.025_var(--hue))] dark:bg-[var(--primary)]
|
||||
flex items-center justify-center">
|
||||
{ badge }
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</button>
|
||||
</a>
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
interface Props {
|
||||
size?: string;
|
||||
dot?: boolean;
|
||||
href?: string;
|
||||
label?: string;
|
||||
}
|
||||
const { dot, href, label }: Props = Astro.props;
|
||||
---
|
||||
<a href={href} aria-label={label} class="btn-regular h-8 text-sm px-3 rounded-lg">
|
||||
{dot && <div class="h-1 w-1 bg-[var(--btn-content)] dark:bg-[var(--card-bg)] transition rounded-md mr-2"></div>}
|
||||
<slot></slot>
|
||||
</a>
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
import type { Page } from "astro";
|
||||
import { Icon } from "astro-icon/components";
|
||||
import { url } from "../../utils/url-utils";
|
||||
|
||||
interface Props {
|
||||
page: Page;
|
||||
class?: string;
|
||||
style?: string;
|
||||
}
|
||||
|
||||
const { page, style } = Astro.props;
|
||||
|
||||
const HIDDEN = -1;
|
||||
|
||||
const className = Astro.props.class;
|
||||
|
||||
const ADJ_DIST = 2;
|
||||
const VISIBLE = ADJ_DIST * 2 + 1;
|
||||
|
||||
// for test
|
||||
let count = 1;
|
||||
let l = page.currentPage;
|
||||
let r = page.currentPage;
|
||||
while (0 < l - 1 && r + 1 <= page.lastPage && count + 2 <= VISIBLE) {
|
||||
count += 2;
|
||||
l--;
|
||||
r++;
|
||||
}
|
||||
while (0 < l - 1 && count < VISIBLE) {
|
||||
count++;
|
||||
l--;
|
||||
}
|
||||
while (r + 1 <= page.lastPage && count < VISIBLE) {
|
||||
count++;
|
||||
r++;
|
||||
}
|
||||
|
||||
let pages: number[] = [];
|
||||
if (l > 1) pages.push(1);
|
||||
if (l === 3) pages.push(2);
|
||||
if (l > 3) pages.push(HIDDEN);
|
||||
for (let i = l; i <= r; i++) pages.push(i);
|
||||
if (r < page.lastPage - 2) pages.push(HIDDEN);
|
||||
if (r === page.lastPage - 2) pages.push(page.lastPage - 1);
|
||||
if (r < page.lastPage) pages.push(page.lastPage);
|
||||
|
||||
const getPageUrl = (p: number) => {
|
||||
if (p === 1) return "/";
|
||||
return `/${p}/`;
|
||||
};
|
||||
---
|
||||
|
||||
<div class:list={[className, "flex flex-row gap-3 justify-center"]} style={style}>
|
||||
<a href={page.url.prev || ""} aria-label={page.url.prev ? "Previous Page" : null}
|
||||
class:list={["btn-card overflow-hidden rounded-lg text-[var(--primary)] w-11 h-11",
|
||||
{"disabled": page.url.prev == undefined}
|
||||
]}
|
||||
>
|
||||
<Icon name="material-symbols:chevron-left-rounded" class="text-[1.75rem]"></Icon>
|
||||
</a>
|
||||
<div class="bg-[var(--card-bg)] flex flex-row rounded-lg items-center text-neutral-700 dark:text-neutral-300 font-bold">
|
||||
{pages.map((p) => {
|
||||
if (p == HIDDEN)
|
||||
return <Icon name="material-symbols:more-horiz" class="mx-1"/>;
|
||||
if (p == page.currentPage)
|
||||
return <div class="h-11 w-11 rounded-lg bg-[var(--primary)] flex items-center justify-center
|
||||
font-bold text-white dark:text-black/70"
|
||||
>
|
||||
{p}
|
||||
</div>
|
||||
return <a href={url(getPageUrl(p))} aria-label={`Page ${p}`}
|
||||
class="btn-card w-11 h-11 rounded-lg overflow-hidden active:scale-[0.85]"
|
||||
>{p}</a>
|
||||
})}
|
||||
</div>
|
||||
<a href={page.url.next || ""} aria-label={page.url.next ? "Next Page" : null}
|
||||
class:list={["btn-card overflow-hidden rounded-lg text-[var(--primary)] w-11 h-11",
|
||||
{"disabled": page.url.next == undefined}
|
||||
]}
|
||||
>
|
||||
<Icon name="material-symbols:chevron-right-rounded" class="text-[1.75rem]"></Icon>
|
||||
</a>
|
||||
</div>
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
import path from "node:path";
|
||||
|
||||
interface Props {
|
||||
id?: string;
|
||||
src: string;
|
||||
class?: string;
|
||||
alt?: string;
|
||||
position?: string;
|
||||
basePath?: string;
|
||||
}
|
||||
|
||||
import { Image } from "astro:assets";
|
||||
import { url } from "../../utils/url-utils";
|
||||
|
||||
const { id, src, alt, position = "center", basePath = "/" } = Astro.props;
|
||||
const className = Astro.props.class;
|
||||
|
||||
const isLocal = !(
|
||||
src.startsWith("/") ||
|
||||
src.startsWith("http") ||
|
||||
src.startsWith("https") ||
|
||||
src.startsWith("data:")
|
||||
);
|
||||
const isPublic = src.startsWith("/");
|
||||
|
||||
// TODO temporary workaround for images dynamic import
|
||||
// https://github.com/withastro/astro/issues/3373
|
||||
// biome-ignore lint/suspicious/noImplicitAnyLet: <check later>
|
||||
let img;
|
||||
if (isLocal) {
|
||||
const files = import.meta.glob<ImageMetadata>("../../**", {
|
||||
import: "default",
|
||||
});
|
||||
let normalizedPath = path
|
||||
.normalize(path.join("../../", basePath, src))
|
||||
.replace(/\\/g, "/");
|
||||
const file = files[normalizedPath];
|
||||
if (!file) {
|
||||
console.error(
|
||||
`\n[ERROR] Image file not found: ${normalizedPath.replace("../../", "src/")}`,
|
||||
);
|
||||
}
|
||||
img = await file();
|
||||
}
|
||||
|
||||
const imageClass = "w-full h-full object-cover";
|
||||
const imageStyle = `object-position: ${position}`;
|
||||
---
|
||||
<div id={id} class:list={[className, 'overflow-hidden relative']}>
|
||||
<div class="transition absolute inset-0 dark:bg-black/10 bg-opacity-50 pointer-events-none"></div>
|
||||
{isLocal && img && <Image src={img} alt={alt || ""} class={imageClass} style={imageStyle}/>}
|
||||
{!isLocal && <img src={isPublic ? url(src) : src} alt={alt || ""} class={imageClass} style={imageStyle}/>}
|
||||
</div>
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
import { Icon } from "astro-icon/components";
|
||||
import { licenseConfig, profileConfig } from "../../config";
|
||||
import I18nKey from "../../i18n/i18nKey";
|
||||
import { i18n } from "../../i18n/translation";
|
||||
import { formatDateToYYYYMMDD } from "../../utils/date-utils";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
slug: string;
|
||||
pubDate: Date;
|
||||
class: string;
|
||||
}
|
||||
|
||||
const { title, pubDate } = Astro.props;
|
||||
const className = Astro.props.class;
|
||||
const profileConf = profileConfig;
|
||||
const licenseConf = licenseConfig;
|
||||
const postUrl = decodeURIComponent(Astro.url.toString());
|
||||
---
|
||||
<div class={`relative transition overflow-hidden bg-[var(--license-block-bg)] py-5 px-6 ${className}`}>
|
||||
<div class="transition font-bold text-black/75 dark:text-white/75">
|
||||
{title}
|
||||
</div>
|
||||
<a href={postUrl} class="link text-[var(--primary)]">
|
||||
{postUrl}
|
||||
</a>
|
||||
<div class="flex gap-6 mt-2">
|
||||
<div>
|
||||
<div class="transition text-black/30 dark:text-white/30 text-sm">{i18n(I18nKey.author)}</div>
|
||||
<div class="transition text-black/75 dark:text-white/75 line-clamp-2">{profileConf.name}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="transition text-black/30 dark:text-white/30 text-sm">{i18n(I18nKey.publishedAt)}</div>
|
||||
<div class="transition text-black/75 dark:text-white/75 line-clamp-2">{formatDateToYYYYMMDD(pubDate)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="transition text-black/30 dark:text-white/30 text-sm">{i18n(I18nKey.license)}</div>
|
||||
<a href={licenseConf.url} target="_blank" class="link text-[var(--primary)] line-clamp-2">{licenseConf.name}</a>
|
||||
</div>
|
||||
</div>
|
||||
<Icon name="fa6-brands:creative-commons" class="transition text-[15rem] absolute pointer-events-none right-6 top-1/2 -translate-y-1/2 text-black/5 dark:text-white/5"></Icon>
|
||||
</div>
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
import "@fontsource-variable/jetbrains-mono";
|
||||
import "@fontsource-variable/jetbrains-mono/wght-italic.css";
|
||||
|
||||
interface Props {
|
||||
class: string;
|
||||
}
|
||||
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 max-w-none custom-md">-->
|
||||
<!--<div class="max-w-none custom-md">-->
|
||||
<slot/>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener("click", function (e: MouseEvent) {
|
||||
const target = e.target as Element | null;
|
||||
if (target && target.classList.contains("copy-btn")) {
|
||||
const preEle = target.closest("pre");
|
||||
const codeEle = preEle?.querySelector("code");
|
||||
const code = Array.from(codeEle?.querySelectorAll(".code:not(summary *)") ?? [])
|
||||
.map(el => el.textContent)
|
||||
.map(t => t === "\n" ? "" : t)
|
||||
.join("\n");
|
||||
navigator.clipboard.writeText(code);
|
||||
|
||||
const timeoutId = target.getAttribute("data-timeout-id");
|
||||
if (timeoutId) {
|
||||
clearTimeout(parseInt(timeoutId));
|
||||
}
|
||||
|
||||
target.classList.add("success");
|
||||
|
||||
// 设置新的timeout并保存ID到按钮的自定义属性中
|
||||
const newTimeoutId = setTimeout(() => {
|
||||
target.classList.remove("success");
|
||||
}, 1000);
|
||||
|
||||
target.setAttribute("data-timeout-id", newTimeoutId.toString());
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
import I18nKey from "../../i18n/i18nKey";
|
||||
import { i18n } from "../../i18n/translation";
|
||||
import { getCategoryList } from "../../utils/content-utils";
|
||||
import ButtonLink from "../control/ButtonLink.astro";
|
||||
import WidgetLayout from "./WidgetLayout.astro";
|
||||
|
||||
const categories = await getCategoryList();
|
||||
|
||||
const COLLAPSED_HEIGHT = "7.5rem";
|
||||
const COLLAPSE_THRESHOLD = 5;
|
||||
|
||||
const isCollapsed = categories.length >= COLLAPSE_THRESHOLD;
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
style?: string;
|
||||
}
|
||||
const className = Astro.props.class;
|
||||
const style = Astro.props.style;
|
||||
---
|
||||
|
||||
<WidgetLayout name={i18n(I18nKey.categories)} id="categories" isCollapsed={isCollapsed} collapsedHeight={COLLAPSED_HEIGHT}
|
||||
class={className} style={style}
|
||||
>
|
||||
{categories.map((c) =>
|
||||
<ButtonLink
|
||||
url={c.url}
|
||||
badge={String(c.count)}
|
||||
label={`View all posts in the ${c.name.trim()} category`}
|
||||
>
|
||||
{c.name.trim()}
|
||||
</ButtonLink>
|
||||
)}
|
||||
</WidgetLayout>
|
||||
@@ -0,0 +1,93 @@
|
||||
<script lang="ts">
|
||||
import I18nKey from "@i18n/i18nKey";
|
||||
import { i18n } from "@i18n/translation";
|
||||
import Icon from "@iconify/svelte";
|
||||
import { getDefaultHue, getHue, setHue } from "@utils/setting-utils";
|
||||
|
||||
let hue = getHue();
|
||||
const defaultHue = getDefaultHue();
|
||||
|
||||
function resetHue() {
|
||||
hue = getDefaultHue();
|
||||
}
|
||||
|
||||
$: if (hue || hue === 0) {
|
||||
setHue(hue);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div id="display-setting" class="float-panel float-panel-closed absolute transition-all w-80 right-4 px-4 py-4">
|
||||
<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-[var(--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} on:click={resetHue}>
|
||||
<div class="text-[var(--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-[var(--btn-regular-bg)] w-10 h-7 rounded-md flex justify-center
|
||||
font-bold text-sm items-center text-[var(--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 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>
|
||||
|
||||
|
||||
<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>
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
import { Icon } from "astro-icon/components";
|
||||
import { type NavBarLink } from "../../types/config";
|
||||
import { url } from "../../utils/url-utils";
|
||||
|
||||
interface Props {
|
||||
links: NavBarLink[];
|
||||
}
|
||||
|
||||
const links = Astro.props.links;
|
||||
---
|
||||
<div id="nav-menu-panel" class:list={["float-panel float-panel-closed absolute transition-all fixed right-4 px-2 py-2"]}>
|
||||
{links.map((link) => (
|
||||
<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-[var(--btn-plain-bg-hover)] active:bg-[var(--btn-plain-bg-active)] transition
|
||||
"
|
||||
target={link.external ? "_blank" : null}
|
||||
>
|
||||
<div class="transition text-black/75 dark:text-white/75 font-bold group-hover:text-[var(--primary)] group-active:text-[var(--primary)]">
|
||||
{link.name}
|
||||
</div>
|
||||
{!link.external && <Icon name="material-symbols:chevron-right-rounded"
|
||||
class="transition text-[1.25rem] text-[var(--primary)]"
|
||||
>
|
||||
</Icon>}
|
||||
{link.external && <Icon name="fa6-solid:arrow-up-right-from-square"
|
||||
class="transition text-[0.75rem] text-black/25 dark:text-white/25 -translate-x-1"
|
||||
>
|
||||
</Icon>}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
import { Icon } from "astro-icon/components";
|
||||
import { profileConfig } from "../../config";
|
||||
import { url } from "../../utils/url-utils";
|
||||
import ImageWrapper from "../misc/ImageWrapper.astro";
|
||||
|
||||
const config = profileConfig;
|
||||
---
|
||||
<div class="card-base p-3">
|
||||
<a aria-label="Go to About Page" href={url('/about/')}
|
||||
class="group block relative mx-auto mt-1 lg:mx-0 lg:mt-0 mb-3
|
||||
max-w-[12rem] lg:max-w-none overflow-hidden rounded-xl active:scale-95">
|
||||
<div class="absolute transition pointer-events-none group-hover:bg-black/30 group-active:bg-black/50
|
||||
w-full h-full z-50 flex items-center justify-center">
|
||||
<Icon name="fa6-regular:address-card"
|
||||
class="transition opacity-0 scale-90 group-hover:scale-100 group-hover:opacity-100 text-white text-5xl">
|
||||
</Icon>
|
||||
</div>
|
||||
<ImageWrapper src={config.avatar || ""} alt="Profile Image of the Author" class="mx-auto lg:w-full h-full lg:mt-0 "></ImageWrapper>
|
||||
</a>
|
||||
<div class="px-2">
|
||||
<div class="font-bold text-xl text-center mb-1 dark:text-neutral-50 transition">{config.name}</div>
|
||||
<div class="h-1 w-5 bg-[var(--primary)] mx-auto rounded-full mb-2 transition"></div>
|
||||
<div class="text-center text-neutral-400 mb-2.5 transition">{config.bio}</div>
|
||||
<div class="flex gap-2 justify-center mb-1">
|
||||
{config.links.length > 1 && config.links.map(item =>
|
||||
<a rel="me" aria-label={item.name} href={item.url} target="_blank" class="btn-regular rounded-lg h-10 w-10 active:scale-90">
|
||||
<Icon name={item.icon} class="text-[1.5rem]"></Icon>
|
||||
</a>
|
||||
)}
|
||||
{config.links.length == 1 && <a rel="me" aria-label={config.links[0].name} href={config.links[0].url} target="_blank"
|
||||
class="btn-regular rounded-lg h-10 gap-2 px-3 font-bold active:scale-95">
|
||||
<Icon name={config.links[0].icon} class="text-[1.5rem]"></Icon>
|
||||
{config.links[0].name}
|
||||
</a>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
import type { MarkdownHeading } from "astro";
|
||||
import Categories from "./Categories.astro";
|
||||
import Profile from "./Profile.astro";
|
||||
import Tag from "./Tags.astro";
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
headings?: MarkdownHeading[];
|
||||
}
|
||||
|
||||
const className = Astro.props.class;
|
||||
---
|
||||
<div id="sidebar" class:list={[className, "w-full"]}>
|
||||
<div class="flex flex-col w-full gap-4 mb-4">
|
||||
<Profile></Profile>
|
||||
</div>
|
||||
<div id="sidebar-sticky" class="transition-all duration-700 flex flex-col w-full gap-4 top-4 sticky top-4">
|
||||
<Categories class="onload-animation" style="animation-delay: 150ms"></Categories>
|
||||
<Tag class="onload-animation" style="animation-delay: 200ms"></Tag>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,264 @@
|
||||
---
|
||||
import type { MarkdownHeading } from "astro";
|
||||
import { siteConfig } from "../../config";
|
||||
import { url } from "../../utils/url-utils";
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
headings: MarkdownHeading[];
|
||||
}
|
||||
|
||||
let { headings = [] } = Astro.props;
|
||||
|
||||
let minDepth = 10;
|
||||
for (const heading of headings) {
|
||||
minDepth = Math.min(minDepth, heading.depth);
|
||||
}
|
||||
|
||||
const className = Astro.props.class;
|
||||
const isPostsRoute = Astro.url.pathname.startsWith(url("/posts/"));
|
||||
|
||||
const removeTailingHash = (text: string) => {
|
||||
let lastIndexOfHash = text.lastIndexOf("#");
|
||||
if (lastIndexOfHash !== text.length - 1) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return text.substring(0, lastIndexOfHash);
|
||||
};
|
||||
|
||||
let heading1Count = 1;
|
||||
|
||||
const maxLevel = siteConfig.toc.depth;
|
||||
---
|
||||
{isPostsRoute &&
|
||||
<table-of-contents class:list={[className, "group"]}>
|
||||
{headings.filter((heading) => heading.depth < minDepth + maxLevel).map((heading) =>
|
||||
<a href={`#${heading.slug}`} class="px-2 flex gap-2 relative transition w-full min-h-9 rounded-xl
|
||||
hover:bg-[var(--toc-btn-hover)] active:bg-[var(--toc-btn-active)] py-2
|
||||
">
|
||||
<div class:list={["transition w-5 h-5 shrink-0 rounded-lg text-xs flex items-center justify-center font-bold",
|
||||
{
|
||||
"bg-[var(--toc-badge-bg)] text-[var(--btn-content)]": heading.depth == minDepth,
|
||||
"ml-4": heading.depth == minDepth + 1,
|
||||
"ml-8": heading.depth == minDepth + 2,
|
||||
}
|
||||
]}
|
||||
>
|
||||
{heading.depth == minDepth && heading1Count++}
|
||||
{heading.depth == minDepth + 1 && <div class="transition w-2 h-2 rounded-[0.1875rem] bg-[var(--toc-badge-bg)]"></div>}
|
||||
{heading.depth == minDepth + 2 && <div class="transition w-1.5 h-1.5 rounded-sm bg-black/5 dark:bg-white/10"></div>}
|
||||
</div>
|
||||
<div class:list={["transition text-sm", {
|
||||
"text-50": heading.depth == minDepth || heading.depth == minDepth + 1,
|
||||
"text-30": heading.depth == minDepth + 2,
|
||||
}]}>{removeTailingHash(heading.text)}</div>
|
||||
</a>
|
||||
)}
|
||||
<div id="active-indicator" class:list={[{'hidden': headings.length == 0}, "-z-10 absolute bg-[var(--toc-btn-hover)] left-0 right-0 rounded-xl transition-all " +
|
||||
"group-hover:bg-transparent border-2 border-[var(--toc-btn-hover)] group-hover:border-[var(--toc-btn-active)] border-dashed"]}></div>
|
||||
</table-of-contents>}
|
||||
|
||||
<script>
|
||||
class TableOfContents extends HTMLElement {
|
||||
tocEl: HTMLElement | null = null;
|
||||
visibleClass = "visible";
|
||||
observer: IntersectionObserver;
|
||||
anchorNavTarget: HTMLElement | null = null;
|
||||
headingIdxMap = new Map<string, number>();
|
||||
headings: HTMLElement[] = [];
|
||||
sections: HTMLElement[] = [];
|
||||
tocEntries: HTMLAnchorElement[] = [];
|
||||
active: boolean[] = [];
|
||||
activeIndicator: HTMLElement | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.observer = new IntersectionObserver(
|
||||
this.markVisibleSection, { threshold: 0 }
|
||||
);
|
||||
};
|
||||
|
||||
markVisibleSection = (entries: IntersectionObserverEntry[]) => {
|
||||
entries.forEach((entry) => {
|
||||
const id = entry.target.children[0]?.getAttribute("id");
|
||||
const idx = id ? this.headingIdxMap.get(id) : undefined;
|
||||
if (idx != undefined)
|
||||
this.active[idx] = entry.isIntersecting;
|
||||
|
||||
if (entry.isIntersecting && this.anchorNavTarget == entry.target.firstChild)
|
||||
this.anchorNavTarget = null;
|
||||
});
|
||||
|
||||
if (!this.active.includes(true))
|
||||
this.fallback();
|
||||
this.update();
|
||||
};
|
||||
|
||||
toggleActiveHeading = () => {
|
||||
let i = this.active.length - 1;
|
||||
let min = this.active.length - 1, max = 0;
|
||||
while (i >= 0 && !this.active[i]) {
|
||||
this.tocEntries[i].classList.remove(this.visibleClass);
|
||||
i--;
|
||||
}
|
||||
while (i >= 0 && this.active[i]) {
|
||||
this.tocEntries[i].classList.add(this.visibleClass);
|
||||
min = Math.min(min, i);
|
||||
max = Math.max(max, i);
|
||||
i--;
|
||||
}
|
||||
while (i >= 0) {
|
||||
this.tocEntries[i].classList.remove(this.visibleClass);
|
||||
i--;
|
||||
}
|
||||
let parentOffset = this.tocEl?.getBoundingClientRect().top || 0;
|
||||
let scrollOffset = this.tocEl?.scrollTop || 0;
|
||||
let top = this.tocEntries[min].getBoundingClientRect().top - parentOffset + scrollOffset;
|
||||
let bottom = this.tocEntries[max].getBoundingClientRect().bottom - parentOffset + scrollOffset;
|
||||
this.activeIndicator?.setAttribute("style", `top: ${top}px; height: ${bottom - top}px`);
|
||||
};
|
||||
|
||||
scrollToActiveHeading = () => {
|
||||
// If the TOC widget can accommodate both the topmost
|
||||
// and bottommost items, scroll to the topmost item.
|
||||
// Otherwise, scroll to the bottommost one.
|
||||
|
||||
if (this.anchorNavTarget || !this.tocEl) return;
|
||||
const activeHeading =
|
||||
document.querySelectorAll<HTMLDivElement>(`#toc .${this.visibleClass}`);
|
||||
if (!activeHeading.length) return;
|
||||
|
||||
const topmost = activeHeading[0];
|
||||
const bottommost = activeHeading[activeHeading.length - 1];
|
||||
const tocHeight = this.tocEl.clientHeight;
|
||||
|
||||
let top;
|
||||
if (bottommost.getBoundingClientRect().bottom -
|
||||
topmost.getBoundingClientRect().top < 0.9 * tocHeight)
|
||||
top = topmost.offsetTop - 32;
|
||||
else
|
||||
top = bottommost.offsetTop - tocHeight * 0.8;
|
||||
|
||||
this.tocEl.scrollTo({
|
||||
top,
|
||||
left: 0,
|
||||
behavior: "smooth",
|
||||
});
|
||||
};
|
||||
|
||||
update = () => {
|
||||
requestAnimationFrame(() => {
|
||||
this.toggleActiveHeading();
|
||||
// requestAnimationFrame(() => {
|
||||
this.scrollToActiveHeading();
|
||||
// });
|
||||
});
|
||||
};
|
||||
|
||||
fallback = () => {
|
||||
if (!this.sections.length) return;
|
||||
|
||||
for (let i = 0; i < this.sections.length; i++) {
|
||||
let offsetTop = this.sections[i].getBoundingClientRect().top;
|
||||
let offsetBottom = this.sections[i].getBoundingClientRect().bottom;
|
||||
|
||||
if (this.isInRange(offsetTop, 0, window.innerHeight)
|
||||
|| this.isInRange(offsetBottom, 0, window.innerHeight)
|
||||
|| (offsetTop < 0 && offsetBottom > window.innerHeight)) {
|
||||
this.markActiveHeading(i);
|
||||
}
|
||||
else if (offsetTop > window.innerHeight) break;
|
||||
}
|
||||
};
|
||||
|
||||
markActiveHeading = (idx: number)=> {
|
||||
this.active[idx] = true;
|
||||
};
|
||||
|
||||
handleAnchorClick = (event: Event) => {
|
||||
const anchor = event
|
||||
.composedPath()
|
||||
.find((element) => element instanceof HTMLAnchorElement);
|
||||
|
||||
if (anchor) {
|
||||
const id = decodeURIComponent(anchor.hash?.substring(1));
|
||||
const idx = this.headingIdxMap.get(id);
|
||||
if (idx !== undefined) {
|
||||
this.anchorNavTarget = this.headings[idx];
|
||||
} else {
|
||||
this.anchorNavTarget = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
isInRange(value: number, min: number, max: number) {
|
||||
return min < value && value < max;
|
||||
};
|
||||
|
||||
connectedCallback() {
|
||||
// wait for the onload animation to finish, which makes the `getBoundingClientRect` return correct values
|
||||
const element = document.querySelector('.prose');
|
||||
if (element) {
|
||||
element.addEventListener('animationend', () => {
|
||||
this.init();
|
||||
}, { once: true });
|
||||
} else {
|
||||
console.debug('Animation element not found');
|
||||
}
|
||||
};
|
||||
|
||||
init() {
|
||||
this.tocEl = document.getElementById(
|
||||
"toc-inner-wrapper"
|
||||
);
|
||||
|
||||
if (!this.tocEl) return;
|
||||
|
||||
this.tocEl.addEventListener("click", this.handleAnchorClick, {
|
||||
capture: true,
|
||||
});
|
||||
|
||||
this.activeIndicator = document.getElementById("active-indicator");
|
||||
|
||||
this.tocEntries = Array.from(
|
||||
document.querySelectorAll<HTMLAnchorElement>("#toc a[href^='#']")
|
||||
);
|
||||
|
||||
if (this.tocEntries.length === 0) return;
|
||||
|
||||
this.sections = new Array(this.tocEntries.length);
|
||||
this.headings = new Array(this.tocEntries.length);
|
||||
for (let i = 0; i < this.tocEntries.length; i++) {
|
||||
const id = decodeURIComponent(this.tocEntries[i].hash?.substring(1));
|
||||
const heading = document.getElementById(id);
|
||||
const section = heading?.parentElement;
|
||||
if (heading instanceof HTMLElement && section instanceof HTMLElement) {
|
||||
this.headings[i] = heading;
|
||||
this.sections[i] = section;
|
||||
this.headingIdxMap.set(id, i);
|
||||
}
|
||||
}
|
||||
this.active = new Array(this.tocEntries.length).fill(false);
|
||||
|
||||
this.sections.forEach((section) =>
|
||||
this.observer.observe(section)
|
||||
);
|
||||
|
||||
this.fallback();
|
||||
this.update();
|
||||
};
|
||||
|
||||
disconnectedCallback() {
|
||||
this.sections.forEach((section) =>
|
||||
this.observer.unobserve(section)
|
||||
);
|
||||
this.observer.disconnect();
|
||||
this.tocEl?.removeEventListener("click", this.handleAnchorClick);
|
||||
};
|
||||
}
|
||||
|
||||
if (!customElements.get("table-of-contents")) {
|
||||
customElements.define("table-of-contents", TableOfContents);
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
|
||||
import I18nKey from "../../i18n/i18nKey";
|
||||
import { i18n } from "../../i18n/translation";
|
||||
import { getTagList } from "../../utils/content-utils";
|
||||
import { getTagUrl } from "../../utils/url-utils";
|
||||
import ButtonTag from "../control/ButtonTag.astro";
|
||||
import WidgetLayout from "./WidgetLayout.astro";
|
||||
|
||||
const tags = await getTagList();
|
||||
|
||||
const COLLAPSED_HEIGHT = "7.5rem";
|
||||
|
||||
const isCollapsed = tags.length >= 20;
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
style?: string;
|
||||
}
|
||||
const className = Astro.props.class;
|
||||
const style = Astro.props.style;
|
||||
---
|
||||
<WidgetLayout name={i18n(I18nKey.tags)} id="tags" isCollapsed={isCollapsed} collapsedHeight={COLLAPSED_HEIGHT} class={className} style={style}>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
{tags.map(t => (
|
||||
<ButtonTag href={getTagUrl(t.name)} label={`View all posts with the ${t.name.trim()} tag`}>
|
||||
{t.name.trim()}
|
||||
</ButtonTag>
|
||||
))}
|
||||
</div>
|
||||
</WidgetLayout>
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
import { Icon } from "astro-icon/components";
|
||||
import I18nKey from "../../i18n/i18nKey";
|
||||
import { i18n } from "../../i18n/translation";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
name?: string;
|
||||
isCollapsed?: boolean;
|
||||
collapsedHeight?: string;
|
||||
class?: string;
|
||||
style?: string;
|
||||
}
|
||||
const { id, name, isCollapsed, collapsedHeight, style } = Astro.props;
|
||||
const className = Astro.props.class;
|
||||
---
|
||||
<widget-layout data-id={id} data-is-collapsed={String(isCollapsed)} class={"pb-4 card-base " + className} style={style}>
|
||||
<div class="font-bold transition text-lg text-neutral-900 dark:text-neutral-100 relative ml-8 mt-4 mb-2
|
||||
before:w-1 before:h-4 before:rounded-md before:bg-[var(--primary)]
|
||||
before:absolute before:left-[-16px] before:top-[5.5px]">{name}</div>
|
||||
<div id={id} class:list={["collapse-wrapper px-4 overflow-hidden", {"collapsed": isCollapsed}]}>
|
||||
<slot></slot>
|
||||
</div>
|
||||
{isCollapsed && <div class="expand-btn px-4 -mb-2">
|
||||
<button class="btn-plain rounded-lg w-full h-9">
|
||||
<div class="text-[var(--primary)] flex items-center justify-center gap-2 -translate-x-2">
|
||||
<Icon name="material-symbols:more-horiz" class="text-[1.75rem]"></Icon> {i18n(I18nKey.more)}
|
||||
</div>
|
||||
</button>
|
||||
</div>}
|
||||
</widget-layout>
|
||||
|
||||
<style define:vars={{ collapsedHeight }}>
|
||||
.collapsed {
|
||||
height: var(--collapsedHeight);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
class WidgetLayout extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
if (this.dataset.isCollapsed !== "true")
|
||||
return;
|
||||
|
||||
const id = this.dataset.id;
|
||||
const btn = this.querySelector('.expand-btn');
|
||||
const wrapper = this.querySelector(`#${id}`)
|
||||
btn!.addEventListener('click', () => {
|
||||
wrapper!.classList.remove('collapsed');
|
||||
btn!.classList.add('hidden');
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get("widget-layout")) {
|
||||
customElements.define("widget-layout", WidgetLayout);
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user