Migrate from react to svelte (#147)

This commit is contained in:
Konrad Szwarc 2023-01-16 21:46:10 +01:00 committed by GitHub
parent cca185e075
commit 41a516c283
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 1042 additions and 1118 deletions

1
.gitignore vendored
View file

@ -6,3 +6,4 @@ node_modules
# Build output
dist
stats.html

12
.vscode/settings.json vendored
View file

@ -5,14 +5,22 @@
"emmet.includeLanguages": { "javascript": "javascriptreact", "astro": "javascriptreact" },
"eslint.validate": ["javascript", "javascriptreact", "astro", "typescript", "typescriptreact"],
"files.eol": "\n",
"prettier.documentSelectors": ["**/*.astro"],
"svelte.enable-ts-plugin": true,
"tailwindCSS.classAttributes": ["class", "className", "class:list"],
"tailwindCSS.experimental.classRegex": [["/\\* tw \\*/ ([^;]*);", "'([^']*)'"]],
"tailwindCSS.includeLanguages": {
"javascript": "javascriptreact",
"astro": "javascriptreact",
"svelte": "javascriptreact"
},
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"[astro]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
"[ignore]": { "editor.defaultFormatter": "foxundermoon.shell-format" },
"[json]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
"[javascript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
"[svelte]": { "editor.defaultFormatter": "svelte.svelte-vscode" },
"[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
"[typescriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
"prettier.documentSelectors": ["**/*.astro"]
"[typescriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }
}

View file

@ -1,10 +1,13 @@
import image from '@astrojs/image';
import react from '@astrojs/react';
import svelte from '@astrojs/svelte';
import tailwind from '@astrojs/tailwind';
import { defineConfig } from 'astro/config';
import { visualizer } from 'rollup-plugin-visualizer';
// https://astro.build/config
export default defineConfig({
integrations: [react(), tailwind(), image()],
vite: { ssr: { external: ['svgo'] } },
integrations: [tailwind(), image(), svelte()],
vite: {
plugins: [visualizer()],
},
});

1536
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -22,11 +22,11 @@
},
"dependencies": {
"@astrojs/image": "0.12.1",
"@iconify-icon/react": "1.0.2",
"@tippyjs/react": "4.2.6",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-use": "17.4.0"
"@astrojs/svelte": "1.0.2",
"@floating-ui/dom": "1.1.0",
"iconify-icon": "1.0.2",
"rollup-plugin-visualizer": "5.9.0",
"svelte": "3.55.1"
},
"devDependencies": {
"@astrojs/react": "1.2.2",

View file

@ -1,7 +1,7 @@
---
import type { IconName } from '@/types/icon';
import Icon from './icon';
import Icon from './icon.svelte';
type IconButtonSize = 'small' | 'large';

View 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>

View file

@ -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;

View 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}

View file

@ -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;

View file

@ -1,17 +1,22 @@
---
import type { Section } from '@/types/data';
import type { SectionKey } from '@/types/data';
import Typography from './typography.astro';
export interface Props {
section: Section;
section: SectionKey;
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 && (
<Typography variant="section-title" id={`${section}-heading`}>
@ -21,3 +26,22 @@ const { section, title } = Astro.props;
}
<slot />
</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>

View 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>

View file

@ -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;

View file

@ -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 {
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]}>
<slot />
<nav
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>

View file

