feat: typed database with kysely, updated config
This commit is contained in:
parent
28ec24b2c2
commit
67da0a72db
24 changed files with 1656 additions and 434 deletions
120
eslint.config.js
120
eslint.config.js
|
@ -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",
|
||||
},
|
||||
},
|
||||
]);
|
||||
],
|
||||
);
|
||||
|
|
10
package.json
10
package.json
|
@ -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
528
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
44
src/components/ui/badge.tsx
Normal file
44
src/components/ui/badge.tsx
Normal 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 };
|
51
src/components/ui/button.tsx
Normal file
51
src/components/ui/button.tsx
Normal 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 };
|
|
@ -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,
|
||||
};
|
||||
|
|
59
src/components/ui/checkbox.tsx
Normal file
59
src/components/ui/checkbox.tsx
Normal 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 };
|
19
src/components/ui/label.tsx
Normal file
19
src/components/ui/label.tsx
Normal 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 };
|
91
src/components/ui/table.tsx
Normal file
91
src/components/ui/table.tsx
Normal 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 };
|
147
src/components/ui/text-field.tsx
Normal file
147
src/components/ui/text-field.tsx
Normal 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
156
src/db.ts
|
@ -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", "!=", ""),
|
||||
]),
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
];
|
|
@ -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
8
src/pages/chat/index.tsx
Normal 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();
|
||||
};
|
|
@ -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 (
|
||||
|
|
|
@ -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;
|
49
src/pages/overview/index.tsx
Normal file
49
src/pages/overview/index.tsx
Normal 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;
|
347
src/pages/overview/overview-table.tsx
Normal file
347
src/pages/overview/overview-table.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -1,5 +1,4 @@
|
|||
import path from "path";
|
||||
|
||||
import { defineConfig } from "vite";
|
||||
import solidPlugin from "vite-plugin-solid";
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue