feat(ui): basic dark mode, better day overview chart

This commit is contained in:
Samuel 2025-01-23 18:49:47 +01:00
parent d9a4088a2f
commit 3b58759102
8 changed files with 405 additions and 106 deletions

View file

@ -1,18 +1,104 @@
import { Route } from "@solidjs/router"; import { A, Route, Router, useNavigate } from "@solidjs/router";
import { type Component } from "solid-js"; import { createEffect, Show, Suspense, type Component } from "solid-js";
import { DmId, GroupId, Home, Overview, preloadDmId, Privacy } from "./pages"; import { DmId, GroupId, Home, Overview, preloadDmId, Privacy } from "./pages";
import "./app.css"; import "./app.css";
import { MetaProvider } from "@solidjs/meta";
import { Portal } from "solid-js/web";
import { Callout, CalloutTitle, CalloutContent } from "./components/ui/callout";
import { dbLoaded } from "./db";
import { hasCashedData } from "./lib/db-cache";
import { isWasmSupported } from "./lib/utils";
import { ColorModeProvider, ColorModeScript, createLocalStorageManager } from "@kobalte/core";
import { ModeToggle } from "./components/ui/mode-toggle";
const NO_DATA_NEEDED_PAGES = ["/", "/privacy"];
const App: Component = () => { const App: Component = () => {
const storageManager = createLocalStorageManager("vite-ui-theme");
return (
<div class="mx-auto max-w-(--breakpoint-2xl)">
<MetaProvider>
<Router
root={(props) => {
const navigate = useNavigate();
createEffect(() => {
if (!dbLoaded() && !hasCashedData() && !NO_DATA_NEEDED_PAGES.includes(props.location.pathname)) {
navigate("/");
}
});
const wasmSupport = isWasmSupported();
return ( return (
<> <>
<ColorModeScript storageType={storageManager.type} />
<ColorModeProvider storageManager={storageManager}>
<header class="flex justify-end bg-accent p-4">
<ModeToggle />
</header>
<Show when={!wasmSupport}>
<Portal>
<div class="fixed inset-0 mx-4 flex flex-col items-center justify-center backdrop-blur-lg">
<Callout variant="error">
Your browser does not support WebAssembly, which is required for this site to work with the
big amount of data a signal backup contains.
<br />
Please try a different browser.
</Callout>
</div>
</Portal>
</Show>
<Show
when={props.location.pathname !== "/" && !dbLoaded() && hasCashedData()}
fallback={
<Show when={!dbLoaded() && hasCashedData()}>
<Callout variant="default" class="m-4">
There is currently no backup database loaded, but you can watch statistics that have been
cached, meaning only chats you already opened or chats that were preloaded.
<br />
<A
href="/overview"
onClick={() => {
umami.track("Watch cached statistics");
}}
>
Watch cached statistics
</A>
</Callout>
</Show>
}
>
<Callout variant="warning" class="m-4">
<CalloutTitle>You are watching cached statistics</CalloutTitle>
<CalloutContent>
Currently there is no backup database loaded. You can only watch statistics that have been
cached, meaning only chats you already opened or chats that were preloaded.
<br />
<A href="/">Load a backup</A>
</CalloutContent>
</Callout>
</Show>
<main class="px-4 md:px-0">
<Suspense>{props.children}</Suspense>
</main>
<footer class="mt-4 flex flex-row justify-end bg-muted p-8">
<A href="/privacy">Privacy policy</A>
</footer>
</ColorModeProvider>
</>
);
}}
>
<Route path="/" component={Home} /> <Route path="/" component={Home} />
<Route path="/overview" component={Overview} /> <Route path="/overview" component={Overview} />
<Route path="/dm/:dmid" component={DmId} preload={preloadDmId} /> <Route path="/dm/:dmid" component={DmId} preload={preloadDmId} />
<Route path="/group/:groupid" component={GroupId} /> <Route path="/group/:groupid" component={GroupId} />
<Route path="/privacy" component={Privacy} /> <Route path="/privacy" component={Privacy} />{" "}
</> </Router>
</MetaProvider>
</div>
); );
}; };

View file

@ -28,8 +28,8 @@
--secondary: 240 4.8% 95.9%; --secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%; --secondary-foreground: 240 5.9% 10%;
--accent: 240 4.8% 95.9%; --accent: 226, 90%, 85%;
--accent-foreground: 240 5.9% 10%; --accent-foreground: 226, 90%, 57%;
--destructive: 0 84.2% 60.2%; --destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%; --destructive-foreground: 0 0% 98%;
@ -59,8 +59,8 @@
--muted: 240 3.7% 15.9%; --muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%; --muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%; --accent: 226, 90%, 20%;
--accent-foreground: 0 0% 98%; --accent-foreground: 226, 90%, 57%;
--popover: 240 10% 3.9%; --popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%; --popover-foreground: 0 0% 98%;

