feat: typed database with kysely, updated config

This commit is contained in:
Samuel 2024-12-11 16:41:37 +01:00
parent d06e6d913e
commit 0e6f5a24bd
24 changed files with 1656 additions and 434 deletions

View file

@ -8,65 +8,69 @@ import simpleImportSort from "eslint-plugin-simple-import-sort";
import solid from "eslint-plugin-solid/configs/typescript";
import tseslint from "typescript-eslint";
export default tseslint.config([
eslint.configs.recommended,
// tseslint.configs.recommendedTypeChecked,
// tseslint.configs.strictTypeChecked,
// tseslint.configs.stylisticTypeChecked,
eslintPluginPrettierRecommended,
eslintConfigPrettier,
export default tseslint.config(
{
...solid,
languageOptions: {
parser: tsparser,
// Specifies the ESLint parser
parserOptions: {
ecmaVersion: 2024,
// Allows for the parsing of modern ECMAScript features
sourceType: "module",
// Allows for the use of imports
ecmaFeatures: {
jsx: true, // Allows for the parsing of JSX
ignores: ["dist/**/*.ts", "dist/**", "**/*.mjs", "eslint.config.js", "**/*.js"],
},
[
eslint.configs.recommended,
tseslint.configs.strictTypeChecked,
tseslint.configs.stylisticTypeChecked,
eslintPluginPrettierRecommended,
eslintConfigPrettier,
{
...solid,
languageOptions: {
parser: tsparser,
// Specifies the ESLint parser
parserOptions: {
ecmaVersion: 2024,
// Allows for the parsing of modern ECMAScript features
sourceType: "module",
// Allows for the use of imports
ecmaFeatures: {
jsx: true, // Allows for the parsing of JSX
},
project: "./tsconfig.json",
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
project: "./tsconfig.json",
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
plugins: {
"simple-import-sort": simpleImportSort,
},
rules: {
// Place to specify ESLint rules. Can be used to overwrite rules specified from the extended configs
// e.g. "@typescript-eslint/explicit-function-return-type": "off",
"simple-import-sort/imports": [
"error",
{
groups: [
// solidjs
["^solid-(js|start)", "^@solidjs/"],
["^@?\\w"],
// components imports
["^~/components/?"],
// other /src imports
["^~/"],
// Parent imports. Put `..` last.
["^\\.\\.(?!/?$)", "^\\.\\./?$"],
// Other relative imports. Put same-folder imports and `.` last.
["^\\./(?=.*/)(?!/?$)", "^\\.(?!/?$)", "^\\./?$"],
// types imports
["^~/types$"],
// Side effect imports.
["^\\u0000"],
// Style imports.
["^.+\\.?(s?css)$"],
],
},
],
"simple-import-sort/exports": "error",
// "@typescript-eslint/consistent-type-imports": "error",
// "@typescript-eslint/consistent-type-exports": "error",
},
},
plugins: {
"simple-import-sort": simpleImportSort,
},
rules: {
// Place to specify ESLint rules. Can be used to overwrite rules specified from the extended configs
// e.g. "@typescript-eslint/explicit-function-return-type": "off",
"simple-import-sort/imports": [
"error",
{
groups: [
// solidjs
["^solid-(js|start)", "^@solidjs/"],
["^@?\\w"],
// components imports
["^~/components/?"],
// other /src imports
["^~/"],
// Parent imports. Put `..` last.
["^\\.\\.(?!/?$)", "^\\.\\./?$"],
// Other relative imports. Put same-folder imports and `.` last.
["^\\./(?=.*/)(?!/?$)", "^\\.(?!/?$)", "^\\./?$"],
// types imports
["^~/types$"],
// Side effect imports.
["^\\u0000"],
// Style imports.
["^.+\\.?(s?css)$"],
],
},
],
"simple-import-sort/exports": "error",
// "@typescript-eslint/consistent-type-imports": "error",
// "@typescript-eslint/consistent-type-exports": "error",
},
},
]);
],
);

View file

@ -8,7 +8,8 @@
"dev": "vite",
"build": "vite build",
"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",
"devDependencies": {
@ -18,11 +19,13 @@
"@typescript-eslint/eslint-plugin": "^8.17.0",
"@typescript-eslint/parser": "^8.17.0",
"autoprefixer": "^10.4.20",
"better-sqlite3": "^11.7.0",
"eslint": "^9.16.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-solid": "^0.14.4",
"kysely-codegen": "^0.17.0",
"postcss": "^8.4.49",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.9",
@ -37,9 +40,14 @@
"@kobalte/tailwindcss": "^0.9.0",
"@solid-primitives/refs": "^1.0.8",
"@solidjs/router": "^0.15.1",
"@tanstack/solid-table": "^8.20.5",
"chart.js": "^4.4.7",
"class-variance-authority": "^0.7.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",
"sql.js": "^1.12.0",
"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 { Route } from "@solidjs/router";
import { Home, Overview } from "./pages";
import "./app.css";
const App: Component = () => {
return (
<>
@ -13,6 +16,7 @@ const App: Component = () => {
path="/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 { createEffect, createSignal, mergeProps, on, onCleanup, onMount } from "solid-js"
import { unwrap } from "solid-js/store"
import type { Component } from "solid-js";
import { createEffect, createSignal, mergeProps, on, onCleanup, onMount } from "solid-js";
import { unwrap } from "solid-js/store";
import type { Ref } from "@solid-primitives/refs"
import { mergeRefs } from "@solid-primitives/refs"
import type { Ref } from "@solid-primitives/refs";
import { mergeRefs } from "@solid-primitives/refs";
import type {
ChartComponent,
ChartData,
ChartItem,
ChartOptions,
Plugin as ChartPlugin,
ChartType,
ChartTypeRegistry,
TooltipModel
} from "chart.js"
Plugin as ChartPlugin,
TooltipModel,
} from "chart.js";
import {
ArcElement,
BarController,
@ -34,167 +34,166 @@ import {
RadarController,
RadialLinearScale,
ScatterController,
Tooltip
} from "chart.js"
Tooltip,
} from "chart.js";
type TypedChartProps = {
data: ChartData
options?: ChartOptions
plugins?: ChartPlugin[]
ref?: Ref<HTMLCanvasElement | null>
width?: number | undefined
height?: number | undefined
interface TypedChartProps {
data: ChartData;
options?: ChartOptions;
plugins?: ChartPlugin[];
ref?: Ref<HTMLCanvasElement | null>;
width?: number | undefined;
height?: number | undefined;
}
type ChartProps = TypedChartProps & {
type: ChartType
}
type: ChartType;
};
type ChartContext = {
chart: Chart
tooltip: TooltipModel<keyof ChartTypeRegistry>
interface ChartContext {
chart: Chart;
tooltip: TooltipModel<keyof ChartTypeRegistry>;
}
const BaseChart: Component<ChartProps> = (rawProps) => {
const [canvasRef, setCanvasRef] = createSignal<HTMLCanvasElement | null>()
const [chart, setChart] = createSignal<Chart>()
const [canvasRef, setCanvasRef] = createSignal<HTMLCanvasElement | null>();
const [chart, setChart] = createSignal<Chart>();
const props = mergeProps(
{
width: 512,
height: 512,
options: { responsive: true } as ChartOptions,
plugins: [] as ChartPlugin[]
plugins: [] as ChartPlugin[],
},
rawProps
)
rawProps,
);
const init = () => {
const ctx = canvasRef()?.getContext("2d") as ChartItem
const config = unwrap(props)
const ctx = canvasRef()?.getContext("2d") as ChartItem;
const config = unwrap(props);
const chart = new Chart(ctx, {
type: config.type,
data: config.data,
options: config.options,
plugins: config.plugins
})
setChart(chart)
}
plugins: config.plugins,
});
setChart(chart);
};
onMount(() => init())
onMount(() => {
init();
});
createEffect(
on(
() => props.data,
() => {
chart()!.data = props.data
chart()!.update()
chart()!.data = props.data;
chart()!.update();
},
{ defer: true }
)
)
{ defer: true },
),
);
createEffect(
on(
() => props.options,
() => {
chart()!.options = props.options
chart()!.update()
chart()!.options = props.options;
chart()!.update();
},
{ defer: true }
)
)
{ defer: true },
),
);
createEffect(
on(
[() => props.width, () => props.height],
() => {
chart()!.resize(props.width, props.height)
chart()!.resize(props.width, props.height);
},
{ defer: true }
)
)
{ defer: true },
),
);
createEffect(
on(
() => props.type,
() => {
const dimensions = [chart()!.width, chart()!.height]
chart()!.destroy()
init()
chart()!.resize(...dimensions)
const dimensions = [chart()!.width, chart()!.height];
chart()!.destroy();
init();
chart()!.resize(...dimensions);
},
{ defer: true }
)
)
{ defer: true },
),
);
onCleanup(() => {
chart()?.destroy()
mergeRefs(props.ref, null)
})
chart()?.destroy();
mergeRefs(props.ref, null);
});
Chart.register(Colors, Filler, Legend, Tooltip)
Chart.register(Colors, Filler, Legend, Tooltip);
return (
<canvas
ref={mergeRefs(props.ref, (el) => setCanvasRef(el))}
height={props.height}
width={props.width}
/>
)
}
);
};
function showTooltip(context: ChartContext) {
let el = document.getElementById("chartjs-tooltip")
let el = document.getElementById("chartjs-tooltip");
if (!el) {
el = document.createElement("div")
el.id = "chartjs-tooltip"
document.body.appendChild(el)
el = document.createElement("div");
el.id = "chartjs-tooltip";
document.body.appendChild(el);
}
const model = context.tooltip
const model = context.tooltip;
if (model.opacity === 0 || !model.body) {
el.style.opacity = "0"
return
el.style.opacity = "0";
return;
}
el.className = `p-2 bg-card text-card-foreground rounded-lg border shadow-sm text-sm ${
model.yAlign ?? `no-transform`
}`
}`;
let content = ""
let content = "";
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">`
const body = model.body.flatMap((body) => body.lines)
content += `<div class="mt-1 text-muted-foreground">`;
const body = model.body.flatMap((body) => body.lines);
body.forEach((line, i) => {
const colors = model.labelColors[i]
const colors = model.labelColors[i];
content += `
<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>
${line}
</div>`
})
content += `</div>`
</div>`;
});
content += `</div>`;
el.innerHTML = content
el.innerHTML = content;
const pos = context.chart.canvas.getBoundingClientRect()
el.style.opacity = "1"
el.style.position = "absolute"
el.style.left = `${pos.left + window.scrollX + model.caretX}px`
el.style.top = `${pos.top + window.scrollY + model.caretY}px`
el.style.pointerEvents = "none"
const pos = context.chart.canvas.getBoundingClientRect();
el.style.opacity = "1";
el.style.position = "absolute";
el.style.left = `${pos.left + window.scrollX + model.caretX}px`;
el.style.top = `${pos.top + window.scrollY + model.caretY}px`;
el.style.pointerEvents = "none";
}
function createTypedChart(
type: ChartType,
components: ChartComponent[]
): Component<TypedChartProps> {
const chartsWithScales: ChartType[] = ["bar", "line", "scatter"]
const chartsWithLegends: ChartType[] = ["bar", "line"]
function createTypedChart(type: ChartType, components: ChartComponent[]): Component<TypedChartProps> {
const chartsWithScales: ChartType[] = ["bar", "line", "scatter"];
const chartsWithLegends: ChartType[] = ["bar", "line"];
const options: ChartOptions = {
responsive: true,
@ -203,18 +202,18 @@ function createTypedChart(
? {
x: {
border: { display: false },
grid: { display: false }
grid: { display: false },
},
y: {
border: {
dash: [3],
dashOffset: 3,
display: false
display: false,
},
grid: {
color: "hsla(240, 3.8%, 46.1%, 0.4)"
}
}
color: "hsla(240, 3.8%, 46.1%, 0.4)",
},
},
}
: {},
plugins: {
@ -227,66 +226,61 @@ function createTypedChart(
boxWidth: 6,
boxHeight: 6,
color: "hsl(240, 3.8%, 46.1%)",
font: { size: 14 }
}
font: { size: 14 },
},
}
: { display: false },
tooltip: {
enabled: false,
external: (context) => showTooltip(context)
}
}
}
external: (context) => {
showTooltip(context);
},
},
},
};
Chart.register(...components)
return (props) => <BaseChart type={type} options={options} {...props} />
Chart.register(...components);
return (props) => (
<BaseChart
type={type}
options={options}
{...props}
/>
);
}
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 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", [
LineController,
LineElement,
PointElement,
CategoryScale,
LinearScale
])
const PieChart = /* #__PURE__ */ createTypedChart("pie", [PieController, ArcElement])
LinearScale,
]);
const PieChart = /* #__PURE__ */ createTypedChart("pie", [PieController, ArcElement]);
const PolarAreaChart = /* #__PURE__ */ createTypedChart("polarArea", [
PolarAreaController,
ArcElement,
RadialLinearScale
])
RadialLinearScale,
]);
const RadarChart = /* #__PURE__ */ createTypedChart("radar", [
RadarController,
LineElement,
PointElement,
RadialLinearScale
])
const ScatterChart = /* #__PURE__ */ createTypedChart("scatter", [
ScatterController,
PointElement,
LinearScale
])
RadialLinearScale,
]);
const ScatterChart = /* #__PURE__ */ createTypedChart("scatter", [ScatterController, PointElement, LinearScale]);
export {
BaseChart as Chart,
BarChart,
BubbleChart,
BaseChart as Chart,
DonutChart,
LineChart,
PieChart,
PolarAreaChart,
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 };

156
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 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;
@ -10,80 +13,87 @@ export const SQL = await initSqlJS({
locateFile: () => wasmURL,
});
const file = await fetch(dbURL).then((res) => res.arrayBuffer());
let rawDb: Accessor<Database | undefined>, setRawDb: Setter<Database | undefined>;
const testDb = new SQL.Database(new Uint8Array(file));
export const [db, setDb] = createSignal<Database>(testDb);
const createStatement = (sql: string) => {
return createMemo(() => {
return db().prepare(sql);
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));
[rawDb, setRawDb] = createSignal<Database>(testDb);
} else {
[rawDb, setRawDb] = createSignal<Database>();
}
export { rawDb as db, setRawDb as setDb };
const sqlJsDialect = () => {
const currentDb = rawDb();
if (currentDb) {
return new SqlJsDialect({
database: currentDb,
});
}
};
export const roomOverviewStmt = createStatement(`
SELECT
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
`);
const kyselyDb = createMemo(() => {
const currentSqlJsDialect = sqlJsDialect();
export type RoomOverviewColumn = {
recipient_id: number;
active: 0 | 1;
archived: 0 | 1;
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;
}
);
if (!currentSqlJsDialect) {
throw new Error("no db selected!");
}
export const overallSentMessagesStmt = createStatement(`
SELECT
COUNT(*) as message_count
FROM
message
WHERE (message.from_recipient_id = :recipient_id AND message.body IS NOT NULL AND message.body != '')
`);
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;
}>();
console.log(threadOverviewQuery.compile());
export const overallSentMessagesQuery = (recipientId: number) =>
kyselyDb()
.selectFrom("message")
.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 */
import { render } from "solid-js/web";
import "./index.css";
import App from "./App";
import { Router } from "@solidjs/router";
import App from "./App";
const root = document.getElementById("root");
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
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?",
);
}
render(
() => (
<Router>
<App />
</Router>
),
root!
);
if (root) {
render(
() => (
<div class="mx-auto max-w-screen-2xl">
<Router>
<App />
</Router>
</div>
),
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 { twMerge } from "tailwind-merge"
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
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,21 +1,24 @@
import { redirect, useNavigate, type RouteSectionProps } from "@solidjs/router";
import { type Component, type JSX } from "solid-js";
import { type RouteSectionProps, useNavigate } from "@solidjs/router";
import { setDb, SQL } from "~/db";
export const Home: Component<RouteSectionProps> = () => {
const navigate = useNavigate();
const onFileChange: JSX.ChangeEventHandler<HTMLInputElement, Event> = (event) => {
const file = event.currentTarget.files![0];
const reader = new FileReader();
const file = event.currentTarget.files?.[0];
if (file) {
const reader = new FileReader();
reader.addEventListener("load", () => {
const Uints = new Uint8Array(reader.result as ArrayBuffer);
setDb(new SQL.Database(Uints));
navigate("/overview");
});
reader.addEventListener("load", () => {
const Uints = new Uint8Array(reader.result as ArrayBuffer);
setDb(new SQL.Database(Uints));
navigate("/overview");
});
reader.readAsArrayBuffer(file);
reader.readAsArrayBuffer(file);
}
};
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 { defineConfig } from "vite";
import solidPlugin from "vite-plugin-solid";