Migrate from react to svelte (#147)
This commit is contained in:
parent
cca185e075
commit
41a516c283
38 changed files with 1042 additions and 1118 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -6,3 +6,4 @@ node_modules
|
||||||
|
|
||||||
# Build output
|
# Build output
|
||||||
dist
|
dist
|
||||||
|
stats.html
|
||||||
|
|
|
||||||
12
.vscode/settings.json
vendored
12
.vscode/settings.json
vendored
|
|
@ -5,14 +5,22 @@
|
||||||
"emmet.includeLanguages": { "javascript": "javascriptreact", "astro": "javascriptreact" },
|
"emmet.includeLanguages": { "javascript": "javascriptreact", "astro": "javascriptreact" },
|
||||||
"eslint.validate": ["javascript", "javascriptreact", "astro", "typescript", "typescriptreact"],
|
"eslint.validate": ["javascript", "javascriptreact", "astro", "typescript", "typescriptreact"],
|
||||||
"files.eol": "\n",
|
"files.eol": "\n",
|
||||||
|
"prettier.documentSelectors": ["**/*.astro"],
|
||||||
|
"svelte.enable-ts-plugin": true,
|
||||||
"tailwindCSS.classAttributes": ["class", "className", "class:list"],
|
"tailwindCSS.classAttributes": ["class", "className", "class:list"],
|
||||||
"tailwindCSS.experimental.classRegex": [["/\\* tw \\*/ ([^;]*);", "'([^']*)'"]],
|
"tailwindCSS.experimental.classRegex": [["/\\* tw \\*/ ([^;]*);", "'([^']*)'"]],
|
||||||
|
"tailwindCSS.includeLanguages": {
|
||||||
|
"javascript": "javascriptreact",
|
||||||
|
"astro": "javascriptreact",
|
||||||
|
"svelte": "javascriptreact"
|
||||||
|
},
|
||||||
"typescript.tsdk": "node_modules/typescript/lib",
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
"typescript.enablePromptUseWorkspaceTsdk": true,
|
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||||
"[astro]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
|
"[astro]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
|
||||||
|
"[ignore]": { "editor.defaultFormatter": "foxundermoon.shell-format" },
|
||||||
"[json]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
|
"[json]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
|
||||||
"[javascript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
|
"[javascript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
|
||||||
|
"[svelte]": { "editor.defaultFormatter": "svelte.svelte-vscode" },
|
||||||
"[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
|
"[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
|
||||||
"[typescriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
|
"[typescriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }
|
||||||
"prettier.documentSelectors": ["**/*.astro"]
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import image from '@astrojs/image';
|
import image from '@astrojs/image';
|
||||||
import react from '@astrojs/react';
|
import svelte from '@astrojs/svelte';
|
||||||
import tailwind from '@astrojs/tailwind';
|
import tailwind from '@astrojs/tailwind';
|
||||||
import { defineConfig } from 'astro/config';
|
import { defineConfig } from 'astro/config';
|
||||||
|
import { visualizer } from 'rollup-plugin-visualizer';
|
||||||
|
|
||||||
// https://astro.build/config
|
// https://astro.build/config
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
integrations: [react(), tailwind(), image()],
|
integrations: [tailwind(), image(), svelte()],
|
||||||
vite: { ssr: { external: ['svgo'] } },
|
vite: {
|
||||||
|
plugins: [visualizer()],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
1536
package-lock.json
generated
1536
package-lock.json
generated
File diff suppressed because it is too large
Load diff
10
package.json
10
package.json
|
|
@ -22,11 +22,11 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/image": "0.12.1",
|
"@astrojs/image": "0.12.1",
|
||||||
"@iconify-icon/react": "1.0.2",
|
"@astrojs/svelte": "1.0.2",
|
||||||
"@tippyjs/react": "4.2.6",
|
"@floating-ui/dom": "1.1.0",
|
||||||
"react": "18.2.0",
|
"iconify-icon": "1.0.2",
|
||||||
"react-dom": "18.2.0",
|
"rollup-plugin-visualizer": "5.9.0",
|
||||||
"react-use": "17.4.0"
|
"svelte": "3.55.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@astrojs/react": "1.2.2",
|
"@astrojs/react": "1.2.2",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
---
|
---
|
||||||
import type { IconName } from '@/types/icon';
|
import type { IconName } from '@/types/icon';
|
||||||
|
|
||||||
import Icon from './icon';
|
import Icon from './icon.svelte';
|
||||||
|
|
||||||
type IconButtonSize = 'small' | 'large';
|
type IconButtonSize = 'small' | 'large';
|
||||||
|
|
||||||
|
|
|
||||||
19
src/components/icon-with-tooltip.svelte
Normal file
19
src/components/icon-with-tooltip.svelte
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Placement, offset, Padding } from '@floating-ui/dom';
|
||||||
|
import type { IconName } from '@/types/icon';
|
||||||
|
import Icon from './icon.svelte';
|
||||||
|
import Tooltip from './tooltip.svelte';
|
||||||
|
|
||||||
|
export let name: IconName;
|
||||||
|
export let size: number;
|
||||||
|
export let color: string | undefined = undefined;
|
||||||
|
|
||||||
|
export let content: string;
|
||||||
|
export let placement: Placement | undefined = undefined;
|
||||||
|
export let padding: Padding = 8;
|
||||||
|
export let spacing: Parameters<typeof offset>[0] = 8;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Tooltip {content} {placement} {padding} {spacing}>
|
||||||
|
<Icon {name} {color} {size} />
|
||||||
|
</Tooltip>
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
import Icon, { IconProps } from './icon';
|
|
||||||
import Tooltip, { TooltipProps } from './tooltip';
|
|
||||||
|
|
||||||
type Props = IconProps & Omit<TooltipProps, 'children'>;
|
|
||||||
|
|
||||||
const IconWithTooltip = ({ name, color, size, ...tooltipProps }: Props) => (
|
|
||||||
<Tooltip {...tooltipProps}>
|
|
||||||
<div className="cursor-pointer">
|
|
||||||
<Icon name={name} color={color} size={size} />
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default IconWithTooltip;
|
|
||||||
17
src/components/icon.svelte
Normal file
17
src/components/icon.svelte
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import 'iconify-icon';
|
||||||
|
import type { IconName } from '@/types/icon';
|
||||||
|
import { isServer } from '@/utils/env';
|
||||||
|
|
||||||
|
export let name: IconName;
|
||||||
|
export let size: number;
|
||||||
|
export let color: string | undefined = undefined;
|
||||||
|
|
||||||
|
const dimensions = `width: ${size}px; height: ${size}px`;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isServer}
|
||||||
|
<div style={dimensions} />
|
||||||
|
{:else}
|
||||||
|
<iconify-icon icon={name} width={size} height={size} style="color: {color}; {dimensions}"> ></iconify-icon>
|
||||||
|
{/if}
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
import { Icon as IconComponent } from '@iconify-icon/react';
|
|
||||||
|
|
||||||
import type { IconName } from '@/types/icon';
|
|
||||||
|
|
||||||
export interface IconProps {
|
|
||||||
name?: IconName;
|
|
||||||
color?: string;
|
|
||||||
size: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Icon = ({ name = undefined, color = undefined, size }: IconProps) =>
|
|
||||||
name ? <IconComponent icon={name} width={size} height={size} style={{ color }} /> : null;
|
|
||||||
|
|
||||||
export default Icon;
|
|
||||||
|
|
@ -1,17 +1,22 @@
|
||||||
---
|
---
|
||||||
import type { Section } from '@/types/data';
|
import type { SectionKey } from '@/types/data';
|
||||||
|
|
||||||
import Typography from './typography.astro';
|
import Typography from './typography.astro';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
section: Section;
|
section: SectionKey;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { section, title } = Astro.props;
|
const { section, title, className } = Astro.props;
|
||||||
---
|
---
|
||||||
|
|
||||||
<div id={section} class="flex flex-col gap-6 rounded-2xl bg-white p-8 shadow-md dark:bg-gray-800">
|
<div
|
||||||
|
data-type="section"
|
||||||
|
id={section}
|
||||||
|
class:list={['flex flex-col gap-6 rounded-2xl bg-white p-8 shadow-md dark:bg-gray-800', className]}
|
||||||
|
>
|
||||||
{
|
{
|
||||||
title && (
|
title && (
|
||||||
<Typography variant="section-title" id={`${section}-heading`}>
|
<Typography variant="section-title" id={`${section}-heading`}>
|
||||||
|
|
@ -21,3 +26,22 @@ const { section, title } = Astro.props;
|
||||||
}
|
}
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import hashState from '@/utils/hash-state';
|
||||||
|
import throttle from '@/utils/throttle';
|
||||||
|
|
||||||
|
const sections = [...document.querySelectorAll('[data-type="section"]')];
|
||||||
|
|
||||||
|
const isInUpperView = (section: Element) => section.getBoundingClientRect().bottom >= window.innerHeight / 3;
|
||||||
|
|
||||||
|
const updateHash = () => {
|
||||||
|
const currentSection = sections.find(isInUpperView);
|
||||||
|
|
||||||
|
if (currentSection) {
|
||||||
|
hashState.updateHash(currentSection.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('scroll', throttle(updateHash, 200));
|
||||||
|
</script>
|
||||||
|
|
|
||||||
36
src/components/sidebar-item.svelte
Normal file
36
src/components/sidebar-item.svelte
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { SectionKey } from '@/types/data';
|
||||||
|
import type { IconName } from '@/types/icon';
|
||||||
|
import hashState from '@/utils/hash-state';
|
||||||
|
import Icon from './icon.svelte';
|
||||||
|
import Tooltip from './tooltip.svelte';
|
||||||
|
|
||||||
|
export let section: SectionKey;
|
||||||
|
export let icon: IconName;
|
||||||
|
export let title: string = '';
|
||||||
|
|
||||||
|
let hash = hashState.getHash();
|
||||||
|
|
||||||
|
hashState.subscribe((newHash) => (hash = newHash));
|
||||||
|
|
||||||
|
const href = `#${section}`;
|
||||||
|
|
||||||
|
$: active = hash === href;
|
||||||
|
|
||||||
|
const classes = /* tw */ {
|
||||||
|
main: 'inline-flex h-10 w-10 items-center justify-center rounded-lg transition',
|
||||||
|
active: 'bg-primary-600 text-white',
|
||||||
|
inactive: 'text-gray-400 hover:bg-primary-600 hover:text-white dark:text-gray-200',
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Tooltip content={`${title || section.charAt(0).toUpperCase() + section.slice(1)}`} placement="left">
|
||||||
|
<a
|
||||||
|
{href}
|
||||||
|
class={`${classes.main} ${active ? classes.active : classes.inactive}`}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
aria-label={`${section} section`}
|
||||||
|
>
|
||||||
|
<Icon name={icon} size={20} />
|
||||||
|
</a>
|
||||||
|
</Tooltip>
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
import { useLocation } from 'react-use';
|
|
||||||
|
|
||||||
import type { Section } from '@/types/data';
|
|
||||||
import type { IconName } from '@/types/icon';
|
|
||||||
|
|
||||||
import Icon from './icon';
|
|
||||||
import Tooltip from './tooltip';
|
|
||||||
|
|
||||||
export interface SidebarItemProps {
|
|
||||||
section: Section;
|
|
||||||
icon: IconName;
|
|
||||||
title?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MAIN_SECTION: Section = 'main';
|
|
||||||
|
|
||||||
const classes = /* tw */ {
|
|
||||||
main: 'inline-flex h-10 w-10 items-center justify-center rounded-lg transition',
|
|
||||||
active: 'bg-primary-600 text-white',
|
|
||||||
inactive: 'bg-white text-gray-400 hover:bg-primary-600 hover:text-white dark:bg-gray-800 dark:text-gray-200',
|
|
||||||
};
|
|
||||||
|
|
||||||
const SidebarItem = ({ section, icon, title = '' }: SidebarItemProps) => {
|
|
||||||
const { hash } = useLocation();
|
|
||||||
const href = `#${section}`;
|
|
||||||
|
|
||||||
const active = hash === '' ? section === MAIN_SECTION : hash === href;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip content={`${title || section.charAt(0).toUpperCase() + section.slice(1)}`} placement="left">
|
|
||||||
<a
|
|
||||||
href={href}
|
|
||||||
className={`${classes.main} ${active ? classes.active : classes.inactive}`}
|
|
||||||
aria-current={active ? 'page' : undefined}
|
|
||||||
aria-label={`${section} section`}
|
|
||||||
>
|
|
||||||
<Icon name={icon} size={20} />
|
|
||||||
</a>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SidebarItem;
|
|
||||||
|
|
@ -1,10 +1,32 @@
|
||||||
---
|
---
|
||||||
|
import type { Data } from '@/data';
|
||||||
|
import isSectionKey from '@/utils/is-section-key';
|
||||||
|
|
||||||
|
import SidebarItem from './sidebar-item.svelte';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
data: Data;
|
||||||
}
|
}
|
||||||
const { className } = Astro.props;
|
|
||||||
|
const { className, data } = Astro.props;
|
||||||
|
|
||||||
|
const sections = Object.keys(data).flatMap((key) => {
|
||||||
|
if (!isSectionKey(key)) return [];
|
||||||
|
|
||||||
|
const section = data[key];
|
||||||
|
|
||||||
|
if (!section) return [];
|
||||||
|
|
||||||
|
return [{ title: section.config.title, icon: section.config.icon, section: key }];
|
||||||
|
});
|
||||||
---
|
---
|
||||||
|
|
||||||
<nav class:list={['flex flex-col w-max h-fit p-2 rounded-lg gap-2 bg-white dark:bg-gray-800 shadow-md', className]}>
|
<nav
|
||||||
<slot />
|
class:list={[
|
||||||
|
'hidden xl:flex flex-col w-max h-fit p-2 rounded-lg gap-2 bg-white dark:bg-gray-800 shadow-md',
|
||||||
|
className,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{sections.map((section) => <SidebarItem client:load {...section} />)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
---
|
---
|
||||||
import type { IconName } from '@/types/icon';
|
import type { IconName } from '@/types/icon';
|
||||||
|
|
||||||
import Icon from './icon';
|
import Icon from './icon.svelte';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
name?: IconName;
|
name?: IconName;
|
||||||
|
|
@ -14,6 +14,6 @@ const { name, color } = Astro.props;
|
||||||
<div
|
<div
|
||||||
class="flex h-6 w-fit items-center gap-x-1.5 rounded bg-gray-100 px-2.5 text-sm font-medium tracking-wide text-gray-700 dark:bg-gray-700 dark:text-gray-100"
|
class="flex h-6 w-fit items-center gap-x-1.5 rounded bg-gray-100 px-2.5 text-sm font-medium tracking-wide text-gray-700 dark:bg-gray-700 dark:text-gray-100"
|
||||||
>
|
>
|
||||||
<Icon client:load name={name} color={color} size={16} />
|
{name && <Icon client:load name={name} color={color} size={16} />}
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import Icon from './icon';
|
|
||||||
|
|
||||||
const STORAGE_THEME_KEY = 'theme';
|
|
||||||
const DARK_THEME_KEY = 'dark';
|
|
||||||
const LIGHT_THEME_KEY = 'light';
|
|
||||||
|
|
||||||
type ThemeVariant = typeof DARK_THEME_KEY | typeof LIGHT_THEME_KEY;
|
|
||||||
|
|
||||||
const getInitialTheme = (): ThemeVariant => {
|
|
||||||
if (typeof localStorage !== 'undefined' && localStorage.getItem(STORAGE_THEME_KEY)) {
|
|
||||||
return localStorage.getItem(STORAGE_THEME_KEY) === LIGHT_THEME_KEY ? LIGHT_THEME_KEY : DARK_THEME_KEY;
|
|
||||||
}
|
|
||||||
if (window.matchMedia(`(prefers-color-scheme: ${DARK_THEME_KEY})`).matches) {
|
|
||||||
return DARK_THEME_KEY;
|
|
||||||
}
|
|
||||||
return LIGHT_THEME_KEY;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ThemeToggle = () => {
|
|
||||||
const [theme, setTheme] = useState<ThemeVariant>(() => getInitialTheme());
|
|
||||||
|
|
||||||
const handleClick = () => {
|
|
||||||
setTheme((prev) => (prev === LIGHT_THEME_KEY ? DARK_THEME_KEY : LIGHT_THEME_KEY));
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (theme === DARK_THEME_KEY) {
|
|
||||||
document.documentElement.classList.add(DARK_THEME_KEY);
|
|
||||||
}
|
|
||||||
if (theme === LIGHT_THEME_KEY) {
|
|
||||||
document.documentElement.classList.remove(DARK_THEME_KEY);
|
|
||||||
}
|
|
||||||
localStorage.setItem(STORAGE_THEME_KEY, theme);
|
|
||||||
}, [theme]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={handleClick}
|
|
||||||
type="button"
|
|
||||||
className="fixed bottom-3 left-3 z-10 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100 text-gray-400 shadow-xl transition focus:ring-primary-500 dark:bg-gray-600 dark:text-gray-200"
|
|
||||||
>
|
|
||||||
<Icon name={theme === DARK_THEME_KEY ? 'ri:moon-fill' : 'ri:sun-line'} size={20} />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ThemeToggle;
|
|
||||||
26
src/components/theme-toggle.svelte
Normal file
26
src/components/theme-toggle.svelte
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { isClient } from '@/utils/env';
|
||||||
|
import Icon from './icon.svelte';
|
||||||
|
|
||||||
|
let theme = localStorage.getItem('theme') ?? 'light';
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
theme = theme === 'light' ? 'dark' : 'light';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleThemeChange = () => {
|
||||||
|
if (isClient) {
|
||||||
|
document.documentElement.classList[theme === 'dark' ? 'add' : 'remove']('dark');
|
||||||
|
localStorage.setItem('theme', theme);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$: theme, handleThemeChange();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
on:click={toggleTheme}
|
||||||
|
class="fixed bottom-3 left-3 z-10 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100 text-gray-400 shadow-xl transition focus:ring-primary-500 dark:bg-gray-600 dark:text-gray-200"
|
||||||
|
>
|
||||||
|
<Icon name={theme === 'light' ? 'ri:moon-fill' : 'ri:sun-line'} size={20} />
|
||||||
|
</button>
|
||||||
47
src/components/tooltip.svelte
Normal file
47
src/components/tooltip.svelte
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { computePosition, Placement, flip, shift, offset, Padding } from '@floating-ui/dom';
|
||||||
|
|
||||||
|
export let content: string;
|
||||||
|
export let placement: Placement | undefined;
|
||||||
|
export let padding: Padding = 8;
|
||||||
|
export let spacing: Parameters<typeof offset>[0] = 8;
|
||||||
|
|
||||||
|
let button: HTMLElement;
|
||||||
|
let tooltip: HTMLElement;
|
||||||
|
|
||||||
|
const updateTooltip = () => {
|
||||||
|
computePosition(button, tooltip, { placement, middleware: [offset(spacing), flip(), shift({ padding })] }).then(
|
||||||
|
({ x, y }) => {
|
||||||
|
Object.assign(tooltip.style, {
|
||||||
|
left: `${x}px`,
|
||||||
|
top: `${y}px`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showTooltip = () => {
|
||||||
|
tooltip.style.display = 'block';
|
||||||
|
updateTooltip();
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideTooltip = () => {
|
||||||
|
tooltip.style.display = '';
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="w-fit h-fit flex justify-center items-center"
|
||||||
|
bind:this={button}
|
||||||
|
on:mouseenter={showTooltip}
|
||||||
|
on:mouseleave={hideTooltip}
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
bind:this={tooltip}
|
||||||
|
role="tooltip"
|
||||||
|
class="hidden max-w-sm absolute top-0 left-0 rounded-lg bg-gray-700 px-3 py-1 text-white dark:bg-gray-100 dark:text-gray-800 sm:max-w-xs"
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
import Tippy, { TippyProps } from '@tippyjs/react/headless';
|
|
||||||
import type { ReactElement } from 'react';
|
|
||||||
|
|
||||||
export interface TooltipProps {
|
|
||||||
children: ReactElement;
|
|
||||||
content: string;
|
|
||||||
placement?: TippyProps['placement'];
|
|
||||||
}
|
|
||||||
|
|
||||||
const Tooltip = ({ children, content, placement = 'top' }: TooltipProps) => (
|
|
||||||
<Tippy
|
|
||||||
render={(attrs) => (
|
|
||||||
<div
|
|
||||||
{...attrs}
|
|
||||||
className="max-w-[95%] rounded-lg bg-gray-700 px-2 py-1.5 text-white dark:bg-gray-100 dark:text-gray-800 sm:max-w-xs"
|
|
||||||
>
|
|
||||||
{content}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
placement={placement}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Tippy>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default Tooltip;
|
|
||||||
|
|
@ -25,8 +25,6 @@ export interface Data {
|
||||||
favorites?: FavoritesSection;
|
favorites?: FavoritesSection;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Section = Exclude<keyof Data, 'seo'>;
|
|
||||||
|
|
||||||
const data: Data = {
|
const data: Data = {
|
||||||
i18n: {
|
i18n: {
|
||||||
locale: 'en-US',
|
locale: 'en-US',
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
---
|
---
|
||||||
import Icon from '@/components/icon';
|
import Icon from '@/components/icon.svelte';
|
||||||
---
|
---
|
||||||
|
|
||||||
<div class="p-5">
|
<div class="p-5">
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
---
|
---
|
||||||
import SidebarItem from '@/components/sidebar-item';
|
import SidebarItem from '@/components/sidebar-item.svelte';
|
||||||
---
|
---
|
||||||
|
|
||||||
<div class="p-5">
|
<div class="p-5">
|
||||||
<SidebarItem client:only="react" icon="fa6-solid:bars-progress" section="experience" />
|
<SidebarItem client:load icon="fa6-solid:bars-progress" section="experience" />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
---
|
|
||||||
import Sidebar from '@/components/sidebar.astro';
|
|
||||||
import SidebarItem from '@/components/sidebar-item';
|
|
||||||
---
|
|
||||||
|
|
||||||
<div class="relative p-5">
|
|
||||||
<Sidebar>
|
|
||||||
<SidebarItem client:only="react" icon="fa6-solid:bars-progress" section="skills" />
|
|
||||||
<SidebarItem client:only="react" icon="fa6-solid:suitcase" section="experience" />
|
|
||||||
<SidebarItem client:only="react" icon="fa6-solid:rocket" section="portfolio" />
|
|
||||||
</Sidebar>
|
|
||||||
</div>
|
|
||||||
|
|
@ -1,18 +1,16 @@
|
||||||
---
|
---
|
||||||
import Sidebar from '@/components/sidebar.astro';
|
import Sidebar from '@/components/sidebar.astro';
|
||||||
import SidebarItem from '@/components/sidebar-item';
|
import ThemeToggle from '@/components/theme-toggle.svelte';
|
||||||
import ThemeToggle from '@/components/theme-icon';
|
|
||||||
import ExperienceSection from '@/sections/experience/experience-section.astro';
|
import ExperienceSection from '@/sections/experience/experience-section.astro';
|
||||||
import FavoritesSection from '@/sections/favorites/favorites-section.astro';
|
import FavoritesSection from '@/sections/favorites/favorites-section.astro';
|
||||||
import MainSection from '@/sections/main/main-section.astro';
|
import MainSection from '@/sections/main/main-section.astro';
|
||||||
import PortfolioSection from '@/sections/portfolio/portfolio-section.astro';
|
import PortfolioSection from '@/sections/portfolio/portfolio-section.astro';
|
||||||
import SkillsSection from '@/sections/skills/skills-section.astro';
|
import SkillsSection from '@/sections/skills/skills-section.astro';
|
||||||
import TestimonialsSection from '@/sections/testimonials/testimonials-section.astro';
|
import TestimonialsSection from '@/sections/testimonials/testimonials-section.astro';
|
||||||
import getObjectKeys from '@/utils/getObjectKeys';
|
|
||||||
|
|
||||||
import data from '../data';
|
import data from '../data';
|
||||||
|
|
||||||
const { seo, i18n, ...dataWithoutSeoAndI18n } = data;
|
const { seo, i18n } = data;
|
||||||
const seoImage = seo.image ? seo.image : '/favicon.svg';
|
const seoImage = seo.image ? seo.image : '/favicon.svg';
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -28,47 +26,33 @@ const seoImage = seo.image ? seo.image : '/favicon.svg';
|
||||||
<meta property="og:title" content={seo.title} />
|
<meta property="og:title" content={seo.title} />
|
||||||
<meta property="og:description" content={seo.description} />
|
<meta property="og:description" content={seo.description} />
|
||||||
<meta property="og:image" content={seoImage} />
|
<meta property="og:image" content={seoImage} />
|
||||||
</head>
|
<script is:inline>
|
||||||
<body class="flex justify-center bg-gray-50 dark:bg-gray-900">
|
const theme = (() => {
|
||||||
<ThemeToggle client:only="react" />
|
if (typeof localStorage !== 'undefined' && localStorage.getItem('theme')) {
|
||||||
<div class="relative flex w-full max-w-5xl transform-none gap-8 px-2 py-3 sm:px-8 sm:py-12 lg:py-20">
|
return localStorage.getItem('theme');
|
||||||
<div class="absolute -right-2 z-40">
|
|
||||||
<Sidebar className="hidden xl:flex fixed">
|
|
||||||
{
|
|
||||||
getObjectKeys(dataWithoutSeoAndI18n).map((key) => {
|
|
||||||
const sectionData = dataWithoutSeoAndI18n[key];
|
|
||||||
return (
|
|
||||||
sectionData && (
|
|
||||||
<SidebarItem
|
|
||||||
client:only="react"
|
|
||||||
title={sectionData.config.title}
|
|
||||||
icon={sectionData.config.icon}
|
|
||||||
section={key}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</Sidebar>
|
|
||||||
</div>
|
|
||||||
<main class="relative w-full space-y-4 sm:space-y-6 lg:space-y-8">
|
|
||||||
<MainSection {...data.main} />
|
|
||||||
{data.skills && <SkillsSection {...data.skills} />}
|
|
||||||
{
|
|
||||||
data.experience && (
|
|
||||||
<ExperienceSection i18n={data.i18n} jobs={data.experience.jobs} config={data.experience.config} />
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
{data.portfolio && <PortfolioSection i18n={data.i18n} {...data.portfolio} />}
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
{data.testimonials && <TestimonialsSection {...data.testimonials} />}
|
})();
|
||||||
{data.favorites && <FavoritesSection {...data.favorites} />}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
<script>
|
|
||||||
import updateHash from '../scripts/updateHash';
|
|
||||||
import data from '../data';
|
|
||||||
|
|
||||||
document.addEventListener('scroll', () => updateHash(data));
|
if (theme === 'light') {
|
||||||
</script>
|
document.documentElement.classList.remove('dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.localStorage.setItem('theme', theme);
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body class="flex justify-center overflow-x-hidden bg-gray-50 dark:bg-gray-900 xl:relative xl:left-7">
|
||||||
|
<ThemeToggle client:load />
|
||||||
|
<main class="w-full max-w-5xl space-y-4 px-2 py-3 sm:space-y-6 sm:px-8 sm:py-12 lg:space-y-8 lg:py-20">
|
||||||
|
<MainSection {...data.main} />
|
||||||
|
{data.skills && <SkillsSection {...data.skills} />}
|
||||||
|
{data.experience && <ExperienceSection i18n={i18n} {...data.experience} />}
|
||||||
|
{data.portfolio && <PortfolioSection i18n={i18n} {...data.portfolio} />}
|
||||||
|
{data.testimonials && <TestimonialsSection {...data.testimonials} />}
|
||||||
|
{data.favorites && <FavoritesSection {...data.favorites} />}
|
||||||
|
</main>
|
||||||
|
<Sidebar data={data} className="sticky top-8 mt-20" />
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
import type { Data } from '@/types/data';
|
|
||||||
|
|
||||||
const updateHash = (data: Data) => {
|
|
||||||
const { seo, ...dataWithoutSeo } = data;
|
|
||||||
|
|
||||||
const distancesToHeadingBottom = Object.keys(dataWithoutSeo)
|
|
||||||
.flatMap((section) => {
|
|
||||||
const sectionWrapper = document.getElementById(`${section}-heading`);
|
|
||||||
|
|
||||||
if (!sectionWrapper) return [];
|
|
||||||
|
|
||||||
const { bottom } = sectionWrapper.getBoundingClientRect();
|
|
||||||
|
|
||||||
return {
|
|
||||||
section,
|
|
||||||
bottom,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((section) => section.bottom > 0);
|
|
||||||
|
|
||||||
if (distancesToHeadingBottom.length === 0) return;
|
|
||||||
|
|
||||||
const currentSection = distancesToHeadingBottom.reduce((previous, current) =>
|
|
||||||
previous.bottom < current.bottom ? previous : current
|
|
||||||
);
|
|
||||||
|
|
||||||
window.history.pushState({}, '', `${window.location.pathname}#${currentSection.section}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default updateHash;
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import DividedList from '@/components/divided-list.astro';
|
import DividedList from '@/components/divided-list.astro';
|
||||||
import Divider from '@/components/divider.astro';
|
import Divider from '@/components/divider.astro';
|
||||||
import SectionCard from '@/components/section-card.astro';
|
import SectionCard from '@/components/section-card.astro';
|
||||||
import type { Section } from '@/types/data';
|
import type { SectionKey } from '@/types/data';
|
||||||
import type { ExperienceSection, Job } from '@/types/experience-section';
|
import type { ExperienceSection, Job } from '@/types/experience-section';
|
||||||
import type { I18n } from '@/types/i18n';
|
import type { I18n } from '@/types/i18n';
|
||||||
import removeLast from '@/utils/remove-last';
|
import removeLast from '@/utils/remove-last';
|
||||||
|
|
@ -20,7 +20,7 @@ const {
|
||||||
jobs,
|
jobs,
|
||||||
} = Astro.props;
|
} = Astro.props;
|
||||||
|
|
||||||
const section: Section = 'experience';
|
const section: SectionKey = 'experience';
|
||||||
---
|
---
|
||||||
|
|
||||||
<SectionCard section={section} title={title}>
|
<SectionCard section={section} title={title}>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import type { ComponentInstance } from 'astro';
|
||||||
|
|
||||||
import SectionCard from '@/components/section-card.astro';
|
import SectionCard from '@/components/section-card.astro';
|
||||||
import Typography from '@/components/typography.astro';
|
import Typography from '@/components/typography.astro';
|
||||||
import type { Section } from '@/types/data';
|
import type { SectionKey } from '@/types/data';
|
||||||
import type { Book, FavoritesSection, Media, Person, Video } from '@/types/favorites-section';
|
import type { Book, FavoritesSection, Media, Person, Video } from '@/types/favorites-section';
|
||||||
|
|
||||||
import BookTile from './book-tile.astro';
|
import BookTile from './book-tile.astro';
|
||||||
|
|
@ -64,7 +64,7 @@ const videosSubsection: FavoritesSubsection<Video> = {
|
||||||
|
|
||||||
const subsections = [booksSubsection, peopleSubsection, videosSubsection, mediasSubsection];
|
const subsections = [booksSubsection, peopleSubsection, videosSubsection, mediasSubsection];
|
||||||
|
|
||||||
const section: Section = 'favorites';
|
const section: SectionKey = 'favorites';
|
||||||
---
|
---
|
||||||
|
|
||||||
<SectionCard section={section} title={title}>
|
<SectionCard section={section} title={title}>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import IconButton from '@/components/icon-button.astro';
|
||||||
import SectionCard from '@/components/section-card.astro';
|
import SectionCard from '@/components/section-card.astro';
|
||||||
import TagsList from '@/components/tags-list.astro';
|
import TagsList from '@/components/tags-list.astro';
|
||||||
import Typography from '@/components/typography.astro';
|
import Typography from '@/components/typography.astro';
|
||||||
import type { Section } from '@/types/data';
|
import type { SectionKey } from '@/types/data';
|
||||||
import type { MainSection } from '@/types/main-section';
|
import type { MainSection } from '@/types/main-section';
|
||||||
|
|
||||||
export interface Props extends MainSection {}
|
export interface Props extends MainSection {}
|
||||||
|
|
@ -22,7 +22,7 @@ const {
|
||||||
tags,
|
tags,
|
||||||
} = Astro.props;
|
} = Astro.props;
|
||||||
|
|
||||||
const section: Section = 'main';
|
const section: SectionKey = 'main';
|
||||||
---
|
---
|
||||||
|
|
||||||
<SectionCard section={section}>
|
<SectionCard section={section}>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import DividedList from '@/components/divided-list.astro';
|
import DividedList from '@/components/divided-list.astro';
|
||||||
import Divider from '@/components/divider.astro';
|
import Divider from '@/components/divider.astro';
|
||||||
import SectionCard from '@/components/section-card.astro';
|
import SectionCard from '@/components/section-card.astro';
|
||||||
import type { Section } from '@/types/data';
|
import type { SectionKey } from '@/types/data';
|
||||||
import type { I18n } from '@/types/i18n';
|
import type { I18n } from '@/types/i18n';
|
||||||
import type { PortfolioSection } from '@/types/portfolio-section';
|
import type { PortfolioSection } from '@/types/portfolio-section';
|
||||||
import removeLast from '@/utils/remove-last';
|
import removeLast from '@/utils/remove-last';
|
||||||
|
|
@ -19,7 +19,7 @@ const {
|
||||||
i18n,
|
i18n,
|
||||||
} = Astro.props;
|
} = Astro.props;
|
||||||
|
|
||||||
const section: Section = 'portfolio';
|
const section: SectionKey = 'portfolio';
|
||||||
---
|
---
|
||||||
|
|
||||||
<SectionCard section={section} title={title}>
|
<SectionCard section={section} title={title}>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
---
|
---
|
||||||
import IconWithTooltip from '@/components/icon-with-tooltip';
|
import IconWithTooltip from '@/components/icon-with-tooltip.svelte';
|
||||||
import Typography from '@/components/typography.astro';
|
import Typography from '@/components/typography.astro';
|
||||||
import type { IconName } from '@/types/icon';
|
import type { IconName } from '@/types/icon';
|
||||||
import type { LevelledSkill } from '@/types/skills-section';
|
import type { LevelledSkill } from '@/types/skills-section';
|
||||||
|
|
||||||
import Icon from '../../components/icon';
|
import Icon from '../../components/icon.svelte';
|
||||||
import SkillLevel from './skill-level.astro';
|
import SkillLevel from './skill-level.astro';
|
||||||
|
|
||||||
export interface Props extends LevelledSkill {}
|
export interface Props extends LevelledSkill {}
|
||||||
|
|
@ -17,7 +17,7 @@ const IconWrapper = url ? 'a' : 'div';
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<div class="flex h-5 items-center justify-between">
|
<div class="flex h-5 items-center justify-between">
|
||||||
<IconWrapper class="flex gap-2 h-5" {...(url && { href: url, target: '_blank', rel: 'noopener noreferrer' })}>
|
<IconWrapper class="flex gap-2 h-5" {...(url && { href: url, target: '_blank', rel: 'noopener noreferrer' })}>
|
||||||
<Icon client:load name={icon} color={iconColor} size={20} />
|
{icon && <Icon client:load name={icon} color={iconColor} size={20} />}
|
||||||
<Typography variant="tile-subtitle">
|
<Typography variant="tile-subtitle">
|
||||||
<span class="text-gray-700 dark:text-gray-300">{name}</span>
|
<span class="text-gray-700 dark:text-gray-300">{name}</span>
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
@ -25,7 +25,7 @@ const IconWrapper = url ? 'a' : 'div';
|
||||||
{
|
{
|
||||||
description && (
|
description && (
|
||||||
<IconWithTooltip
|
<IconWithTooltip
|
||||||
client:only="react"
|
client:load
|
||||||
name={'akar-icons:info-fill' as IconName}
|
name={'akar-icons:info-fill' as IconName}
|
||||||
color="#D1D5DB"
|
color="#D1D5DB"
|
||||||
size={14}
|
size={14}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
---
|
---
|
||||||
import SectionCard from '@/components/section-card.astro';
|
import SectionCard from '@/components/section-card.astro';
|
||||||
import type { Section } from '@/types/data';
|
import type { SectionKey } from '@/types/data';
|
||||||
import type { SkillsSection } from '@/types/skills-section';
|
import type { SkillsSection } from '@/types/skills-section';
|
||||||
|
|
||||||
import SkillSubsection from './skill-subsection.astro';
|
import SkillSubsection from './skill-subsection.astro';
|
||||||
|
|
@ -12,7 +12,7 @@ const {
|
||||||
skillSets,
|
skillSets,
|
||||||
} = Astro.props;
|
} = Astro.props;
|
||||||
|
|
||||||
const section: Section = 'skills';
|
const section: SectionKey = 'skills';
|
||||||
---
|
---
|
||||||
|
|
||||||
<SectionCard section={section} title={title}>
|
<SectionCard section={section} title={title}>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import DividedList from '@/components/divided-list.astro';
|
import DividedList from '@/components/divided-list.astro';
|
||||||
import Divider from '@/components/divider.astro';
|
import Divider from '@/components/divider.astro';
|
||||||
import SectionCard from '@/components/section-card.astro';
|
import SectionCard from '@/components/section-card.astro';
|
||||||
import type { Section } from '@/types/data';
|
import type { SectionKey } from '@/types/data';
|
||||||
import type { TestimonialsSection } from '@/types/testimonials-section';
|
import type { TestimonialsSection } from '@/types/testimonials-section';
|
||||||
import removeLast from '@/utils/remove-last';
|
import removeLast from '@/utils/remove-last';
|
||||||
|
|
||||||
|
|
@ -15,7 +15,7 @@ const {
|
||||||
config: { title },
|
config: { title },
|
||||||
} = Astro.props;
|
} = Astro.props;
|
||||||
|
|
||||||
const section: Section = 'testimonials';
|
const section: SectionKey = 'testimonials';
|
||||||
---
|
---
|
||||||
|
|
||||||
<SectionCard section={section} title={title}>
|
<SectionCard section={section} title={title}>
|
||||||
|
|
|
||||||
|
|
@ -18,4 +18,4 @@ export interface Data {
|
||||||
favorites?: FavoritesSection;
|
favorites?: FavoritesSection;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Section = Exclude<keyof Data, 'seo'>;
|
export type SectionKey = Exclude<keyof Data, 'seo' | 'i18n'>;
|
||||||
|
|
|
||||||
3
src/utils/env.ts
Normal file
3
src/utils/env.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export const isServer = typeof window === 'undefined';
|
||||||
|
|
||||||
|
export const isClient = !isServer;
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
const getObjectKeys = Object.keys as <T extends Record<string, unknown>>(obj: T) => Array<keyof T>;
|
|
||||||
|
|
||||||
export default getObjectKeys;
|
|
||||||
33
src/utils/hash-state.ts
Normal file
33
src/utils/hash-state.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { isServer } from './env';
|
||||||
|
|
||||||
|
const getInitialHash = () => {
|
||||||
|
if (isServer) return '';
|
||||||
|
|
||||||
|
return window.location.hash || '#main';
|
||||||
|
};
|
||||||
|
|
||||||
|
const createHashState = () => {
|
||||||
|
let hash = getInitialHash();
|
||||||
|
|
||||||
|
const subscribers: ((hash: string) => void)[] = [];
|
||||||
|
|
||||||
|
const subscribe = (callback: (hash: string) => void) => {
|
||||||
|
subscribers.push(callback);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getHash = () => hash;
|
||||||
|
|
||||||
|
const updateHash = (value: string) => {
|
||||||
|
const newHash = value.includes('#') ? value : `#${value}`;
|
||||||
|
|
||||||
|
if (newHash !== hash) {
|
||||||
|
hash = newHash.includes('#') ? newHash : `#${newHash}`;
|
||||||
|
window.history.replaceState(null, '', `${window.location.pathname}${hash}`);
|
||||||
|
subscribers.forEach((callback) => callback(hash));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { getHash, subscribe, updateHash };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default createHashState();
|
||||||
14
src/utils/is-section-key.ts
Normal file
14
src/utils/is-section-key.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import type { SectionKey } from '@/types/data';
|
||||||
|
|
||||||
|
const sectionsMap: Record<SectionKey, SectionKey> = {
|
||||||
|
main: 'main',
|
||||||
|
skills: 'skills',
|
||||||
|
experience: 'experience',
|
||||||
|
portfolio: 'portfolio',
|
||||||
|
testimonials: 'testimonials',
|
||||||
|
favorites: 'favorites',
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSectionKey = (key: string): key is SectionKey => Object.keys(sectionsMap).includes(key as SectionKey);
|
||||||
|
|
||||||
|
export default isSectionKey;
|
||||||
24
src/utils/throttle.ts
Normal file
24
src/utils/throttle.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
const throttle = (fn: () => void, wait: number) => {
|
||||||
|
let initialized: boolean;
|
||||||
|
let timeoutId: NodeJS.Timeout;
|
||||||
|
let lastCalled: number;
|
||||||
|
|
||||||
|
const isPastTimeout = () => Date.now() - lastCalled >= wait;
|
||||||
|
|
||||||
|
const getTimeout = () => Math.max(wait - (Date.now() - lastCalled), 0);
|
||||||
|
|
||||||
|
const call = () => {
|
||||||
|
fn();
|
||||||
|
initialized ??= true;
|
||||||
|
lastCalled = Date.now();
|
||||||
|
};
|
||||||
|
|
||||||
|
const recall = () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
timeoutId = setTimeout(() => isPastTimeout() && call(), getTimeout());
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => (initialized ? recall() : call());
|
||||||
|
};
|
||||||
|
|
||||||
|
export default throttle;
|
||||||
Loading…
Reference in a new issue