feat: implement barebone zola templates
This commit is contained in:
parent
9c20f5ed2e
commit
f99a9ae2ac
198 changed files with 2434 additions and 227991 deletions
52
_src/components/Button/Button.astro
Normal file
52
_src/components/Button/Button.astro
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
---
|
||||
interface Props {
|
||||
className?: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
const { className = '', href } = Astro.props;
|
||||
---
|
||||
|
||||
{
|
||||
href !== undefined ? (
|
||||
<a href={href} class:list={['clean', 'btn', className]}>
|
||||
<slot />
|
||||
</a>
|
||||
) : (
|
||||
<button class:list={className}>
|
||||
<slot />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
<style lang="scss">
|
||||
button,
|
||||
.btn {
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
font-size: 1rem;
|
||||
padding: var(--prj-spacing-1) var(--prj-spacing-3);
|
||||
background-color: var(--prj-accent-bg);
|
||||
color: var(--prj-accent-text);
|
||||
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--prj-accent-bg);
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
margin-bottom: 0;
|
||||
box-shadow: 0 0 0px 0px var(--prj-accent-bg);
|
||||
|
||||
transition: color 0.2s, background-color 0.2s, translate 0.2s,
|
||||
box-shadow 0.2s;
|
||||
|
||||
&:hover {
|
||||
--anim-translation-value: -5px;
|
||||
background-color: transparent;
|
||||
color: var(--prj-text);
|
||||
translate: var(--anim-translation-value) var(--anim-translation-value);
|
||||
box-shadow: calc(var(--anim-translation-value) * -2)
|
||||
calc(var(--anim-translation-value) * -2) 0px 0px var(--prj-accent-bg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
57
_src/components/Card.astro
Normal file
57
_src/components/Card.astro
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
---
|
||||
export interface Props {
|
||||
title?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const { className } = Astro.props;
|
||||
---
|
||||
|
||||
<div class:list={['card', 'vstack', className]}>
|
||||
<div class="img-header">
|
||||
<slot name="img-header" />
|
||||
</div>
|
||||
<div class="title">
|
||||
<slot name="title" />
|
||||
</div>
|
||||
|
||||
<div class="content flex-grow">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.card {
|
||||
background-color: var(--prj-surface-2);
|
||||
color: var(--prj-surface-text);
|
||||
border: 1px solid var(--prj-surface-2);
|
||||
border-radius: var(--prj-border-radius);
|
||||
box-shadow: 5px 5px 5px 5px var(--prj-shadow);
|
||||
|
||||
padding: var(--prj-spacing-2) var(--prj-spacing-3);
|
||||
|
||||
:global(a) {
|
||||
text-decoration-line: none;
|
||||
}
|
||||
|
||||
:global(a):hover {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.img-header {
|
||||
:global(img) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.title > :global(:last-child) {
|
||||
margin-bottom: var(--prj-spacing-2);
|
||||
}
|
||||
</style>
|
||||
49
_src/components/Carousel/Carousel.module.css
Normal file
49
_src/components/Carousel/Carousel.module.css
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
.carousel {
|
||||
height: 100%;
|
||||
max-width: 90%;
|
||||
margin: auto;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.container {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
transition: transform 0.6s ease;
|
||||
}
|
||||
|
||||
.item {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
flex: 0 0 100%;
|
||||
}
|
||||
|
||||
.itemContent {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btnPrev,
|
||||
.btnNext {
|
||||
top: 50%;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.btnPrev {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.btnNext {
|
||||
right: 0;
|
||||
}
|
||||
66
_src/components/Carousel/Carousel.tsx
Normal file
66
_src/components/Carousel/Carousel.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import React, { Children, useEffect, useRef, useState } from 'react';
|
||||
import classes from './Carousel.module.css';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function Carousel({ children }: Props): JSX.Element {
|
||||
const [activeItem, setActiveItem] = useState(0);
|
||||
const content = useRef<HTMLDivElement>(null);
|
||||
const maxItems = Children.count(children) - 1;
|
||||
|
||||
useEffect(() => {
|
||||
if (content.current == null) return;
|
||||
|
||||
const offset = content.current.clientWidth * activeItem;
|
||||
// const offset = 100 * activeItem;
|
||||
//
|
||||
console.log(offset, activeItem, content.current.clientWidth);
|
||||
|
||||
content.current.style.transform = `translate3d(-${offset}px, 0px, 0px)`;
|
||||
}, [activeItem]);
|
||||
|
||||
const offsetActiveItem = (offset: number): void => {
|
||||
setActiveItem((prev) => {
|
||||
const newActiveItem = prev + offset;
|
||||
|
||||
// Wrap on end in both sides
|
||||
if (newActiveItem < 0) {
|
||||
return maxItems;
|
||||
}
|
||||
|
||||
if (newActiveItem > maxItems) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return newActiveItem;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.carousel}>
|
||||
<button
|
||||
className={classes.btnPrev}
|
||||
onClick={() => {
|
||||
offsetActiveItem(-1);
|
||||
}}
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<div className={classes.container}>
|
||||
<div ref={content} className={classes.content}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className={classes.btnNext}
|
||||
onClick={() => {
|
||||
offsetActiveItem(1);
|
||||
}}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
_src/components/Carousel/CarouselItem.tsx
Normal file
15
_src/components/Carousel/CarouselItem.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import React from 'react';
|
||||
|
||||
import classes from './Carousel.module.css';
|
||||
|
||||
interface Props {
|
||||
children: JSX.Element | JSX.Element[];
|
||||
}
|
||||
|
||||
export default function CarouselItem({ children }: Props): JSX.Element {
|
||||
return (
|
||||
<div className={classes.item}>
|
||||
<div className={classes.itemContent}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
_src/components/Inputs/SelectInput.module.css
Normal file
69
_src/components/Inputs/SelectInput.module.css
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
.wrapper {
|
||||
--bg-color: var(--prj-input);
|
||||
--text-color: var(--prj-input-text);
|
||||
|
||||
position: relative;
|
||||
padding: var(--prj-spacing-1);
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
|
||||
display: flex;
|
||||
gap: var(--prj-spacing-1);
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
gap: var(--prj-spacing-1);
|
||||
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.selectedItem {
|
||||
background-color: var(--prj-surface-3);
|
||||
color: var(--prj-text);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.selectedItem > * {
|
||||
padding: var(--prj-spacing-1);
|
||||
}
|
||||
|
||||
.deleteItem:hover {
|
||||
background-color: var(--prj-danger);
|
||||
}
|
||||
|
||||
.optionList {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 120%;
|
||||
|
||||
width: 100%;
|
||||
|
||||
padding: var(--prj-spacing-1);
|
||||
text-align: start;
|
||||
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.optionItem {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
text-align: start;
|
||||
padding: var(--prj-spacing-1);
|
||||
}
|
||||
|
||||
.optionItem:disabled {
|
||||
color: var(--prj-disabled-text);
|
||||
}
|
||||
|
||||
.optionItem:not(:first-child) {
|
||||
margin-top: var(--prj-spacing-1);
|
||||
}
|
||||
|
||||
.optionItem:not(:disabled):hover {
|
||||
background-color: var(--prj-accent-bg);
|
||||
}
|
||||
142
_src/components/Inputs/SelectInput.tsx
Normal file
142
_src/components/Inputs/SelectInput.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import styles from './SelectInput.module.css';
|
||||
|
||||
interface Option {
|
||||
label: string;
|
||||
value: any;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onChange: (value: string | string[] | null) => void;
|
||||
options: Option[];
|
||||
isMultiple?: boolean;
|
||||
value?: string | string[];
|
||||
}
|
||||
|
||||
export default function SelectInput({
|
||||
options,
|
||||
isMultiple = false,
|
||||
onChange,
|
||||
value = [],
|
||||
}: Props): JSX.Element {
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [filteredOptions, setFilteredOptions] = useState<Props['options']>([]);
|
||||
const [isOptionsOpen, setIsOptionsOpen] = useState<boolean>(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setFilteredOptions(options);
|
||||
}, [options]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selected.length === 0) {
|
||||
onChange(null);
|
||||
return;
|
||||
}
|
||||
|
||||
onChange(isMultiple ? selected : selected[0]);
|
||||
}, [selected]);
|
||||
|
||||
const handleFocusInput = (): void => {
|
||||
setIsOptionsOpen(true);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleAddElement = (item: any): void => {
|
||||
setSelected((prev) => {
|
||||
if (isMultiple) {
|
||||
return [...prev, item];
|
||||
}
|
||||
return [item];
|
||||
});
|
||||
setIsOptionsOpen(false);
|
||||
|
||||
if (inputRef.current === null) return;
|
||||
|
||||
inputRef.current.value = '';
|
||||
};
|
||||
|
||||
const handleRemoveElement = (idx: number): void => {
|
||||
setIsOptionsOpen(false);
|
||||
setSelected((prev) => {
|
||||
prev.splice(idx, 1);
|
||||
|
||||
return [...prev];
|
||||
});
|
||||
};
|
||||
|
||||
const handleLooseFocus = (e: React.FocusEvent<HTMLDivElement>): void => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget)) {
|
||||
// Not triggered when swapping focus between children
|
||||
setIsOptionsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFilterOptions = ({
|
||||
target,
|
||||
}: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
if (target.value === '') {
|
||||
setFilteredOptions(options);
|
||||
return;
|
||||
}
|
||||
|
||||
const newOptions = options.filter(
|
||||
(item) =>
|
||||
item.label.toLowerCase().search(target.value.toLowerCase()) !== -1,
|
||||
);
|
||||
setFilteredOptions(newOptions);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper} onBlur={handleLooseFocus}>
|
||||
<div
|
||||
className={styles.input}
|
||||
onClick={() => {
|
||||
handleFocusInput();
|
||||
}}
|
||||
>
|
||||
{selected.map((item, idx) => (
|
||||
<div className={styles.selectedItem + ' hstack'} key={idx}>
|
||||
<div>{item}</div>
|
||||
<div
|
||||
className={styles.deleteItem}
|
||||
onClick={() => {
|
||||
handleRemoveElement(idx);
|
||||
}}
|
||||
>
|
||||
{'X'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<input
|
||||
ref={inputRef}
|
||||
className={styles.realInput}
|
||||
type="text"
|
||||
onChange={handleFilterOptions}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
onChange(null);
|
||||
setSelected([]);
|
||||
}}
|
||||
>
|
||||
X
|
||||
</button>
|
||||
<div className={styles.optionList} hidden={!isOptionsOpen}>
|
||||
{filteredOptions.map((item, idx) => (
|
||||
<button
|
||||
className={styles.optionItem}
|
||||
key={idx}
|
||||
disabled={selected.includes(item.value)}
|
||||
onClick={() => {
|
||||
handleAddElement(item.value);
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
0
_src/components/Inputs/Types.ts
Normal file
0
_src/components/Inputs/Types.ts
Normal file
16
_src/components/LangSelector.astro
Normal file
16
_src/components/LangSelector.astro
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
---
|
||||
import { LanguageSelector } from 'astro-i18next/components';
|
||||
---
|
||||
|
||||
<LanguageSelector showFlag={false} class="selector" />
|
||||
|
||||
<style lang="scss">
|
||||
.selector {
|
||||
padding: var(--prj-spacing-1);
|
||||
border: 1px solid var(--prj-input);
|
||||
color: var(--prj-input-text);
|
||||
background-color: var(--prj-input);
|
||||
font-size: 0.9rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
31
_src/components/LocalizedMarkdown.astro
Normal file
31
_src/components/LocalizedMarkdown.astro
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
import i18next from 'i18next';
|
||||
import config from '../../astro-i18next.config.mjs';
|
||||
|
||||
export interface Props {
|
||||
path: string;
|
||||
}
|
||||
|
||||
const { path } = Astro.props;
|
||||
|
||||
const pages = await Astro.glob('../../public/locales/**/*.md');
|
||||
let markdown;
|
||||
|
||||
markdown = pages.find((page) =>
|
||||
page.file.includes(`locales/${i18next.language}/${path}`),
|
||||
);
|
||||
|
||||
if (!markdown) {
|
||||
markdown = pages.find((page) =>
|
||||
page.file.includes(`locales/${config.defaultLocale}/${path}`),
|
||||
);
|
||||
}
|
||||
|
||||
if (!markdown) {
|
||||
throw Error(`The file: "${path}" was not found.`);
|
||||
}
|
||||
|
||||
const { Content } = markdown;
|
||||
---
|
||||
|
||||
<Content />
|
||||
18
_src/components/MediaGallery/Gallery.module.css
Normal file
18
_src/components/MediaGallery/Gallery.module.css
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
.thumbnailList {
|
||||
display: grid;
|
||||
overflow-x: scroll;
|
||||
|
||||
gap: var(--prj-spacing-3);
|
||||
padding-bottom: var(--prj-spacing-2);
|
||||
|
||||
grid-auto-columns: 25%;
|
||||
grid-auto-flow: column;
|
||||
}
|
||||
|
||||
.thumbnailItem {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.thumbnailItem:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
29
_src/components/MediaGallery/Gallery.tsx
Normal file
29
_src/components/MediaGallery/Gallery.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import React from 'react';
|
||||
import { type Media } from './types';
|
||||
|
||||
import classes from './Gallery.module.css';
|
||||
import Carousel from '@components/Carousel/Carousel';
|
||||
import CarouselItem from '@components/Carousel/CarouselItem';
|
||||
|
||||
interface Props {
|
||||
items: Media[];
|
||||
height: number;
|
||||
}
|
||||
|
||||
export default function Gallery({ items, height = 500 }: Props): JSX.Element {
|
||||
return (
|
||||
<div style={{ height }}>
|
||||
<Carousel>
|
||||
{items.map((item, idx) => (
|
||||
<CarouselItem key={idx}>
|
||||
<img
|
||||
className="respect-height"
|
||||
src={item.thumbnail ?? item.url}
|
||||
alt={item.alt}
|
||||
/>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</Carousel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
_src/components/MediaGallery/types.ts
Normal file
12
_src/components/MediaGallery/types.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
export enum MediaType {
|
||||
Image = 'image',
|
||||
Video = 'video',
|
||||
}
|
||||
|
||||
export interface Media {
|
||||
type: MediaType;
|
||||
url: string;
|
||||
alt: string;
|
||||
mime?: string;
|
||||
thumbnail?: string;
|
||||
}
|
||||
124
_src/components/Navbar.astro
Normal file
124
_src/components/Navbar.astro
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
---
|
||||
import { localizePath } from 'astro-i18next';
|
||||
import OffCanvas from '@components/OffCanvas/OffCanvas.astro';
|
||||
import OffCanvasBtn from '@components/OffCanvas/OffCanvasBtn.astro';
|
||||
import LangSelector from '@components/LangSelector.astro';
|
||||
|
||||
const links = [
|
||||
{ href: localizePath('/'), text: 'Home' },
|
||||
{ href: localizePath('/blog'), text: 'Blog' },
|
||||
{ href: localizePath('/projects'), text: 'Projects' },
|
||||
{ href: localizePath('/curriculum'), text: 'Curriculum' },
|
||||
{ href: localizePath('/contact'), text: 'Contact' },
|
||||
];
|
||||
---
|
||||
|
||||
<div id="main-navbar" class="pt-1">
|
||||
<nav class="navbar navbar-desktop d-none d-lg-block container">
|
||||
<ul class="list-unstyle hstack">
|
||||
{
|
||||
links.map((link) => (
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href={link.href}>
|
||||
{link.text}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
<li class="nav-item">
|
||||
<LangSelector />
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div class="text-end d-lg-none">
|
||||
<OffCanvasBtn />
|
||||
<OffCanvas>
|
||||
<nav class="navbar navbar-mobile">
|
||||
<ul class="list-unstyle text-start">
|
||||
{
|
||||
links.map((link) => (
|
||||
<li class="nav-item mb-3">
|
||||
<a class="nav-link" href={link.href}>
|
||||
{link.text}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
<li class="nav-item mb-3">
|
||||
<LangSelector />
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</OffCanvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const setActiveLink = () => {
|
||||
const links =
|
||||
document.querySelectorAll<HTMLAnchorElement>(`#main-navbar a`);
|
||||
|
||||
links.forEach((link) => {
|
||||
if (link.pathname === '/' && location.pathname === '/') {
|
||||
link.classList.add('active');
|
||||
return;
|
||||
}
|
||||
|
||||
if (link.pathname === '/' && location.pathname !== '/') {
|
||||
link.classList.remove('active');
|
||||
return;
|
||||
}
|
||||
|
||||
location.pathname.startsWith(link.pathname)
|
||||
? link.classList.add('active')
|
||||
: link.classList.remove('active');
|
||||
});
|
||||
};
|
||||
// Add active class to the current link
|
||||
document.addEventListener('astro:page-load', setActiveLink, { once: true });
|
||||
document.addEventListener('astro:after-swap', setActiveLink);
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
nav {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.navbar-desktop ul {
|
||||
width: fit-content;
|
||||
margin-left: auto;
|
||||
|
||||
.nav-item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
li > a {
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
a {
|
||||
--boder-color: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
|
||||
transition: background-color 200ms, color 200ms;
|
||||
}
|
||||
|
||||
a.active {
|
||||
border: 1px solid var(--prj-accent-bg);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
--border-color: var(--prj-accent-bg);
|
||||
background-color: var(--prj-accent-bg);
|
||||
color: var(--prj-accent-text);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
</style>
|
||||
107
_src/components/OffCanvas/OffCanvas.astro
Normal file
107
_src/components/OffCanvas/OffCanvas.astro
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
---
|
||||
const { isOpen } = Astro.props;
|
||||
---
|
||||
|
||||
<div id="mobile-nav" class="off-canvas">
|
||||
<div class="off-canvas-content" transition:persist>
|
||||
<button class="off-canvas-toggle" data-target="#mobile-nav">
|
||||
<svg
|
||||
width="40px"
|
||||
height="40px"
|
||||
viewBox="0 0 1024 1024"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="#ffffff"
|
||||
><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g
|
||||
id="SVGRepo_tracerCarrier"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"
|
||||
><path
|
||||
fill="#cad3f5"
|
||||
d="M195.2 195.2a64 64 0 0 1 90.496 0L512 421.504 738.304 195.2a64 64 0 0 1 90.496 90.496L602.496 512 828.8 738.304a64 64 0 0 1-90.496 90.496L512 602.496 285.696 828.8a64 64 0 0 1-90.496-90.496L421.504 512 195.2 285.696a64 64 0 0 1 0-90.496z"
|
||||
></path></g
|
||||
></svg
|
||||
>
|
||||
</button>
|
||||
|
||||
<div class="content">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="off-canvas-backdrop off-canvas-toggle" data-target="#mobile-nav">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.off-canvas {
|
||||
.off-canvas-content {
|
||||
overflow: hidden;
|
||||
position: fixed;
|
||||
height: 100vh;
|
||||
z-index: 5;
|
||||
|
||||
background-color: var(--prj-bg);
|
||||
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 100%;
|
||||
|
||||
padding: var(--prj-spacing-3);
|
||||
|
||||
transition: left 0.4s ease-in-out;
|
||||
}
|
||||
|
||||
&.active .off-canvas-content {
|
||||
left: 50%;
|
||||
}
|
||||
|
||||
.off-canvas-backdrop {
|
||||
position: fixed;
|
||||
height: 100vh;
|
||||
z-index: 4;
|
||||
|
||||
background-color: rgba(0, 0, 0);
|
||||
opacity: 0;
|
||||
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 100%;
|
||||
|
||||
padding: var(--prj-spacing-3);
|
||||
|
||||
// Delay the left transition on remove so it's desn't appear to be sliding or to be not working
|
||||
transition: opacity 0.8s ease, left 0s linear 1s;
|
||||
}
|
||||
|
||||
&.active .off-canvas-backdrop {
|
||||
left: 0%;
|
||||
opacity: 40%;
|
||||
transition: opacity 0.8s ease, left 0s linear;
|
||||
}
|
||||
}
|
||||
|
||||
button.off-canvas-toggle {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
document.addEventListener('astro:page-load', () => {
|
||||
document
|
||||
.querySelectorAll<HTMLElement>('.off-canvas-toggle')
|
||||
.forEach((btn) =>
|
||||
btn.addEventListener('click', () => {
|
||||
const { target } = btn.dataset;
|
||||
|
||||
if (!target) return;
|
||||
|
||||
document.querySelector(target)?.classList.toggle('active');
|
||||
}),
|
||||
);
|
||||
});
|
||||
</script>
|
||||
53
_src/components/OffCanvas/OffCanvasBtn.astro
Normal file
53
_src/components/OffCanvas/OffCanvasBtn.astro
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<button id="btn-toggle" class="off-canvas-toggle" data-target="#mobile-nav">
|
||||
<svg
|
||||
width="40px"
|
||||
height="40px"
|
||||
viewBox="-2.4 -2.4 28.80 28.80"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
stroke=""
|
||||
stroke-width="0.00024000000000000003"
|
||||
transform="rotate(0)"
|
||||
><g
|
||||
id="SVGRepo_bgCarrier"
|
||||
stroke-width="0"
|
||||
transform="translate(1.1999999999999993,1.1999999999999993), scale(0.9)"
|
||||
><rect
|
||||
x="-2.4"
|
||||
y="-2.4"
|
||||
width="28.80"
|
||||
height="28.80"
|
||||
rx="2.88"
|
||||
fill="none"
|
||||
strokewidth="0"></rect></g
|
||||
><g
|
||||
id="SVGRepo_tracerCarrier"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke="#CCCCCC"
|
||||
stroke-width="0.384"></g><g id="SVGRepo_iconCarrier">
|
||||
<path
|
||||
d="M2 5.5C2 4.94772 2.44772 4.5 3 4.5H21C21.5523 4.5 22 4.94772 22 5.5V6.5C22 7.05228 21.5523 7.5 21 7.5H3C2.44772 7.5 2 7.05228 2 6.5V5.5Z"
|
||||
fill="#cad3f5"></path>
|
||||
<path
|
||||
d="M2 11.5C2 10.9477 2.44772 10.5 3 10.5H21C21.5523 10.5 22 10.9477 22 11.5V12.5C22 13.0523 21.5523 13.5 21 13.5H3C2.44772 13.5 2 13.0523 2 12.5V11.5Z"
|
||||
fill="#cad3f5"></path>
|
||||
<path
|
||||
d="M3 16.5C2.44772 16.5 2 16.9477 2 17.5V18.5C2 19.0523 2.44772 19.5 3 19.5H21C21.5523 19.5 22 19.0523 22 18.5V17.5C22 16.9477 21.5523 16.5 21 16.5H3Z"
|
||||
fill="#cad3f5"></path>
|
||||
</g></svg
|
||||
>
|
||||
|
||||
<span class="visually-hidden">Open sidebar</span>
|
||||
</button>
|
||||
|
||||
<style lang="scss">
|
||||
button.off-canvas-toggle {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
133
_src/components/Pagination.astro
Normal file
133
_src/components/Pagination.astro
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
---
|
||||
import { type Page } from 'astro';
|
||||
|
||||
interface Props {
|
||||
page: Page;
|
||||
paginationOffset?: number;
|
||||
urlPattern: string;
|
||||
}
|
||||
|
||||
const { page, urlPattern, paginationOffset = 3 } = Astro.props;
|
||||
|
||||
const pages = [];
|
||||
|
||||
const lowerEnd = Math.max(page.currentPage - paginationOffset, 1);
|
||||
const highEnd = Math.min(page.currentPage + paginationOffset, page.lastPage);
|
||||
|
||||
const generateUrl = (index: number) => {
|
||||
return urlPattern.replace('{}', index.toString());
|
||||
};
|
||||
|
||||
for (let index = lowerEnd; index <= highEnd; index++) {
|
||||
pages.push({
|
||||
index,
|
||||
url: generateUrl(index),
|
||||
});
|
||||
}
|
||||
---
|
||||
|
||||
<nav role="navigation" aria-label="Pagination" class="w-100 my-4">
|
||||
<ul class="list-unstyle hstack justify-content-center">
|
||||
{
|
||||
page.url.prev !== undefined && (
|
||||
<li>
|
||||
<a
|
||||
href={page.url.prev}
|
||||
class="prev-page"
|
||||
aria-label="Go to previous page"
|
||||
>
|
||||
« Prev
|
||||
</a>
|
||||
</li>
|
||||
)
|
||||
}{
|
||||
lowerEnd !== 1 && (
|
||||
<>
|
||||
<li>
|
||||
<a
|
||||
href={generateUrl(1)}
|
||||
class="prev-page"
|
||||
aria-label={`Go to page 1`}
|
||||
>
|
||||
1
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<span class="start-ellipsis">…</span>
|
||||
</li>
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
pages.map((item) => (
|
||||
<li>
|
||||
<a
|
||||
class:list={[{ current: item.index === page.currentPage }]}
|
||||
href={item.url}
|
||||
aria-label={`Go to page ${item.index}`}
|
||||
>
|
||||
{item.index}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
{
|
||||
highEnd !== page.lastPage && (
|
||||
<>
|
||||
<li>
|
||||
<span class="start-ellipsis">…</span>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href={generateUrl(page.lastPage)}
|
||||
class="next-page"
|
||||
aria-label={`Go to page ${page.lastPage}`}
|
||||
>
|
||||
{page.lastPage}
|
||||
</a>
|
||||
</li>
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
page.url.next !== undefined && (
|
||||
<li>
|
||||
<a
|
||||
href={page.url.next}
|
||||
class="next-page"
|
||||
aria-label="Go to next page"
|
||||
>
|
||||
Next »
|
||||
</a>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<style lang="scss">
|
||||
li {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
border: 1px solid var(--prj-link-text);
|
||||
padding: var(--prj-spacing-1) var(--prj-spacing-2);
|
||||
border-radius: var(--prj-border-radius);
|
||||
text-decoration: none;
|
||||
transition: background-color 400ms, color 400ms;
|
||||
|
||||
&.current {
|
||||
background-color: var(--prj-secondary);
|
||||
border: 1px solid var(--prj-secondary);
|
||||
color: var(--prj-secondary-text);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--prj-link-text);
|
||||
border: 1px solid var(--prj-link-text);
|
||||
color: var(--prj-accent-text);
|
||||
text-shadow: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
63
_src/components/Spinner.astro
Normal file
63
_src/components/Spinner.astro
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
---
|
||||
interface Props {
|
||||
size?: number;
|
||||
color?: string;
|
||||
bgColor?: string;
|
||||
}
|
||||
|
||||
const {size = 200, color= "#cad3f5", bgColor} = Astro.props;
|
||||
---
|
||||
|
||||
<div class="spinner">
|
||||
<div class="container">
|
||||
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="">
|
||||
<g id="SVGRepo_bgCarrier" stroke-width="0"></g>
|
||||
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g>
|
||||
<g id="SVGRepo_iconCarrier">
|
||||
<path class="animation animation-normal" d="M4 24C4 35.0457 12.9543 44 24 44V44C35.0457 44 44 35.0457 44 24C44 12.9543 35.0457 4 24 4" stroke={color} stroke-width="4" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
<path class="animation animation-reverse" d="M36 24C36 17.3726 30.6274 12 24 12C17.3726 12 12 17.3726 12 24C12 30.6274 17.3726 36 24 36V36" stroke={color} stroke-width="4" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<style define:vars={{size: `${size}px`, bgColor: bgColor ?? "var(--prj-bg)"}}>
|
||||
.spinner {
|
||||
background: var(--bgColor);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 10px 0px -10px 0px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: var(--size);
|
||||
}
|
||||
|
||||
.animation {
|
||||
animation: rotate 1.5s linear infinite;
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.animation-normal {
|
||||
animation-direction: normal;
|
||||
}
|
||||
|
||||
.animation-reverse {
|
||||
animation-direction: reverse;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
43
_src/components/Table/Filters/NumberFilter.tsx
Normal file
43
_src/components/Table/Filters/NumberFilter.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
interface Props {
|
||||
onChange: (value: [string, number | null]) => void;
|
||||
keyData: string;
|
||||
}
|
||||
|
||||
export default function NumberInput({ keyData, onChange }: Props): JSX.Element {
|
||||
const [value, setValue] = useState<string>('');
|
||||
const [operator, setOperator] = useState<string>('=');
|
||||
|
||||
useEffect(() => {
|
||||
onChange([operator, value === '' ? null : parseFloat(value)]);
|
||||
}, [value, operator]);
|
||||
|
||||
return (
|
||||
<div className="hstack">
|
||||
<select
|
||||
name={`number-select-${keyData}`}
|
||||
id={`number-select-${keyData}`}
|
||||
defaultValue={'='}
|
||||
onChange={({ target }) => {
|
||||
setOperator(target.value);
|
||||
}}
|
||||
>
|
||||
<option value="=">=</option>
|
||||
<option value=">">{'>'}</option>
|
||||
<option value="<">{'<'}</option>
|
||||
<option value=">=">{'>='}</option>
|
||||
<option value="<=">{'<='}</option>
|
||||
</select>
|
||||
<input
|
||||
name={`number-input-${keyData}`}
|
||||
id="foo"
|
||||
type="number"
|
||||
placeholder="1"
|
||||
onChange={({ target }) => {
|
||||
setValue(target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
_src/components/Table/Filters/SelectFilter.tsx
Normal file
41
_src/components/Table/Filters/SelectFilter.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import type { DataItem, Value } from '../types';
|
||||
import SelectInput from '@components/Inputs/SelectInput';
|
||||
|
||||
interface Props {
|
||||
onChange: (value: Value) => void;
|
||||
data: DataItem[];
|
||||
keyData: string;
|
||||
isMultiple?: boolean;
|
||||
}
|
||||
|
||||
export default function SelectFilter({
|
||||
data,
|
||||
keyData,
|
||||
isMultiple = false,
|
||||
onChange,
|
||||
}: Props): JSX.Element {
|
||||
const options = useMemo(() => {
|
||||
let options = [];
|
||||
|
||||
if (isMultiple) {
|
||||
options = data.flatMap((item) => item[keyData]);
|
||||
} else {
|
||||
options = data.map((item) => item[keyData]);
|
||||
}
|
||||
|
||||
options = [...new Set(options)];
|
||||
|
||||
options = options.map((item) => ({ label: item, value: item }));
|
||||
|
||||
return options;
|
||||
}, [data, keyData]);
|
||||
|
||||
return (
|
||||
<SelectInput
|
||||
options={options}
|
||||
isMultiple={isMultiple}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
64
_src/components/Table/Filters/index.tsx
Normal file
64
_src/components/Table/Filters/index.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { HeaderType, type Filter, type Value } from '../types.ts';
|
||||
|
||||
export { default as SelectFilter } from './SelectFilter.tsx';
|
||||
export { default as NumberFilter } from './NumberFilter.tsx';
|
||||
|
||||
const filterString = (value: string, data: string): boolean => {
|
||||
return data.toLowerCase().search(value.toLowerCase()) !== -1;
|
||||
};
|
||||
const filterNumber = (value: Value, data: any): boolean => {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error(
|
||||
'Value should be an array in the form of [operator: string, value: number]',
|
||||
);
|
||||
}
|
||||
|
||||
const [operator, numberValue] = value;
|
||||
|
||||
if (numberValue === null) return true;
|
||||
|
||||
switch (operator) {
|
||||
case '=':
|
||||
return data === numberValue;
|
||||
case '<':
|
||||
return data < numberValue;
|
||||
case '>':
|
||||
return data > numberValue;
|
||||
case '<=':
|
||||
return data <= numberValue;
|
||||
case '>=':
|
||||
return data >= numberValue;
|
||||
|
||||
default:
|
||||
return data === numberValue;
|
||||
}
|
||||
};
|
||||
const filterSelect = (value: Value, data: any): boolean => {
|
||||
return data === value;
|
||||
};
|
||||
const filterMultiple = (value: Value, data: any): boolean => {
|
||||
if (value === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof value === 'string' || typeof value === 'number') {
|
||||
return data.includes(value);
|
||||
}
|
||||
|
||||
return value.every((filter: string | number) => data.includes(filter));
|
||||
};
|
||||
|
||||
export const resolveFilterByType = (filter: Filter, data: any): boolean => {
|
||||
switch (filter.type) {
|
||||
case HeaderType.String:
|
||||
return filterString(filter.value, data);
|
||||
case HeaderType.Number:
|
||||
return filterNumber(filter.value, data);
|
||||
case HeaderType.Select:
|
||||
return filterSelect(filter.value, data);
|
||||
case HeaderType.Multiple:
|
||||
return filterMultiple(filter.value, data);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
6
_src/components/Table/Table.module.css
Normal file
6
_src/components/Table/Table.module.css
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
.table th,
|
||||
.table td {
|
||||
padding: 0.25rem 1rem;
|
||||
border: 1px solid white;
|
||||
text-align: center;
|
||||
}
|
||||
197
_src/components/Table/Table.tsx
Normal file
197
_src/components/Table/Table.tsx
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import React, { useMemo, useRef, useState } from 'react';
|
||||
import usePagination, { Offset } from 'src/hooks/usePagination';
|
||||
import { SelectFilter, NumberFilter, resolveFilterByType } from './Filters';
|
||||
import type { DataItem, Header, Value, Filter } from './types';
|
||||
import { HeaderType } from './types';
|
||||
import styles from './Table.module.css';
|
||||
|
||||
interface Props {
|
||||
data: DataItem[];
|
||||
headers: Header[];
|
||||
}
|
||||
|
||||
export default function Table({ data, headers }: Props): JSX.Element {
|
||||
const [filters, setFilters] = useState<Record<string, Filter>>({});
|
||||
const filtersId = useRef(crypto.randomUUID());
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
return data.filter((item) => {
|
||||
return Object.entries(filters).every(([key, filter]) =>
|
||||
resolveFilterByType(filter, item[key]),
|
||||
);
|
||||
});
|
||||
}, [data, filters]);
|
||||
|
||||
const { items, changeOffset, getPaginationRange } = usePagination<DataItem>({
|
||||
items: filteredItems,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
const handleUpdateFilters = (
|
||||
name: string,
|
||||
type: HeaderType,
|
||||
value: Value,
|
||||
): void => {
|
||||
setFilters((prev) => {
|
||||
if (value === null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
delete prev[name];
|
||||
return { ...prev };
|
||||
}
|
||||
|
||||
return { ...prev, [name]: { value, type } };
|
||||
});
|
||||
};
|
||||
|
||||
function formatCell(data: DataItem, header: Header): JSX.Element {
|
||||
// This formatting is only used because the source is trusted (private markdown files manage only by me)
|
||||
// and because Astro don't allow me to pass JSX from an Astro file to a TSX file,
|
||||
// so I have to pass the formatted row as a string.
|
||||
// DON'T use this method on a public API
|
||||
if (header.hasCustomCell && header.formatter) {
|
||||
return (
|
||||
<div dangerouslySetInnerHTML={{ __html: header.formatter(data) }} />
|
||||
);
|
||||
}
|
||||
|
||||
if (header.type === HeaderType.Multiple) {
|
||||
return (
|
||||
<ul className="text-start">
|
||||
{data[header.key].map((item: JSX.Element, idx: number) => (
|
||||
<li key={idx}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
return data[header.key];
|
||||
}
|
||||
|
||||
function formatFilter(header: Header): JSX.Element {
|
||||
const baseProps = {
|
||||
key: header.key + filtersId.current,
|
||||
keyData: header.key,
|
||||
value: filters[header.key]?.value,
|
||||
onChange: (value: Value) => {
|
||||
handleUpdateFilters(header.key, header.type, value);
|
||||
},
|
||||
};
|
||||
|
||||
switch (header.type) {
|
||||
case HeaderType.String:
|
||||
return (
|
||||
<input
|
||||
onChange={(e) => {
|
||||
baseProps.onChange(e.target.value);
|
||||
}}
|
||||
key={baseProps.key}
|
||||
/>
|
||||
);
|
||||
case HeaderType.Number:
|
||||
return (
|
||||
<NumberFilter
|
||||
{...baseProps}
|
||||
onChange={(value: [string, number | null]) => {
|
||||
handleUpdateFilters(header.key, header.type, value as Value);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case HeaderType.Select:
|
||||
return <SelectFilter data={data} {...baseProps} />;
|
||||
case HeaderType.Multiple:
|
||||
return <SelectFilter {...baseProps} isMultiple data={data} />;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="hstack">
|
||||
<button
|
||||
className="ml-auto"
|
||||
onClick={() => {
|
||||
setFilters({});
|
||||
filtersId.current = crypto.randomUUID();
|
||||
}}
|
||||
>
|
||||
Clear Filters
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="mt-1 overflow-scroll">
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
{headers.map((item, idx) => (
|
||||
<th key={idx}>
|
||||
<div className="vstack">
|
||||
{item.header}
|
||||
{formatFilter(item)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{items.map((item, idx) => (
|
||||
<tr key={idx}>
|
||||
{headers.map((header, hidx) => (
|
||||
<td key={hidx}>{formatCell(item, header)}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section className="mt-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
changeOffset(Offset.First);
|
||||
}}
|
||||
>
|
||||
First
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
changeOffset(Offset.Prev);
|
||||
}}
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
|
||||
{getPaginationRange().map((item) => (
|
||||
<button
|
||||
className={item.current ? 'btn-primary' : ''}
|
||||
key={item.page}
|
||||
onClick={() => {
|
||||
changeOffset(Offset.To, item.page);
|
||||
}}
|
||||
>
|
||||
{item.page}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
changeOffset(Offset.Next);
|
||||
}}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
changeOffset(Offset.Last);
|
||||
}}
|
||||
>
|
||||
Last
|
||||
</button>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
_src/components/Table/index.ts
Normal file
1
_src/components/Table/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from './Table';
|
||||
24
_src/components/Table/types.ts
Normal file
24
_src/components/Table/types.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
export type DataItem = Record<string, any>;
|
||||
|
||||
export type Value = string | string[] | number | number[] | null;
|
||||
|
||||
export enum HeaderType {
|
||||
Index,
|
||||
String,
|
||||
Number,
|
||||
Select,
|
||||
Multiple,
|
||||
}
|
||||
|
||||
export interface Header {
|
||||
key: string;
|
||||
header: string;
|
||||
type: HeaderType;
|
||||
hasCustomCell?: boolean;
|
||||
formatter?: (data: any) => string;
|
||||
}
|
||||
|
||||
export interface Filter {
|
||||
type: HeaderType;
|
||||
value: Value;
|
||||
}
|
||||
0
_src/components/Toc/Toc.module.css
Normal file
0
_src/components/Toc/Toc.module.css
Normal file
26
_src/components/Toc/Toc.tsx
Normal file
26
_src/components/Toc/Toc.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import React from 'react';
|
||||
import type { MarkdownHeading } from 'astro';
|
||||
// import styles from './Toc.module.css';
|
||||
|
||||
interface Props {
|
||||
headings: MarkdownHeading[];
|
||||
}
|
||||
|
||||
// TODO: Change this for the floating container with Intersection Observer
|
||||
// FIXME: Create real nesting and not the ilussion of nesting (aka nested ul and not padding multiplied by depth)
|
||||
export default function Toc({ headings }: Props): JSX.Element {
|
||||
return (
|
||||
<ul className="mb-3">
|
||||
{headings.map((item, idx) => (
|
||||
<li
|
||||
key={idx}
|
||||
style={{ paddingLeft: item.depth > 1 ? 20 * item.depth : 0 }}
|
||||
>
|
||||
<a href={`#${item.slug}`}>
|
||||
{item.depth} - {item.text}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue