feat: typed database with kysely, updated config

This commit is contained in:
Samuel 2024-12-11 16:41:37 +01:00
parent 28ec24b2c2
commit 67da0a72db
24 changed files with 1656 additions and 434 deletions

View file

@ -8,11 +8,14 @@ import simpleImportSort from "eslint-plugin-simple-import-sort";
import solid from "eslint-plugin-solid/configs/typescript"; import solid from "eslint-plugin-solid/configs/typescript";
import tseslint from "typescript-eslint"; import tseslint from "typescript-eslint";
export default tseslint.config([ export default tseslint.config(
{
ignores: ["dist/**/*.ts", "dist/**", "**/*.mjs", "eslint.config.js", "**/*.js"],
},
[
eslint.configs.recommended, eslint.configs.recommended,
// tseslint.configs.recommendedTypeChecked, tseslint.configs.strictTypeChecked,
// tseslint.configs.strictTypeChecked, tseslint.configs.stylisticTypeChecked,
// tseslint.configs.stylisticTypeChecked,
eslintPluginPrettierRecommended, eslintPluginPrettierRecommended,
eslintConfigPrettier, eslintConfigPrettier,
{ {
@ -69,4 +72,5 @@ export default tseslint.config([
// "@typescript-eslint/consistent-type-exports": "error", // "@typescript-eslint/consistent-type-exports": "error",
}, },
}, },
]); ],
);

View file

@ -8,7 +8,8 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"serve": "vite preview", "serve": "vite preview",
"postinstall": "cp ./node_modules/sql.js/dist/sql-wasm.wasm ./src/assets/sql-wasm.wasm" "postinstall": "cp ./node_modules/sql.js/dist/sql-wasm.wasm ./src/assets/sql-wasm.wasm",
"generate-db-types": "kysely-codegen --dialect=sqlite --url=./src/assets/database.sqlite"
}, },
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
@ -18,11 +19,13 @@
"@typescript-eslint/eslint-plugin": "^8.17.0", "@typescript-eslint/eslint-plugin": "^8.17.0",
"@typescript-eslint/parser": "^8.17.0", "@typescript-eslint/parser": "^8.17.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"better-sqlite3": "^11.7.0",
"eslint": "^9.16.0", "eslint": "^9.16.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1", "eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-solid": "^0.14.4", "eslint-plugin-solid": "^0.14.4",
"kysely-codegen": "^0.17.0",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"prettier": "^3.4.2", "prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.9", "prettier-plugin-tailwindcss": "^0.6.9",
@ -37,9 +40,14 @@
"@kobalte/tailwindcss": "^0.9.0", "@kobalte/tailwindcss": "^0.9.0",
"@solid-primitives/refs": "^1.0.8", "@solid-primitives/refs": "^1.0.8",
"@solidjs/router": "^0.15.1", "@solidjs/router": "^0.15.1",
"@tanstack/solid-table": "^8.20.5",
"chart.js": "^4.4.7", "chart.js": "^4.4.7",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0",
"kysely": "^0.27.5",
"kysely-wasm": "^0.7.0",
"lucide-solid": "^0.468.0",
"solid-js": "^1.9.3", "solid-js": "^1.9.3",
"sql.js": "^1.12.0", "sql.js": "^1.12.0",
"tailwind-merge": "^2.5.5", "tailwind-merge": "^2.5.5",