@ -1,7 +1,7 @@
---
import type { IconName } from '@/types/icon';
import Icon from './icon';
import Icon from './icon.svelte';
export interface Props {
name?: IconName;
@ -14,6 +14,6 @@ const { name, color } = Astro.props;
<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"
>
<Icon client:load name={name} color={color} size={16} />
{name && <Icon client:load name={name} color={color} size={16} />}
<slot />
</div>

View file

@ -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;

View 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>

View 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>

View file

@ -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;

View file

@ -25,8 +25,6 @@ export interface Data {
favorites?: FavoritesSection;
}
export type Section = Exclude<keyof Data, 'seo'>;
const data: Data = {
i18n: {
locale: 'en-US',

View file

@ -1,5 +1,5 @@
---
import Icon from '@/components/icon';
import Icon from '@/components/icon.svelte';
---
<div class="p-5">

View file

@ -1,7 +1,7 @@
---
import SidebarItem from '@/components/sidebar-item';
import SidebarItem from '@/components/sidebar-item.svelte';
---
<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>

View file

@ -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>

View file

@ -1,18 +1,16 @@
---
import Sidebar from '@/components/sidebar.astro';
import SidebarItem from '@/components/sidebar-item';
import ThemeToggle from '@/components/theme-icon';
import ThemeToggle from '@/components/theme-toggle.svelte';
import ExperienceSection from '@/sections/experience/experience-section.astro';
import FavoritesSection from '@/sections/favorites/favorites-section.astro';
import MainSection from '@/sections/main/main-section.astro';
import PortfolioSection from '@/sections/portfolio/portfolio-section.astro';
import SkillsSection from '@/sections/skills/skills-section.astro';
import TestimonialsSection from '@/sections/testimonials/testimonials-section.astro';
import getObjectKeys from '@/utils/getObjectKeys';
import data from '../data';
const { seo, i18n, ...dataWithoutSeoAndI18n } = data;
const { seo, i18n } = data;
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:description" content={seo.description} />
<meta property="og:image" content={seoImage} />
</head>
<body class="flex justify-center bg-gray-50 dark:bg-gray-900">
<ThemeToggle client:only="react" />
<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">
<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}
/>
)
);
})
<script is:inline>
const theme = (() => {
if (typeof localStorage !== 'undefined' && localStorage.getItem('theme')) {
return localStorage.getItem('theme');
}
</Sidebar>
</div>
<main class="relative w-full space-y-4 sm:space-y-6 lg:space-y-8">
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
})();
if (theme === 'light') {
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={data.i18n} jobs={data.experience.jobs} config={data.experience.config} />
)
}
{data.portfolio && <PortfolioSection i18n={data.i18n} {...data.portfolio} />}
{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>
</div>
<Sidebar data={data} className="sticky top-8 mt-20" />
</body>
<script>
import updateHash from '../scripts/updateHash';
import data from '../data';
document.addEventListener('scroll', () => updateHash(data));
</script>
</html>

View file

@ -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;

View file

@ -2,7 +2,7 @@
import DividedList from '@/components/divided-list.astro';
import Divider from '@/components/divider.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 { I18n } from '@/types/i18n';
import removeLast from '@/utils/remove-last';
@ -20,7 +20,7 @@ const {
jobs,
} = Astro.props;
const section: Section = 'experience';
const section: SectionKey = 'experience';
---
<SectionCard section={section} title={title}>

View file

@ -3,7 +3,7 @@ import type { ComponentInstance } from 'astro';
import SectionCard from '@/components/section-card.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 BookTile from './book-tile.astro';
@ -64,7 +64,7 @@ const videosSubsection: FavoritesSubsection<Video> = {
const subsections = [booksSubsection, peopleSubsection, videosSubsection, mediasSubsection];
const section: Section = 'favorites';
const section: SectionKey = 'favorites';
---
<SectionCard section={section} title={title}>

View file

@ -6,7 +6,7 @@ import IconButton from '@/components/icon-button.astro';
import SectionCard from '@/components/section-card.astro';
import TagsList from '@/components/tags-list.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';
export interface Props extends MainSection {}
@ -22,7 +22,7 @@ const {
tags,
} = Astro.props;
const section: Section = 'main';
const section: SectionKey = 'main';
---
<SectionCard section={section}>

View file

@ -2,7 +2,7 @@
import DividedList from '@/components/divided-list.astro';
import Divider from '@/components/divider.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 { PortfolioSection } from '@/types/portfolio-section';
import removeLast from '@/utils/remove-last';
@ -19,7 +19,7 @@ const {
i18n,
} = Astro.props;
const section: Section = 'portfolio';
const section: SectionKey = 'portfolio';
---
<SectionCard section={section} title={title}>

View file

@ -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 type { IconName } from '@/types/icon';
import type { LevelledSkill } from '@/types/skills-section';
import Icon from '../../components/icon';
import Icon from '../../components/icon.svelte';
import SkillLevel from './skill-level.astro';
export interface Props extends LevelledSkill {}
@ -17,7 +17,7 @@ const IconWrapper = url ? 'a' : 'div';
<div class="flex flex-col gap-2">
<div class="flex h-5 items-center justify-between">
<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">
<span class="text-gray-700 dark:text-gray-300">{name}</span>
</Typography>
@ -25,7 +25,7 @@ const IconWrapper = url ? 'a' : 'div';
{
description && (
<IconWithTooltip
client:only="react"
client:load
name={'akar-icons:info-fill' as IconName}
color="#D1D5DB"
size={14}

View file

@ -1,6 +1,6 @@
---
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 SkillSubsection from './skill-subsection.astro';
@ -12,7 +12,7 @@ const {
skillSets,
} = Astro.props;
const section: Section = 'skills';
const section: SectionKey = 'skills';
---
<SectionCard section={section} title={title}>

View file

@ -2,7 +2,7 @@
import DividedList from '@/components/divided-list.astro';
import Divider from '@/components/divider.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 removeLast from '@/utils/remove-last';
@ -15,7 +15,7 @@ const {
config: { title },
} = Astro.props;
const section: Section = 'testimonials';
const section: SectionKey = 'testimonials';
---
<SectionCard section={section} title={title}>

View file

@ -18,4 +18,4 @@ export interface Data {
favorites?: FavoritesSection;
}
export type Section = Exclude<keyof Data, 'seo'>;
export type SectionKey = Exclude<keyof Data, 'seo' | 'i18n'>;

3
src/utils/env.ts Normal file
View file

@ -0,0 +1,3 @@
export const isServer = typeof window === 'undefined';
export const isClient = !isServer;

View file

@ -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
View 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();

View 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
View 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;