View file

@ -0,0 +1,260 @@
import type { Component, ComponentProps, JSX, ValidComponent } from "solid-js"
import { splitProps } from "solid-js"
import * as DropdownMenuPrimitive from "@kobalte/core/dropdown-menu"
import type { PolymorphicProps } from "@kobalte/core/polymorphic"
import { cn } from "~/lib/utils"
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenu: Component<DropdownMenuPrimitive.DropdownMenuRootProps> = (props) => {
return <DropdownMenuPrimitive.Root gutter={4} {...props} />
}
type DropdownMenuContentProps<T extends ValidComponent = "div"> =
DropdownMenuPrimitive.DropdownMenuContentProps<T> & {
class?: string | undefined
}
const DropdownMenuContent = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, DropdownMenuContentProps<T>>
) => {
const [, rest] = splitProps(props as DropdownMenuContentProps, ["class"])
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
class={cn(
"z-50 min-w-32 origin-[var(--kb-menu-content-transform-origin)] animate-content-hide overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[expanded]:animate-content-show",
props.class
)}
{...rest}
/>
</DropdownMenuPrimitive.Portal>
)
}
type DropdownMenuItemProps<T extends ValidComponent = "div"> =
DropdownMenuPrimitive.DropdownMenuItemProps<T> & {
class?: string | undefined
}
const DropdownMenuItem = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, DropdownMenuItemProps<T>>
) => {
const [, rest] = splitProps(props as DropdownMenuItemProps, ["class"])
return (
<DropdownMenuPrimitive.Item
class={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
props.class
)}
{...rest}
/>
)
}
const DropdownMenuShortcut: Component<ComponentProps<"span">> = (props) => {
const [, rest] = splitProps(props, ["class"])
return <span class={cn("ml-auto text-xs tracking-widest opacity-60", props.class)} {...rest} />
}
const DropdownMenuLabel: Component<ComponentProps<"div"> & { inset?: boolean }> = (props) => {
const [, rest] = splitProps(props, ["class", "inset"])
return (
<div
class={cn("px-2 py-1.5 text-sm font-semibold", props.inset && "pl-8", props.class)}
{...rest}
/>
)
}
type DropdownMenuSeparatorProps<T extends ValidComponent = "hr"> =
DropdownMenuPrimitive.DropdownMenuSeparatorProps<T> & {
class?: string | undefined
}
const DropdownMenuSeparator = <T extends ValidComponent = "hr">(
props: PolymorphicProps<T, DropdownMenuSeparatorProps<T>>
) => {
const [, rest] = splitProps(props as DropdownMenuSeparatorProps, ["class"])
return (
<DropdownMenuPrimitive.Separator
class={cn("-mx-1 my-1 h-px bg-muted", props.class)}
{...rest}
/>
)
}
type DropdownMenuSubTriggerProps<T extends ValidComponent = "div"> =
DropdownMenuPrimitive.DropdownMenuSubTriggerProps<T> & {
class?: string | undefined
children?: JSX.Element
}
const DropdownMenuSubTrigger = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, DropdownMenuSubTriggerProps<T>>
) => {
const [, rest] = splitProps(props as DropdownMenuSubTriggerProps, ["class", "children"])
return (
<DropdownMenuPrimitive.SubTrigger
class={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
props.class
)}
{...rest}
>
{props.children}
<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="ml-auto size-4"
>
<path d="M9 6l6 6l-6 6" />
</svg>
</DropdownMenuPrimitive.SubTrigger>
)
}
type DropdownMenuSubContentProps<T extends ValidComponent = "div"> =
DropdownMenuPrimitive.DropdownMenuSubContentProps<T> & {
class?: string | undefined
}
const DropdownMenuSubContent = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, DropdownMenuSubContentProps<T>>
) => {
const [, rest] = splitProps(props as DropdownMenuSubContentProps, ["class"])
return (
<DropdownMenuPrimitive.SubContent
class={cn(
"z-50 min-w-32 origin-[var(--kb-menu-content-transform-origin)] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in",
props.class
)}
{...rest}
/>
)
}
type DropdownMenuCheckboxItemProps<T extends ValidComponent = "div"> =
DropdownMenuPrimitive.DropdownMenuCheckboxItemProps<T> & {
class?: string | undefined
children?: JSX.Element
}
const DropdownMenuCheckboxItem = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, DropdownMenuCheckboxItemProps<T>>
) => {
const [, rest] = splitProps(props as DropdownMenuCheckboxItemProps, ["class", "children"])
return (
<DropdownMenuPrimitive.CheckboxItem
class={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
props.class
)}
{...rest}
>
<span class="absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<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>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{props.children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
type DropdownMenuGroupLabelProps<T extends ValidComponent = "span"> =
DropdownMenuPrimitive.DropdownMenuGroupLabelProps<T> & {
class?: string | undefined
}
const DropdownMenuGroupLabel = <T extends ValidComponent = "span">(
props: PolymorphicProps<T, DropdownMenuGroupLabelProps<T>>
) => {
const [, rest] = splitProps(props as DropdownMenuGroupLabelProps, ["class"])
return (
<DropdownMenuPrimitive.GroupLabel
class={cn("px-2 py-1.5 text-sm font-semibold", props.class)}
{...rest}
/>
)
}
type DropdownMenuRadioItemProps<T extends ValidComponent = "div"> =
DropdownMenuPrimitive.DropdownMenuRadioItemProps<T> & {
class?: string | undefined
children?: JSX.Element
}
const DropdownMenuRadioItem = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, DropdownMenuRadioItemProps<T>>
) => {
const [, rest] = splitProps(props as DropdownMenuRadioItemProps, ["class", "children"])
return (
<DropdownMenuPrimitive.RadioItem
class={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
props.class
)}
{...rest}
>
<span class="absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<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-2 fill-current"
>
<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
</svg>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{props.children}
</DropdownMenuPrimitive.RadioItem>
)
}
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuPortal,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuShortcut,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
DropdownMenuCheckboxItem,
DropdownMenuGroup,
DropdownMenuGroupLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem
}