528
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,33 +0,0 @@
.App {
text-align: center;
}
.logo {
animation: logo-spin infinite 20s linear;
height: 40vmin;
pointer-events: none;
}
.header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.link {
color: #b318f0;
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View file

@ -1,7 +1,10 @@
import { type Component } from "solid-js"; import { type Component } from "solid-js";
import { Route } from "@solidjs/router"; import { Route } from "@solidjs/router";
import { Home, Overview } from "./pages"; import { Home, Overview } from "./pages";
import "./app.css";
const App: Component = () => { const App: Component = () => {
return ( return (
<> <>
@ -13,6 +16,7 @@ const App: Component = () => {
path="/overview" path="/overview"
component={Overview} component={Overview}
/> />
<Route path="/thread/:threadid" />
</> </>
); );
}; };

View file

@ -0,0 +1,44 @@
import type { Component, ComponentProps } from "solid-js";
import { splitProps } from "solid-js";
import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
import { cn } from "~/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground",
secondary: "border-transparent bg-secondary text-secondary-foreground",
outline: "text-foreground",
success: "border-success-foreground bg-success text-success-foreground",
warning: "border-warning-foreground bg-warning text-warning-foreground",
error: "border-error-foreground bg-error text-error-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
type BadgeProps = ComponentProps<"div"> &
VariantProps<typeof badgeVariants> & {
round?: boolean;
};
const Badge: Component<BadgeProps> = (props) => {
const [local, others] = splitProps(props, ["class", "variant", "round"]);
return (
<div
class={cn(badgeVariants({ variant: local.variant }), local.round && "rounded-full", local.class)}
{...others}
/>
);
};
export type { BadgeProps };
export { Badge, badgeVariants };

View file

@ -0,0 +1,51 @@
import type { JSX, ValidComponent } from "solid-js";
import { splitProps } from "solid-js";
import * as ButtonPrimitive from "@kobalte/core/button";
import type { PolymorphicProps } from "@kobalte/core/polymorphic";
import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
import { cn } from "~/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 px-3 text-xs",
lg: "h-11 px-8",
icon: "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
type ButtonProps<T extends ValidComponent = "button"> = ButtonPrimitive.ButtonRootProps<T> &
VariantProps<typeof buttonVariants> & { class?: string | undefined; children?: JSX.Element };
const Button = <T extends ValidComponent = "button">(props: PolymorphicProps<T, ButtonProps<T>>) => {
const [local, others] = splitProps(props as ButtonProps, ["variant", "size", "class"]);
return (
<ButtonPrimitive.Root
class={cn(buttonVariants({ variant: local.variant, size: local.size }), local.class)}
{...others}
/>
);
};
export type { ButtonProps };
export { Button, buttonVariants };

View file

@ -1,19 +1,19 @@
import type { Component } from "solid-js" import type { Component } from "solid-js";
import { createEffect, createSignal, mergeProps, on, onCleanup, onMount } from "solid-js" import { createEffect, createSignal, mergeProps, on, onCleanup, onMount } from "solid-js";
import { unwrap } from "solid-js/store" import { unwrap } from "solid-js/store";
import type { Ref } from "@solid-primitives/refs" import type { Ref } from "@solid-primitives/refs";
import { mergeRefs } from "@solid-primitives/refs" import { mergeRefs } from "@solid-primitives/refs";
import type { import type {
ChartComponent, ChartComponent,
ChartData, ChartData,
ChartItem, ChartItem,
ChartOptions, ChartOptions,
Plugin as ChartPlugin,
ChartType, ChartType,
ChartTypeRegistry, ChartTypeRegistry,
TooltipModel Plugin as ChartPlugin,
} from "chart.js" TooltipModel,
} from "chart.js";
import { import {
ArcElement, ArcElement,
BarController, BarController,
@ -34,167 +34,166 @@ import {
RadarController, RadarController,
RadialLinearScale, RadialLinearScale,
ScatterController, ScatterController,
Tooltip Tooltip,
} from "chart.js" } from "chart.js";
type TypedChartProps = { interface TypedChartProps {
data: ChartData data: ChartData;
options?: ChartOptions options?: ChartOptions;
plugins?: ChartPlugin[] plugins?: ChartPlugin[];
ref?: Ref<HTMLCanvasElement | null> ref?: Ref<HTMLCanvasElement | null>;
width?: number | undefined width?: number | undefined;
height?: number | undefined height?: number | undefined;
} }
type ChartProps = TypedChartProps & { type ChartProps = TypedChartProps & {
type: ChartType type: ChartType;
} };
type ChartContext = { interface ChartContext {
chart: Chart chart: Chart;
tooltip: TooltipModel<keyof ChartTypeRegistry> tooltip: TooltipModel<keyof ChartTypeRegistry>;
} }
const BaseChart: Component<ChartProps> = (rawProps) => { const BaseChart: Component<ChartProps> = (rawProps) => {
const [canvasRef, setCanvasRef] = createSignal<HTMLCanvasElement | null>() const [canvasRef, setCanvasRef] = createSignal<HTMLCanvasElement | null>();
const [chart, setChart] = createSignal<Chart>() const [chart, setChart] = createSignal<Chart>();
const props = mergeProps( const props = mergeProps(
{ {
width: 512, width: 512,
height: 512, height: 512,
options: { responsive: true } as ChartOptions, options: { responsive: true } as ChartOptions,
plugins: [] as ChartPlugin[] plugins: [] as ChartPlugin[],
}, },
rawProps rawProps,
) );
const init = () => { const init = () => {
const ctx = canvasRef()?.getContext("2d") as ChartItem const ctx = canvasRef()?.getContext("2d") as ChartItem;
const config = unwrap(props) const config = unwrap(props);
const chart = new Chart(ctx, { const chart = new Chart(ctx, {
type: config.type, type: config.type,
data: config.data, data: config.data,
options: config.options, options: config.options,
plugins: config.plugins plugins: config.plugins,
}) });
setChart(chart) setChart(chart);
} };
onMount(() => init()) onMount(() => {
init();
});
createEffect( createEffect(
on( on(
() => props.data, () => props.data,
() => { () => {
chart()!.data = props.data chart()!.data = props.data;
chart()!.update() chart()!.update();
}, },
{ defer: true } { defer: true },
) ),
) );
createEffect( createEffect(
on( on(
() => props.options, () => props.options,
() => { () => {
chart()!.options = props.options chart()!.options = props.options;
chart()!.update() chart()!.update();
}, },
{ defer: true } { defer: true },
) ),
) );
createEffect( createEffect(
on( on(
[() => props.width, () => props.height], [() => props.width, () => props.height],
() => { () => {
chart()!.resize(props.width, props.height) chart()!.resize(props.width, props.height);
}, },
{ defer: true } { defer: true },
) ),
) );
createEffect( createEffect(
on( on(
() => props.type, () => props.type,
() => { () => {
const dimensions = [chart()!.width, chart()!.height] const dimensions = [chart()!.width, chart()!.height];
chart()!.destroy() chart()!.destroy();
init() init();
chart()!.resize(...dimensions) chart()!.resize(...dimensions);
}, },
{ defer: true } { defer: true },
) ),
) );
onCleanup(() => { onCleanup(() => {
chart()?.destroy() chart()?.destroy();
mergeRefs(props.ref, null) mergeRefs(props.ref, null);
}) });
Chart.register(Colors, Filler, Legend, Tooltip) Chart.register(Colors, Filler, Legend, Tooltip);
return ( return (
<canvas <canvas
ref={mergeRefs(props.ref, (el) => setCanvasRef(el))} ref={mergeRefs(props.ref, (el) => setCanvasRef(el))}
height={props.height} height={props.height}
width={props.width} width={props.width}
/> />
) );
} };
function showTooltip(context: ChartContext) { function showTooltip(context: ChartContext) {
let el = document.getElementById("chartjs-tooltip") let el = document.getElementById("chartjs-tooltip");
if (!el) { if (!el) {
el = document.createElement("div") el = document.createElement("div");
el.id = "chartjs-tooltip" el.id = "chartjs-tooltip";
document.body.appendChild(el) document.body.appendChild(el);
} }
const model = context.tooltip const model = context.tooltip;
if (model.opacity === 0 || !model.body) { if (model.opacity === 0 || !model.body) {
el.style.opacity = "0" el.style.opacity = "0";
return return;
} }
el.className = `p-2 bg-card text-card-foreground rounded-lg border shadow-sm text-sm ${ el.className = `p-2 bg-card text-card-foreground rounded-lg border shadow-sm text-sm ${
model.yAlign ?? `no-transform` model.yAlign ?? `no-transform`
}` }`;
let content = "" let content = "";
model.title.forEach((title) => { model.title.forEach((title) => {
content += `<h3 class="font-semibold leading-none tracking-tight">${title}</h3>` content += `<h3 class="font-semibold leading-none tracking-tight">${title}</h3>`;
}) });
content += `<div class="mt-1 text-muted-foreground">` content += `<div class="mt-1 text-muted-foreground">`;
const body = model.body.flatMap((body) => body.lines) const body = model.body.flatMap((body) => body.lines);
body.forEach((line, i) => { body.forEach((line, i) => {
const colors = model.labelColors[i] const colors = model.labelColors[i];
content += ` content += `
<div class="flex items-center"> <div class="flex items-center">
<span class="inline-block h-2 w-2 mr-1 rounded-full border" style="background: ${colors.backgroundColor}; border-color: ${colors.borderColor}"></span> <span class="inline-block h-2 w-2 mr-1 rounded-full border" style="background: ${colors.backgroundColor}; border-color: ${colors.borderColor}"></span>
${line} ${line}
</div>` </div>`;
}) });
content += `</div>` content += `</div>`;
el.innerHTML = content el.innerHTML = content;
const pos = context.chart.canvas.getBoundingClientRect() const pos = context.chart.canvas.getBoundingClientRect();
el.style.opacity = "1" el.style.opacity = "1";
el.style.position = "absolute" el.style.position = "absolute";
el.style.left = `${pos.left + window.scrollX + model.caretX}px` el.style.left = `${pos.left + window.scrollX + model.caretX}px`;
el.style.top = `${pos.top + window.scrollY + model.caretY}px` el.style.top = `${pos.top + window.scrollY + model.caretY}px`;
el.style.pointerEvents = "none" el.style.pointerEvents = "none";
} }
function createTypedChart( function createTypedChart(type: ChartType, components: ChartComponent[]): Component<TypedChartProps> {
type: ChartType, const chartsWithScales: ChartType[] = ["bar", "line", "scatter"];
components: ChartComponent[] const chartsWithLegends: ChartType[] = ["bar", "line"];
): Component<TypedChartProps> {
const chartsWithScales: ChartType[] = ["bar", "line", "scatter"]
const chartsWithLegends: ChartType[] = ["bar", "line"]
const options: ChartOptions = { const options: ChartOptions = {
responsive: true, responsive: true,
@ -203,18 +202,18 @@ function createTypedChart(
? { ? {
x: { x: {
border: { display: false }, border: { display: false },
grid: { display: false } grid: { display: false },
}, },
y: { y: {
border: { border: {
dash: [3], dash: [3],
dashOffset: 3, dashOffset: 3,
display: false display: false,
}, },
grid: { grid: {
color: "hsla(240, 3.8%, 46.1%, 0.4)" color: "hsla(240, 3.8%, 46.1%, 0.4)",
} },
} },
} }
: {}, : {},
plugins: { plugins: {
@ -227,66 +226,61 @@ function createTypedChart(
boxWidth: 6, boxWidth: 6,
boxHeight: 6, boxHeight: 6,
color: "hsl(240, 3.8%, 46.1%)", color: "hsl(240, 3.8%, 46.1%)",
font: { size: 14 } font: { size: 14 },
} },
} }
: { display: false }, : { display: false },
tooltip: { tooltip: {
enabled: false, enabled: false,
external: (context) => showTooltip(context) external: (context) => {
} showTooltip(context);
} },
},
},
};
Chart.register(...components);
return (props) => (
<BaseChart
type={type}
options={options}
{...props}
/>
);
} }
Chart.register(...components) const BarChart = /* #__PURE__ */ createTypedChart("bar", [BarController, BarElement, CategoryScale, LinearScale]);
return (props) => <BaseChart type={type} options={options} {...props} /> const BubbleChart = /* #__PURE__ */ createTypedChart("bubble", [BubbleController, PointElement, LinearScale]);
} const DonutChart = /* #__PURE__ */ createTypedChart("doughnut", [DoughnutController, ArcElement]);
const BarChart = /* #__PURE__ */ createTypedChart("bar", [
BarController,
BarElement,
CategoryScale,
LinearScale
])
const BubbleChart = /* #__PURE__ */ createTypedChart("bubble", [
BubbleController,
PointElement,
LinearScale
])
const DonutChart = /* #__PURE__ */ createTypedChart("doughnut", [DoughnutController, ArcElement])
const LineChart = /* #__PURE__ */ createTypedChart("line", [ const LineChart = /* #__PURE__ */ createTypedChart("line", [
LineController, LineController,
LineElement, LineElement,
PointElement, PointElement,
CategoryScale, CategoryScale,
LinearScale LinearScale,
]) ]);
const PieChart = /* #__PURE__ */ createTypedChart("pie", [PieController, ArcElement]) const PieChart = /* #__PURE__ */ createTypedChart("pie", [PieController, ArcElement]);
const PolarAreaChart = /* #__PURE__ */ createTypedChart("polarArea", [ const PolarAreaChart = /* #__PURE__ */ createTypedChart("polarArea", [
PolarAreaController, PolarAreaController,
ArcElement, ArcElement,
RadialLinearScale RadialLinearScale,
]) ]);
const RadarChart = /* #__PURE__ */ createTypedChart("radar", [ const RadarChart = /* #__PURE__ */ createTypedChart("radar", [
RadarController, RadarController,
LineElement, LineElement,
PointElement, PointElement,
RadialLinearScale RadialLinearScale,
]) ]);
const ScatterChart = /* #__PURE__ */ createTypedChart("scatter", [ const ScatterChart = /* #__PURE__ */ createTypedChart("scatter", [ScatterController, PointElement, LinearScale]);
ScatterController,
PointElement,
LinearScale
])
export { export {
BaseChart as Chart,
BarChart, BarChart,
BubbleChart, BubbleChart,
BaseChart as Chart,
DonutChart, DonutChart,
LineChart, LineChart,
PieChart, PieChart,
PolarAreaChart, PolarAreaChart,
RadarChart, RadarChart,
ScatterChart ScatterChart,
} };

View file

@ -0,0 +1,59 @@
import type { ValidComponent } from "solid-js";
import { Match, splitProps, Switch } from "solid-js";
import * as CheckboxPrimitive from "@kobalte/core/checkbox";
import type { PolymorphicProps } from "@kobalte/core/polymorphic";
import { cn } from "~/lib/utils";
type CheckboxRootProps<T extends ValidComponent = "div"> = CheckboxPrimitive.CheckboxRootProps<T> & {
class?: string | undefined;
};
const Checkbox = <T extends ValidComponent = "div">(props: PolymorphicProps<T, CheckboxRootProps<T>>) => {
const [local, others] = splitProps(props as CheckboxRootProps, ["class"]);
return (
<CheckboxPrimitive.Root
class={cn("items-top group relative flex space-x-2", local.class)}
{...others}
>
<CheckboxPrimitive.Input class="peer" />
<CheckboxPrimitive.Control class="size-4 shrink-0 rounded-sm border border-primary ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 peer-focus-visible:outline-none peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 data-[checked]:border-none data-[indeterminate]:border-none data-[checked]:bg-primary data-[indeterminate]:bg-primary data-[checked]:text-primary-foreground data-[indeterminate]:text-primary-foreground">
<CheckboxPrimitive.Indicator>
<Switch>
<Match when={!others.indeterminate}>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4"
>
<path d="M5 12l5 5l10 -10" />
</svg>
</Match>
<Match when={others.indeterminate}>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4"
>
<path d="M5 12l14 0" />
</svg>
</Match>
</Switch>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Control>
</CheckboxPrimitive.Root>
);
};
export { Checkbox };

View file

@ -0,0 +1,19 @@
import type { Component, ComponentProps } from "solid-js";
import { splitProps } from "solid-js";
import { cn } from "~/lib/utils";
const Label: Component<ComponentProps<"label">> = (props) => {
const [local, others] = splitProps(props, ["class"]);
return (
<label
class={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
local.class,
)}
{...others}
/>
);
};
export { Label };

