feat: implement barebone zola templates

This commit is contained in:
Alexander Navarro 2024-11-10 21:45:59 +00:00
parent 9c20f5ed2e
commit f99a9ae2ac
198 changed files with 2434 additions and 227991 deletions

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