View file

@ -0,0 +1,33 @@
import { useColorMode } from "@kobalte/core";
import { Button } from "./button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./dropdown-menu";
import { Sun, Moon, Laptop } from "lucide-solid";
export function ModeToggle() {
const { setColorMode } = useColorMode();
return (
<DropdownMenu>
<DropdownMenuTrigger as={Button<"button">} variant="ghost" size="sm" class="w-9 px-0">
<Sun class="dark:-rotate-90 size-6 rotate-0 scale-100 transition-all dark:scale-0" />
<Moon class="absolute size-6 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span class="sr-only">Toggle theme</span>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onSelect={() => setColorMode("light")}>
<Sun class="mr-2 size-4" />
<span>Light</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setColorMode("dark")}>
<Moon class="mr-2 size-4" />
<span>Dark</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setColorMode("system")}>
<Laptop class="mr-2 size-4" />
<span>System</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -1,14 +1,5 @@
/* @refresh reload */ import { render } from "solid-js/web";
import { MetaProvider } from "@solidjs/meta";
import { Router, useNavigate } from "@solidjs/router";
import { Portal, render } from "solid-js/web";
import App from "./App"; import App from "./App";
import { hasCashedData } from "./lib/db-cache";
import { createEffect, Show } from "solid-js";
import { dbLoaded } from "./db";
import { Callout, CalloutContent, CalloutTitle } from "./components/ui/callout";
import { A } from "./components/ui/A";
import { isWasmSupported } from "./lib/utils";
const root = document.getElementById("root"); const root = document.getElementById("root");
@ -26,82 +17,6 @@ if (import.meta.env.DEV && !("umami" in window)) {
}; };
} }
const NO_DATA_NEEDED_PAGES = ["/", "/privacy"];
if (root) { if (root) {
render( render(() => <App />, root);
() => (
<div class="mx-auto max-w-(--breakpoint-2xl)">
<MetaProvider>
<Router
root={(props) => {
const navigate = useNavigate();
createEffect(() => {
if (!dbLoaded() && !hasCashedData() && !NO_DATA_NEEDED_PAGES.includes(props.location.pathname)) {
navigate("/");
}
});
const wasmSupport = isWasmSupported();
return (
<>
<Show when={!wasmSupport}>
<Portal>
<div class="fixed inset-0 mx-4 flex flex-col items-center justify-center backdrop-blur-lg">
<Callout variant="error">
Your browser does not support WebAssembly, which is required for this site to work with the
big amount of data a signal backup contains.
<br />
Please try a different browser.
</Callout>
</div>
</Portal>
</Show>
<Show
when={props.location.pathname !== "/" && !dbLoaded() && hasCashedData()}
fallback={
<Show when={!dbLoaded() && hasCashedData()}>
<Callout variant="default" class="m-4">
There is currently no backup database loaded, but you can watch statistics that have been
cached, meaning only chats you already opened or chats that were preloaded.
<br />
<A
href="/overview"
onClick={() => {
umami.track("Watch cached statistics");
}}
>
Watch cached statistics
</A>
</Callout>
</Show>
}
>
<Callout variant="warning" class="m-4">
<CalloutTitle>You are watching cached statistics</CalloutTitle>
<CalloutContent>
Currently there is no backup database loaded. You can only watch statistics that have been
cached, meaning only chats you already opened or chats that were preloaded.
<br />
<A href="/">Load a backup</A>
</CalloutContent>
</Callout>
</Show>
<main>{props.children}</main>
<footer class="mt-4 flex flex-row justify-end bg-muted p-8">
<A href="/privacy">Privacy policy</A>
</footer>
</>
);
}}
>
<App />
</Router>
</MetaProvider>
</div>
),
root,
);
} }