View file

@ -0,0 +1,91 @@
import type { Component, ComponentProps } from "solid-js";
import { splitProps } from "solid-js";
import { cn } from "~/lib/utils";
const Table: Component<ComponentProps<"table">> = (props) => {
const [local, others] = splitProps(props, ["class"]);
return (
<div class="relative w-full overflow-auto">
<table
class={cn("w-full caption-bottom text-sm", local.class)}
{...others}
/>
</div>
);
};
const TableHeader: Component<ComponentProps<"thead">> = (props) => {
const [local, others] = splitProps(props, ["class"]);
return (
<thead
class={cn("[&_tr]:border-b", local.class)}
{...others}
/>
);
};
const TableBody: Component<ComponentProps<"tbody">> = (props) => {
const [local, others] = splitProps(props, ["class"]);
return (
<tbody
class={cn("[&_tr:last-child]:border-0", local.class)}
{...others}
/>
);
};
const TableFooter: Component<ComponentProps<"tfoot">> = (props) => {
const [local, others] = splitProps(props, ["class"]);
return (
<tfoot
class={cn("bg-primary font-medium text-primary-foreground", local.class)}
{...others}
/>
);
};
const TableRow: Component<ComponentProps<"tr">> = (props) => {
const [local, others] = splitProps(props, ["class"]);
return (
<tr
class={cn("border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", local.class)}
{...others}
/>
);
};
const TableHead: Component<ComponentProps<"th">> = (props) => {
const [local, others] = splitProps(props, ["class"]);
return (
<th
class={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
local.class,
)}
{...others}
/>
);
};
const TableCell: Component<ComponentProps<"td">> = (props) => {
const [local, others] = splitProps(props, ["class"]);
return (
<td
class={cn("p-2 align-middle [&:has([role=checkbox])]:pr-0", local.class)}
{...others}
/>
);
};
const TableCaption: Component<ComponentProps<"caption">> = (props) => {
const [local, others] = splitProps(props, ["class"]);
return (
<caption
class={cn("mt-4 text-sm text-muted-foreground", local.class)}
{...others}
/>
);
};
export { Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow };

