feat: implement barebone zola templates

This commit is contained in:
Alexander Navarro 2024-11-10 21:45:59 +00:00
parent 7487c327cd
commit 22901cf551
198 changed files with 2434 additions and 227991 deletions

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

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

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

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

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

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

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

View file

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

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

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

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

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

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

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

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

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

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

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

View 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}
/>
);
}

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

View file

@ -0,0 +1,6 @@
.table th,
.table td {
padding: 0.25rem 1rem;
border: 1px solid white;
text-align: center;
}

View 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>
</>
);
}

View file

@ -0,0 +1 @@
export { default } from './Table';

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

View file

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