View file

@ -21,6 +21,8 @@ export const DmMessagesPerDate: Component<{
label: "Total", label: "Total",
data: currentDmMessagesValues.map((row) => row.totalMessages), data: currentDmMessagesValues.map((row) => row.totalMessages),
borderWidth: 2, borderWidth: 2,
pointRadius: 0,
pointHitRadius: 6,
}, },
...currentRecipients.map((recipient) => { ...currentRecipients.map((recipient) => {
return { return {
@ -28,6 +30,8 @@ export const DmMessagesPerDate: Component<{
label: recipient.name.toString(), label: recipient.name.toString(),
data: currentDmMessagesValues.map((date) => date[recipient.recipientId]), data: currentDmMessagesValues.map((date) => date[recipient.recipientId]),
borderWidth: 2, borderWidth: 2,
pointRadius: 0,
pointHitRadius: 6,
}; };
}), }),
], ],
@ -41,7 +45,7 @@ export const DmMessagesPerDate: Component<{
<LineChart <LineChart
options={{ options={{
normalized: true, normalized: true,
aspectRatio: 3, responsive: true,
plugins: { plugins: {
zoom: { zoom: {
pan: { pan: {
@ -61,6 +65,7 @@ export const DmMessagesPerDate: Component<{
}, },
}} }}
data={currentDateChartData()} data={currentDateChartData()}
class="max-h-96"
/> />
)} )}
</Show> </Show>

View file

@ -23,7 +23,7 @@ export const DmOverview: Component<{
}; };
return ( return (
<Grid cols={1} colsMd={2} class="my-12 min-w-[35rem] gap-y-8 text-sm"> <Grid cols={1} colsMd={2} class="my-12 w-full gap-y-8 text-sm md:min-w-[35rem]">
<Flex flexDirection="row" justifyContent="evenly" class="bg-amber-200 p-2 text-amber-900"> <Flex flexDirection="row" justifyContent="evenly" class="bg-amber-200 p-2 text-amber-900">
<Flex alignItems="center" justifyContent="center" class="min-w-16"> <Flex alignItems="center" justifyContent="center" class="min-w-16">
<CalendarArrowDown class="h-8 w-8" /> <CalendarArrowDown class="h-8 w-8" />

View file

@ -122,16 +122,16 @@ export const Home: Component<RouteSectionProps> = () => {
</Flex> </Flex>
</Portal> </Portal>
<Title>Signal stats</Title> <Title>Signal stats</Title>
<form class="flex flex-col gap-y-8 p-8" onSubmit={onSubmit}> <form class="mx-auto flex w-full flex-col gap-y-8 p-8 md:w-fit" onSubmit={onSubmit}>
<TextField onChange={(value) => setPassphrase(value)}> <TextField onChange={(value) => setPassphrase(value)}>
<TextFieldLabel>Passphrase</TextFieldLabel> <TextFieldLabel>Passphrase</TextFieldLabel>
<TextFieldInput type="password" class="max-w-md" /> <TextFieldInput type="password" class="w-full md:w-sm" />
</TextField> </TextField>
<Flex <Flex
ref={dropzone.setRef} ref={dropzone.setRef}
justifyContent="center" justifyContent="center"
alignItems="center" alignItems="center"
class="relative min-h-32 min-w-96 max-w-xl rounded-lg border-4 border-border border-dashed" class="relative min-h-40 w-full rounded-lg border-4 border-border border-dashed md:w-xl"
classList={{ classList={{
"border-ring": dropzone.isDragging(), "border-ring": dropzone.isDragging(),
}} }}
@ -154,7 +154,7 @@ export const Home: Component<RouteSectionProps> = () => {
{backupFile() ? backupFile()?.name : "or drop the file here"} {backupFile() ? backupFile()?.name : "or drop the file here"}
</span> </span>
</Flex> </Flex>
<Button type="submit" class="max-w-72"> <Button type="submit" class="max-w-72 self-end md:w-sm">
Decrypt and load backup Decrypt and load backup
</Button> </Button>
</form> </form>