View file

@ -0,0 +1,147 @@
import type { ValidComponent } from "solid-js";
import { mergeProps, splitProps } from "solid-js";
import type { PolymorphicProps } from "@kobalte/core";
import * as TextFieldPrimitive from "@kobalte/core/text-field";
import { cva } from "class-variance-authority";
import { cn } from "~/lib/utils";
type TextFieldRootProps<T extends ValidComponent = "div"> = TextFieldPrimitive.TextFieldRootProps<T> & {
class?: string | undefined;
};
const TextField = <T extends ValidComponent = "div">(props: PolymorphicProps<T, TextFieldRootProps<T>>) => {
const [local, others] = splitProps(props as TextFieldRootProps, ["class"]);
return (
<TextFieldPrimitive.Root
class={cn("flex flex-col gap-1", local.class)}
{...others}
/>
);
};
type TextFieldInputProps<T extends ValidComponent = "input"> = TextFieldPrimitive.TextFieldInputProps<T> & {
class?: string | undefined;
type?:
| "button"
| "checkbox"
| "color"
| "date"
| "datetime-local"
| "email"
| "file"
| "hidden"
| "image"
| "month"
| "number"
| "password"
| "radio"
| "range"
| "reset"
| "search"
| "submit"
| "tel"
| "text"
| "time"
| "url"
| "week";
};
const TextFieldInput = <T extends ValidComponent = "input">(rawProps: PolymorphicProps<T, TextFieldInputProps<T>>) => {
const props = mergeProps<TextFieldInputProps<T>[]>({ type: "text" }, rawProps);
const [local, others] = splitProps(props as TextFieldInputProps, ["type", "class"]);
return (
<TextFieldPrimitive.Input
type={local.type}
class={cn(
"flex h-10 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[invalid]:border-error-foreground data-[invalid]:text-error-foreground",
local.class,
)}
{...others}
/>
);
};
type TextFieldTextAreaProps<T extends ValidComponent = "textarea"> = TextFieldPrimitive.TextFieldTextAreaProps<T> & {
class?: string | undefined;
};
const TextFieldTextArea = <T extends ValidComponent = "textarea">(
props: PolymorphicProps<T, TextFieldTextAreaProps<T>>,
) => {
const [local, others] = splitProps(props as TextFieldTextAreaProps, ["class"]);
return (
<TextFieldPrimitive.TextArea
class={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
local.class,
)}
{...others}
/>
);
};
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
{
variants: {
variant: {
label: "data-[invalid]:text-destructive",
description: "font-normal text-muted-foreground",
error: "text-xs text-destructive",
},
},
defaultVariants: {
variant: "label",
},
},
);
type TextFieldLabelProps<T extends ValidComponent = "label"> = TextFieldPrimitive.TextFieldLabelProps<T> & {
class?: string | undefined;
};
const TextFieldLabel = <T extends ValidComponent = "label">(props: PolymorphicProps<T, TextFieldLabelProps<T>>) => {
const [local, others] = splitProps(props as TextFieldLabelProps, ["class"]);
return (
<TextFieldPrimitive.Label
class={cn(labelVariants(), local.class)}
{...others}
/>
);
};
type TextFieldDescriptionProps<T extends ValidComponent = "div"> = TextFieldPrimitive.TextFieldDescriptionProps<T> & {
class?: string | undefined;
};
const TextFieldDescription = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, TextFieldDescriptionProps<T>>,
) => {
const [local, others] = splitProps(props as TextFieldDescriptionProps, ["class"]);
return (
<TextFieldPrimitive.Description
class={cn(labelVariants({ variant: "description" }), local.class)}
{...others}
/>
);
};
type TextFieldErrorMessageProps<T extends ValidComponent = "div"> = TextFieldPrimitive.TextFieldErrorMessageProps<T> & {
class?: string | undefined;
};
const TextFieldErrorMessage = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, TextFieldErrorMessageProps<T>>,
) => {
const [local, others] = splitProps(props as TextFieldErrorMessageProps, ["class"]);
return (
<TextFieldPrimitive.ErrorMessage
class={cn(labelVariants({ variant: "error" }), local.class)}
{...others}
/>
);
};
export { TextField, TextFieldDescription, TextFieldErrorMessage, TextFieldInput, TextFieldLabel, TextFieldTextArea };

148
src/db.ts
View file

@ -1,8 +1,11 @@
import { type Accessor, createMemo, createSignal, DEV, type Setter } from "solid-js";
import { Kysely, type NotNull } from "kysely";
import type { DB } from "kysely-codegen";
import { SqlJsDialect } from "kysely-wasm";
import initSqlJS, { type Database } from "sql.js"; import initSqlJS, { type Database } from "sql.js";
import wasmURL from "./assets/sql-wasm.wasm?url"; import wasmURL from "./assets/sql-wasm.wasm?url";
import dbURL from "./assets/database.sqlite?url";
import { createMemo, createSignal } from "solid-js";
export const SELF_ID = 2; export const SELF_ID = 2;
@ -10,80 +13,87 @@ export const SQL = await initSqlJS({
locateFile: () => wasmURL, locateFile: () => wasmURL,
}); });
const file = await fetch(dbURL).then((res) => res.arrayBuffer()); let rawDb: Accessor<Database | undefined>, setRawDb: Setter<Database | undefined>;
if (DEV) {
const file = await import("./assets/database.sqlite?url").then((result) => {
return fetch(result.default).then((res) => res.arrayBuffer());
});
const testDb = new SQL.Database(new Uint8Array(file)); const testDb = new SQL.Database(new Uint8Array(file));
export const [db, setDb] = createSignal<Database>(testDb); [rawDb, setRawDb] = createSignal<Database>(testDb);
} else {
[rawDb, setRawDb] = createSignal<Database>();
}
const createStatement = (sql: string) => { export { rawDb as db, setRawDb as setDb };
return createMemo(() => {
return db().prepare(sql); const sqlJsDialect = () => {
const currentDb = rawDb();
if (currentDb) {
return new SqlJsDialect({
database: currentDb,
}); });
}
}; };
export const roomOverviewStmt = createStatement(` const kyselyDb = createMemo(() => {
SELECT const currentSqlJsDialect = sqlJsDialect();
thread.recipient_id,
thread.active,
thread.archived,
recipient.profile_joined_name,
recipient.system_joined_name,
groups.title,
message_count,
last_message_date
FROM
thread
LEFT JOIN (
SELECT
thread_id,
COUNT(*) AS message_count
FROM
message
WHERE
message.body IS NOT NULL
AND message.body != ''
GROUP BY
thread_id
) message_counts ON message_counts.thread_id = thread._id
LEFT JOIN (
SELECT
thread_id,
max(date_sent) AS last_message_date
FROM
message
GROUP BY
thread_id
) last_messages ON last_messages.thread_id = thread._id
JOIN recipient ON thread.recipient_id = recipient._id
LEFT JOIN groups ON recipient._id = groups.recipient_id
WHERE
message_count > 0
`);
export type RoomOverviewColumn = { if (!currentSqlJsDialect) {
recipient_id: number; throw new Error("no db selected!");
active: 0 | 1; }
archived: 0 | 1;
return new Kysely<DB>({
dialect: currentSqlJsDialect,
});
});
export const threadOverviewQuery = kyselyDb()
.selectFrom("thread")
.innerJoin(
(eb) =>
eb
.selectFrom("message")
.select(["thread_id", kyselyDb().fn.countAll().as("message_count")])
.where((eb) => {
return eb.and([eb("message.body", "is not", null), eb("message.body", "is not", "")]);
})
.groupBy("message.thread_id")
.as("message"),
(join) => join.onRef("message.thread_id", "=", "thread._id"),
)
.innerJoin("recipient", "thread.recipient_id", "recipient._id")
.leftJoin("groups", "recipient._id", "groups.recipient_id")
.select([
"thread._id as thread_id",
"thread.recipient_id",
"thread.archived",
"recipient.profile_joined_name",
"recipient.system_joined_name",
"groups.title",
"message_count",
"thread.date as last_message_date",
])
.where("message_count", ">", 0)
.$narrowType<{
thread_id: NotNull;
archived: NotNull;
message_count: number; message_count: number;
last_message_date: number; }>();
} & (
| {
profile_joined_name: string;
system_joined_name: string | null;
title: null;
}
| {
profile_joined_name: null;
system_joined_name: null;
title: string;
}
);
export const overallSentMessagesStmt = createStatement(` console.log(threadOverviewQuery.compile());
SELECT
COUNT(*) as message_count export const overallSentMessagesQuery = (recipientId: number) =>
FROM kyselyDb()
message .selectFrom("message")
WHERE (message.from_recipient_id = :recipient_id AND message.body IS NOT NULL AND message.body != '') .select(kyselyDb().fn.countAll().as("message_count"))
`); .where((eb) =>
eb.and([
eb("message.from_recipient_id", "=", recipientId),
eb("message.body", "is not", null),
eb("message.body", "!=", ""),
]),
);

View file

@ -1,13 +0,0 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto',
'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

View file

@ -1,23 +1,26 @@
/* @refresh reload */ /* @refresh reload */
import { render } from "solid-js/web"; import { render } from "solid-js/web";
import "./index.css";
import App from "./App";
import { Router } from "@solidjs/router"; import { Router } from "@solidjs/router";
import App from "./App";
const root = document.getElementById("root"); const root = document.getElementById("root");
if (import.meta.env.DEV && !(root instanceof HTMLElement)) { if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
throw new Error( throw new Error(
"Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?" "Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?",
); );
} }
if (root) {
render( render(
() => ( () => (
<div class="mx-auto max-w-screen-2xl">
<Router> <Router>
<App /> <App />
</Router> </Router>
</div>
), ),
root! root,
); );
}

View file

@ -1,53 +0,0 @@
const TABLES = [
"attachment",
"sqlite_sequence",
"avatar_picker",
"recipient",
"thread",
"message",
"call",
"call_link",
"cds",
"chat_colors",
"chat_folder",
"chat_folder_membership",
"distribution_list",
"distribution_list_member",
"donation_receipt",
"drafts",
"emoji_search",
"groups",
"group_membership",
"group_receipts",
"identities",
"in_app_payment",
"in_app_payment_subscriber",
"kyber_prekey",
"mention",
"message_fts",
"message_fts_data",
"message_fts_idx",
"message_fts_docsize",
"message_fts_config",
"msl_payload",
"msl_message",
"msl_recipient",
"name_collision",
"name_collision_membership",
"notification_profile",
"notification_profile_allowed_members",
"notification_profile_schedule",
"one_time_prekeys",
"payments",
"pending_pni_signature_message",
"pending_retry_receipts",
"reaction",
"remapped_recipients",
"remapped_threads",
"remote_megaphone",
"sender_key_shared",
"sender_keys",
"sessions",
"storage_key",
"story_sends",
];

View file

@ -1,6 +1,6 @@
import { type ClassValue, clsx } from "clsx" import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs));
} }

8
src/pages/chat/index.tsx Normal file
View file

@ -0,0 +1,8 @@
import type { Component } from "solid-js";
import type { RouteSectionProps } from "@solidjs/router";
export const Chat: Component<RouteSectionProps> = (props) => {
const threadId = () => props.params.threadid;
return threadId();
};

View file

@ -1,12 +1,14 @@
import { redirect, useNavigate, type RouteSectionProps } from "@solidjs/router";
import { type Component, type JSX } from "solid-js"; import { type Component, type JSX } from "solid-js";
import { type RouteSectionProps, useNavigate } from "@solidjs/router";
import { setDb, SQL } from "~/db"; import { setDb, SQL } from "~/db";
export const Home: Component<RouteSectionProps> = () => { export const Home: Component<RouteSectionProps> = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const onFileChange: JSX.ChangeEventHandler<HTMLInputElement, Event> = (event) => { const onFileChange: JSX.ChangeEventHandler<HTMLInputElement, Event> = (event) => {
const file = event.currentTarget.files![0]; const file = event.currentTarget.files?.[0];
if (file) {
const reader = new FileReader(); const reader = new FileReader();
reader.addEventListener("load", () => { reader.addEventListener("load", () => {
@ -16,6 +18,7 @@ export const Home: Component<RouteSectionProps> = () => {
}); });
reader.readAsArrayBuffer(file); reader.readAsArrayBuffer(file);
}
}; };
return ( return (

View file

@ -1,47 +0,0 @@
import type { RouteSectionProps } from "@solidjs/router";
import type { Component } from "solid-js";
import { overallSentMessagesStmt, roomOverviewStmt, SELF_ID, type RoomOverviewColumn } from "~/db";
type RoomOverview = {
recipientId: number;
active: boolean;
archived: boolean;
messageCount: number;
lastMessageDate: number;
title: string;
isGroup: boolean;
}[];
export const Overview: Component<RouteSectionProps> = () => {
const allSelfSentMessagesCount = overallSentMessagesStmt().getAsObject({
":recipient_id": SELF_ID,
});
const roomOverviewRaw: RoomOverviewColumn[] = [];
while (roomOverviewStmt().step()) {
roomOverviewRaw.push(roomOverviewStmt().getAsObject() as RoomOverviewColumn);
}
roomOverviewStmt().free();
const roomOverview: RoomOverview = roomOverviewRaw.map((column) => {
const isGroup = Boolean(column.title);
return {
recipientId: column.recipient_id,
active: Boolean(column.active),
archived: Boolean(column.archived),
messageCount: column.message_count,
lastMessageDate: column.last_message_date,
title: isGroup ? column.title! : (column.system_joined_name ?? column.profile_joined_name)!,
isGroup,
};
});
console.log(roomOverview);
return <p>All messages: {allSelfSentMessagesCount.message_count as number}</p>;
};
export default Overview;

View file

@ -0,0 +1,49 @@
import { type Component, createResource, Show } from "solid-js";
import type { RouteSectionProps } from "@solidjs/router";
import { overallSentMessagesQuery, SELF_ID, threadOverviewQuery } from "~/db";
import { OverviewTable, type RoomOverview } from "./overview-table";
export const Overview: Component<RouteSectionProps> = () => {
const [allSelfSentMessagesCount] = createResource(() => overallSentMessagesQuery(SELF_ID).executeTakeFirstOrThrow());
const [roomOverview] = createResource<RoomOverview[]>(async () => {
return (await threadOverviewQuery.execute()).map((row) => {
const isGroup = row.title !== null;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const name = (
isGroup
? row.title
: /* seems possible that it is an empty string */ !row.system_joined_name
? row.profile_joined_name
: row.system_joined_name
)!;
return {
threadId: row.thread_id,
recipientId: row.recipient_id,
archived: Boolean(row.archived),
messageCount: row.message_count,
lastMessageDate: row.last_message_date ? new Date(row.last_message_date) : undefined,
name,
isGroup,
};
});
});
return (
<div>
<p>All messages: {allSelfSentMessagesCount()?.message_count as number}</p>
<Show
when={!roomOverview.loading}
fallback="Loading..."
>
<OverviewTable data={roomOverview()!} />;
</Show>
</div>
);
};
export default Overview;

View file

@ -0,0 +1,347 @@
import { type Component, createSignal, For, Match, Show, Switch } from "solid-js";
import {
type ColumnFiltersState,
createColumnHelper,
createSolidTable,
type FilterFn,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
type SortDirection,
type SortingState,
} from "@tanstack/solid-table";
import { intlFormatDistance } from "date-fns";
import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-solid";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Checkbox } from "~/components/ui/checkbox";
import { Label } from "~/components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
import { TextField, TextFieldInput } from "~/components/ui/text-field";
import { cn } from "~/lib/utils";
export interface RoomOverview {
threadId: number;
recipientId: number;
archived: boolean;
messageCount: number;
lastMessageDate: Date | undefined;
name: string;
isGroup: boolean;
}
const columnHelper = createColumnHelper<RoomOverview>();
const archivedFilterFn: FilterFn<RoomOverview> = (row, columnId, filterValue) => {
if (filterValue === true) {
return true;
}
return !row.original.archived;
};
const SortingDisplay: Component<{ sorting: false | SortDirection; class?: string; activeClass?: string }> = (props) => {
return (
<Switch>
<Match when={props.sorting === false}>
<ArrowUpDown class={props.class} />
</Match>
<Match when={props.sorting === "asc"}>
<ArrowUp class={cn(props.class, props.activeClass)} />
</Match>
<Match when={props.sorting === "desc"}>
<ArrowDown class={cn(props.class, props.activeClass)} />
</Match>
</Switch>
);
};
export const columns = [
columnHelper.accessor("threadId", {}),
columnHelper.accessor("name", {
header: (props) => {
const sorting = () => props.column.getIsSorted();
return (
<Button
variant="ghost"
onClick={() => {
props.column.toggleSorting();
}}
>
Name
<SortingDisplay
sorting={sorting()}
class="ml-2 h-4 w-4"
activeClass="text-info-foreground"
/>
</Button>
);
},
cell: (props) => {
const isArchived = props.row.getValue("archived");
const isGroup = props.row.getValue("isGroup");
return (
<div class="flex w-full flex-row">
<span class="font-bold">{props.cell.getValue()}</span>
<Show when={isArchived || isGroup}>
<div class="ml-auto flex flex-row gap-2">
<Show when={isArchived}>
<Badge
variant="outline"
class="ml-auto"
>
Archived
</Badge>
</Show>
<Show when={isGroup}>
<Badge
variant="outline"
class="ml-auto"
>
Group
</Badge>
</Show>
</div>
</Show>
</div>
);
},
}),
columnHelper.accessor("messageCount", {
id: "messageCount",
header: (props) => {
const sorting = () => props.column.getIsSorted();
return (
<Button
variant="ghost"
onClick={() => {
props.column.toggleSorting();
}}
>
Number of messages
<SortingDisplay
sorting={sorting()}
class="ml-2 h-4 w-4"
activeClass="text-info-foreground"
/>
</Button>
);
},
sortingFn: "basic",
}),
columnHelper.accessor("lastMessageDate", {
header: (props) => {
const sorting = () => props.column.getIsSorted();
return (
<Button
variant="ghost"
onClick={() => {
props.column.toggleSorting();
}}
>
Date of last message
<SortingDisplay
sorting={sorting()}
class="ml-2 h-4 w-4"
activeClass="text-info-foreground"
/>
</Button>
);
},
sortingFn: "datetime",
cell: (props) => {
const value = props.cell.getValue();
if (value) {
return intlFormatDistance(new Date(value), new Date());
} else {
return "";
}
},
}),
columnHelper.accessor("archived", {
id: "archived",
header: "Archived",
cell: (props) => {
return (
<Show when={props.cell.getValue()}>
<Badge>Archived</Badge>
</Show>
);
},
filterFn: archivedFilterFn,
}),
columnHelper.accessor("isGroup", {
header: "Group",
cell: (props) => {
return (
<Show when={props.cell.getValue()}>
<Badge>Group</Badge>
</Show>
);
},
}),
];
interface OverviewTableProps {
data: RoomOverview[];
}
export const OverviewTable = (props: OverviewTableProps) => {
const [sorting, setSorting] = createSignal<SortingState>([
{
id: "messageCount",
desc: true,
},
]);
const [columnFilters, setColumnFilters] = createSignal<ColumnFiltersState>([
{
id: "archived",
value: false,
},
]);
const table = createSolidTable({
get data() {
return props.data;
},
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
state: {
get sorting() {
return sorting();
},
get columnFilters() {
return columnFilters();
},
columnVisibility: {
threadId: false,
archived: false,
isGroup: false,
},
},
initialState: {
pagination: {
pageIndex: 0,
pageSize: 25,
},
},
});
return (
<div>
<div class="flex flex-row items-center gap-x-4">
<div class="flex items-center py-4">
<TextField
value={(table.getColumn("name")?.getFilterValue() as string | undefined) ?? ""}
onChange={(value) => table.getColumn("name")?.setFilterValue(value)}
>
<TextFieldInput
placeholder="Filter by name..."
class="max-w-sm"
/>
</TextField>
</div>
<div class="flex items-start space-x-2">
<Checkbox
id="show-archived"
checked={(table.getColumn("archived")?.getFilterValue() as boolean | undefined) ?? false}
onChange={(value) => table.getColumn("archived")?.setFilterValue(value)}
/>
<div class="grid gap-1.5 leading-none">
<Label for="show-archived">Show archived chats</Label>
</div>
</div>
</div>
<Table class="border-separate border-spacing-0">
<TableHeader>
<For each={table.getHeaderGroups()}>
{(headerGroup) => (
<TableRow>
<For each={headerGroup.headers}>
{(header) => (
<TableHead
class="border-b border-r border-t first-of-type:rounded-tl-md first-of-type:border-l last-of-type:rounded-tr-md"
colSpan={header.colSpan}
>
<Show when={!header.isPlaceholder}>
{flexRender(header.column.columnDef.header, header.getContext())}
</Show>
</TableHead>
)}
</For>
</TableRow>
)}
</For>
</TableHeader>
<TableBody>
<Show
when={table.getRowModel().rows.length}
fallback={
<TableRow>
<TableCell
colSpan={columns.length}
class="h-24 border-b border-r text-center first-of-type:rounded-tl-md first-of-type:border-l last-of-type:rounded-br-md"
>
No results.
</TableCell>
</TableRow>
}
>
<For each={table.getRowModel().rows}>
{(row) => (
<TableRow
class="[&:last-of-type>td:first-of-type]:rounded-bl-md [&:last-of-type>td:last-of-type]:rounded-br-md"
data-state={row.getIsSelected() && "selected"}
>
<For each={row.getVisibleCells()}>
{(cell) => (
<TableCell class="border-b border-r first-of-type:border-l">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
)}
</For>
</TableRow>
)}
</For>
</Show>
</TableBody>
</Table>
<div class="flex items-center justify-end space-x-2 py-4">
<Button
variant="outline"
size="sm"
onClick={() => {
table.previousPage();
}}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
table.nextPage();
}}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
);
};

View file

@ -1,5 +1,4 @@
import path from "path"; import path from "path";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import solidPlugin from "vite-plugin-solid"; import solidPlugin from "vite-plugin-solid";