feat(ui): add sidebar and flesh out app routes
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
div.sidebar {
|
div.sidebar {
|
||||||
@apply h-full w-auto max-w-60 border-none;
|
@apply w-60 border-none;
|
||||||
|
|
||||||
& > div.header {
|
& > div.header {
|
||||||
}
|
}
|
||||||
157
pkgs/clan-app/ui/src/components/Sidebar/Sidebar.stories.tsx
Normal file
157
pkgs/clan-app/ui/src/components/Sidebar/Sidebar.stories.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import type { Meta, StoryObj } from "@kachurun/storybook-solid";
|
||||||
|
import {
|
||||||
|
createMemoryHistory,
|
||||||
|
MemoryRouter,
|
||||||
|
Route,
|
||||||
|
RouteSectionProps,
|
||||||
|
} from "@solidjs/router";
|
||||||
|
import { Sidebar } from "@/src/components/Sidebar/Sidebar";
|
||||||
|
import { Suspense } from "solid-js";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query";
|
||||||
|
import { addClanURI, resetStore } from "@/src/stores/clan";
|
||||||
|
import { SolidQueryDevtools } from "@tanstack/solid-query-devtools";
|
||||||
|
import { encodeBase64 } from "@/src/hooks/clan";
|
||||||
|
|
||||||
|
const defaultClanURI = "/home/brian/clans/my-clan";
|
||||||
|
|
||||||
|
const queryData = {
|
||||||
|
"/home/brian/clans/my-clan": {
|
||||||
|
details: {
|
||||||
|
name: "Brian's Clan",
|
||||||
|
uri: "/home/brian/clans/my-clan",
|
||||||
|
},
|
||||||
|
machines: {
|
||||||
|
europa: {
|
||||||
|
name: "Europa",
|
||||||
|
machineClass: "nixos",
|
||||||
|
},
|
||||||
|
ganymede: {
|
||||||
|
name: "Ganymede",
|
||||||
|
machineClass: "nixos",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/home/brian/clans/davhau": {
|
||||||
|
details: {
|
||||||
|
name: "Dave's Clan",
|
||||||
|
uri: "/home/brian/clans/davhau",
|
||||||
|
},
|
||||||
|
machines: {
|
||||||
|
callisto: {
|
||||||
|
name: "Callisto",
|
||||||
|
machineClass: "nixos",
|
||||||
|
},
|
||||||
|
amalthea: {
|
||||||
|
name: "Amalthea",
|
||||||
|
machineClass: "nixos",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/home/brian/clans/mic92": {
|
||||||
|
details: {
|
||||||
|
name: "Mic92's Clan",
|
||||||
|
uri: "/home/brian/clans/mic92",
|
||||||
|
},
|
||||||
|
machines: {
|
||||||
|
thebe: {
|
||||||
|
name: "Thebe",
|
||||||
|
machineClass: "nixos",
|
||||||
|
},
|
||||||
|
sponde: {
|
||||||
|
name: "Sponde",
|
||||||
|
machineClass: "nixos",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const staticSections = [
|
||||||
|
{
|
||||||
|
title: "Links",
|
||||||
|
links: [
|
||||||
|
{ label: "GitHub", path: "https://github.com/brian-the-dev" },
|
||||||
|
{ label: "Twitter", path: "https://twitter.com/brian_the_dev" },
|
||||||
|
{
|
||||||
|
label: "LinkedIn",
|
||||||
|
path: "https://www.linkedin.com/in/brian-the-dev/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Instagram",
|
||||||
|
path: "https://www.instagram.com/brian_the_dev/",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const meta: Meta<RouteSectionProps> = {
|
||||||
|
title: "Components/Sidebar",
|
||||||
|
component: Sidebar,
|
||||||
|
render: () => {
|
||||||
|
// set history to point to our test clan
|
||||||
|
const history = createMemoryHistory();
|
||||||
|
history.set({ value: `/clans/${encodeBase64(defaultClanURI)}` });
|
||||||
|
|
||||||
|
// reset local storage and then add each clan
|
||||||
|
resetStore();
|
||||||
|
|
||||||
|
Object.keys(queryData).forEach((uri) => addClanURI(uri));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style="height: 670px;">
|
||||||
|
<MemoryRouter
|
||||||
|
history={history}
|
||||||
|
root={(props) => <Suspense>{props.children}</Suspense>}
|
||||||
|
>
|
||||||
|
<Route
|
||||||
|
path="/clans/:clanURI"
|
||||||
|
component={() => <Sidebar staticSections={staticSections} />}
|
||||||
|
>
|
||||||
|
<Route path="/" />
|
||||||
|
<Route
|
||||||
|
path="/machines/:machineID"
|
||||||
|
component={() => <h1>Machine</h1>}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
</MemoryRouter>
|
||||||
|
<SolidQueryDevtools initialIsOpen={true} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<RouteSectionProps>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {},
|
||||||
|
decorators: [
|
||||||
|
(Story: StoryObj) => {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: false,
|
||||||
|
staleTime: Infinity,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.entries(queryData).forEach(([clanURI, clan]) => {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
["clans", encodeBase64(clanURI), "details"],
|
||||||
|
clan.details,
|
||||||
|
);
|
||||||
|
queryClient.setQueryData(
|
||||||
|
["clans", encodeBase64(clanURI), "machines"],
|
||||||
|
clan.machines || {},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Story />
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
28
pkgs/clan-app/ui/src/components/Sidebar/Sidebar.tsx
Normal file
28
pkgs/clan-app/ui/src/components/Sidebar/Sidebar.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import "./Sidebar.css";
|
||||||
|
import { SidebarHeader } from "@/src/components/Sidebar/SidebarHeader";
|
||||||
|
import { SidebarBody } from "@/src/components/Sidebar/SidebarBody";
|
||||||
|
|
||||||
|
export interface LinkProps {
|
||||||
|
path: string;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SectionProps {
|
||||||
|
title: string;
|
||||||
|
links: LinkProps[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SidebarProps {
|
||||||
|
staticSections?: SectionProps[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Sidebar = (props: SidebarProps) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div class="sidebar">
|
||||||
|
<SidebarHeader />
|
||||||
|
<SidebarBody {...props} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,46 +1,60 @@
|
|||||||
import "./SidebarNavBody.css";
|
import "./SidebarBody.css";
|
||||||
import { A } from "@solidjs/router";
|
import { A } from "@solidjs/router";
|
||||||
import { Accordion } from "@kobalte/core/accordion";
|
import { Accordion } from "@kobalte/core/accordion";
|
||||||
import Icon from "../Icon/Icon";
|
import Icon from "../Icon/Icon";
|
||||||
import { Typography } from "@/src/components/Typography/Typography";
|
import { Typography } from "@/src/components/Typography/Typography";
|
||||||
import {
|
import { For, Suspense } from "solid-js";
|
||||||
MachineProps,
|
|
||||||
SidebarNavProps,
|
|
||||||
} from "@/src/components/Sidebar/SidebarNav";
|
|
||||||
import { For } from "solid-js";
|
|
||||||
import { MachineStatus } from "@/src/components/MachineStatus/MachineStatus";
|
import { MachineStatus } from "@/src/components/MachineStatus/MachineStatus";
|
||||||
|
import { buildMachinePath, useClanURI } from "@/src/hooks/clan";
|
||||||
|
import { useMachinesQuery } from "@/src/queries/queries";
|
||||||
|
import { SidebarProps } from "./Sidebar";
|
||||||
|
|
||||||
|
interface MachineProps {
|
||||||
|
clanURI: string;
|
||||||
|
machineID: string;
|
||||||
|
name: string;
|
||||||
|
status: MachineStatus;
|
||||||
|
serviceCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
const MachineRoute = (props: MachineProps) => (
|
const MachineRoute = (props: MachineProps) => (
|
||||||
<div class="flex w-full flex-col gap-2">
|
<A href={buildMachinePath(props.clanURI, props.machineID)}>
|
||||||
<div class="flex flex-row items-center justify-between">
|
<div class="flex w-full flex-col gap-2">
|
||||||
<Typography
|
<div class="flex flex-row items-center justify-between">
|
||||||
hierarchy="label"
|
<Typography
|
||||||
size="xs"
|
hierarchy="label"
|
||||||
weight="bold"
|
size="xs"
|
||||||
color="primary"
|
weight="bold"
|
||||||
inverted={true}
|
color="primary"
|
||||||
>
|
inverted={true}
|
||||||
{props.label}
|
>
|
||||||
</Typography>
|
{props.name}
|
||||||
<MachineStatus status={props.status} />
|
</Typography>
|
||||||
|
<MachineStatus status={props.status} />
|
||||||
|
</div>
|
||||||
|
<div class="flex w-full flex-row items-center gap-1">
|
||||||
|
<Icon icon="Flash" size="0.75rem" inverted={true} color="tertiary" />
|
||||||
|
<Typography
|
||||||
|
hierarchy="label"
|
||||||
|
family="mono"
|
||||||
|
size="s"
|
||||||
|
inverted={true}
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
{props.serviceCount}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex w-full flex-row items-center gap-1">
|
</A>
|
||||||
<Icon icon="Flash" size="0.75rem" inverted={true} color="tertiary" />
|
|
||||||
<Typography
|
|
||||||
hierarchy="label"
|
|
||||||
family="mono"
|
|
||||||
size="s"
|
|
||||||
inverted={true}
|
|
||||||
color="primary"
|
|
||||||
>
|
|
||||||
{props.serviceCount}
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export const SidebarNavBody = (props: SidebarNavProps) => {
|
export const SidebarBody = (props: SidebarProps) => {
|
||||||
const sectionLabels = props.extraSections.map((section) => section.label);
|
const clanURI = useClanURI();
|
||||||
|
const machineList = useMachinesQuery(clanURI);
|
||||||
|
|
||||||
|
const sectionLabels = (props.staticSections || []).map(
|
||||||
|
(section) => section.title,
|
||||||
|
);
|
||||||
|
|
||||||
// controls which sections are open by default
|
// controls which sections are open by default
|
||||||
// we want them all to be open by default
|
// we want them all to be open by default
|
||||||
@@ -75,21 +89,27 @@ export const SidebarNavBody = (props: SidebarNavProps) => {
|
|||||||
</Accordion.Trigger>
|
</Accordion.Trigger>
|
||||||
</Accordion.Header>
|
</Accordion.Header>
|
||||||
<Accordion.Content class="content">
|
<Accordion.Content class="content">
|
||||||
<nav>
|
<Suspense fallback={"Loading..."}>
|
||||||
<For each={props.clanDetail.machines}>
|
<nav>
|
||||||
{(machine) => (
|
<For each={Object.entries(machineList.data || {})}>
|
||||||
<A href={machine.path}>
|
{([id, machine]) => (
|
||||||
<MachineRoute {...machine} />
|
<MachineRoute
|
||||||
</A>
|
clanURI={clanURI}
|
||||||
)}
|
machineID={id}
|
||||||
</For>
|
name={machine.name || id}
|
||||||
</nav>
|
status="Not Installed"
|
||||||
|
serviceCount={0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</nav>
|
||||||
|
</Suspense>
|
||||||
</Accordion.Content>
|
</Accordion.Content>
|
||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
|
|
||||||
<For each={props.extraSections}>
|
<For each={props.staticSections}>
|
||||||
{(section) => (
|
{(section) => (
|
||||||
<Accordion.Item class="item" value={section.label}>
|
<Accordion.Item class="item" value={section.title}>
|
||||||
<Accordion.Header class="header">
|
<Accordion.Header class="header">
|
||||||
<Accordion.Trigger class="trigger">
|
<Accordion.Trigger class="trigger">
|
||||||
<Typography
|
<Typography
|
||||||
@@ -100,7 +120,7 @@ export const SidebarNavBody = (props: SidebarNavProps) => {
|
|||||||
inverted={true}
|
inverted={true}
|
||||||
color="tertiary"
|
color="tertiary"
|
||||||
>
|
>
|
||||||
{section.label}
|
{section.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Icon
|
<Icon
|
||||||
icon="CaretDown"
|
icon="CaretDown"
|
||||||
@@ -15,10 +15,11 @@ div.sidebar-header {
|
|||||||
|
|
||||||
transition: all 250ms ease-in-out;
|
transition: all 250ms ease-in-out;
|
||||||
|
|
||||||
div.title {
|
div.clan-label {
|
||||||
@apply flex items-center gap-2 justify-start;
|
@apply flex items-center gap-2 justify-start;
|
||||||
|
|
||||||
& > .clan-icon {
|
& > .clan-icon {
|
||||||
|
@apply flex justify-center items-center;
|
||||||
@apply rounded-full bg-inv-4 w-7 h-7;
|
@apply rounded-full bg-inv-4 w-7 h-7;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
107
pkgs/clan-app/ui/src/components/Sidebar/SidebarHeader.tsx
Normal file
107
pkgs/clan-app/ui/src/components/Sidebar/SidebarHeader.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import "./SidebarHeader.css";
|
||||||
|
import Icon from "@/src/components/Icon/Icon";
|
||||||
|
import { DropdownMenu } from "@kobalte/core/dropdown-menu";
|
||||||
|
import { useNavigate } from "@solidjs/router";
|
||||||
|
import { Typography } from "../Typography/Typography";
|
||||||
|
import { createSignal, For, Suspense } from "solid-js";
|
||||||
|
import { useAllClanDetailsQuery } from "@/src/queries/queries";
|
||||||
|
import { navigateToClan, useClanURI } from "@/src/hooks/clan";
|
||||||
|
import { clanURIs } from "@/src/stores/clan";
|
||||||
|
|
||||||
|
export const SidebarHeader = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [open, setOpen] = createSignal(false);
|
||||||
|
|
||||||
|
// get information about the current active clan
|
||||||
|
const clanURI = useClanURI();
|
||||||
|
const allClans = useAllClanDetailsQuery(clanURIs());
|
||||||
|
|
||||||
|
const activeClan = () => allClans.find(({ data }) => data?.uri === clanURI);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<Suspense fallback={"Loading..."}>
|
||||||
|
<DropdownMenu open={open()} onOpenChange={setOpen} sameWidth={true}>
|
||||||
|
<DropdownMenu.Trigger class="dropdown-trigger">
|
||||||
|
<div class="clan-label">
|
||||||
|
<div class="clan-icon">
|
||||||
|
<Typography
|
||||||
|
hierarchy="label"
|
||||||
|
size="s"
|
||||||
|
weight="bold"
|
||||||
|
inverted={true}
|
||||||
|
>
|
||||||
|
{activeClan()?.data?.name.charAt(0).toUpperCase()}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
<Typography
|
||||||
|
hierarchy="label"
|
||||||
|
size="s"
|
||||||
|
weight="bold"
|
||||||
|
inverted={!open()}
|
||||||
|
>
|
||||||
|
{activeClan()?.data?.name}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
<DropdownMenu.Icon>
|
||||||
|
<Icon icon={"CaretDown"} inverted={!open()} size="0.75rem" />
|
||||||
|
</DropdownMenu.Icon>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Portal>
|
||||||
|
<DropdownMenu.Content class="sidebar-dropdown-content">
|
||||||
|
<DropdownMenu.Item
|
||||||
|
class="dropdown-item"
|
||||||
|
onSelect={() => navigateToClan(navigate, clanURI)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon="Settings"
|
||||||
|
size="0.75rem"
|
||||||
|
inverted={true}
|
||||||
|
color="tertiary"
|
||||||
|
/>
|
||||||
|
<Typography hierarchy="label" size="xs" weight="medium">
|
||||||
|
Settings
|
||||||
|
</Typography>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Group class="dropdown-group">
|
||||||
|
<DropdownMenu.GroupLabel class="dropdown-group-label">
|
||||||
|
<Typography
|
||||||
|
hierarchy="label"
|
||||||
|
family="mono"
|
||||||
|
size="xs"
|
||||||
|
color="tertiary"
|
||||||
|
>
|
||||||
|
YOUR CLANS
|
||||||
|
</Typography>
|
||||||
|
</DropdownMenu.GroupLabel>
|
||||||
|
<div class="dropdown-group-items">
|
||||||
|
<For each={allClans}>
|
||||||
|
{(clan) => (
|
||||||
|
<Suspense fallback={"Loading..."}>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
class="dropdown-item"
|
||||||
|
onSelect={() =>
|
||||||
|
navigateToClan(navigate, clan.data!.uri)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
hierarchy="label"
|
||||||
|
size="xs"
|
||||||
|
weight="medium"
|
||||||
|
>
|
||||||
|
{clan.data?.name}
|
||||||
|
</Typography>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</DropdownMenu.Group>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Portal>
|
||||||
|
</DropdownMenu>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
import type { Meta, StoryObj } from "@kachurun/storybook-solid";
|
|
||||||
import {
|
|
||||||
createMemoryHistory,
|
|
||||||
MemoryRouter,
|
|
||||||
Route,
|
|
||||||
RouteSectionProps,
|
|
||||||
} from "@solidjs/router";
|
|
||||||
import {
|
|
||||||
SidebarNav,
|
|
||||||
SidebarNavProps,
|
|
||||||
} from "@/src/components/Sidebar/SidebarNav";
|
|
||||||
import { Suspense } from "solid-js";
|
|
||||||
import { StoryContext } from "@kachurun/storybook-solid-vite";
|
|
||||||
|
|
||||||
const sidebarNavProps: SidebarNavProps = {
|
|
||||||
clanLinks: [
|
|
||||||
{ label: "Brian's Clan", path: "/clans/1" },
|
|
||||||
{ label: "Dave's Clan", path: "/clans/2" },
|
|
||||||
{ label: "Mic92's Clan", path: "/clans/3" },
|
|
||||||
],
|
|
||||||
clanDetail: {
|
|
||||||
label: "Brian's Clan",
|
|
||||||
settingsPath: "/clans/1/settings",
|
|
||||||
machines: [
|
|
||||||
{
|
|
||||||
label: "Backup & Home",
|
|
||||||
path: "/clans/1/machine/backup",
|
|
||||||
serviceCount: 3,
|
|
||||||
status: "Online",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Raspberry Pi",
|
|
||||||
path: "/clans/1/machine/pi",
|
|
||||||
serviceCount: 1,
|
|
||||||
status: "Offline",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Mom's Laptop",
|
|
||||||
path: "/clans/1/machine/moms-laptop",
|
|
||||||
serviceCount: 2,
|
|
||||||
status: "Installed",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Dad's Laptop",
|
|
||||||
path: "/clans/1/machine/dads-laptop",
|
|
||||||
serviceCount: 4,
|
|
||||||
status: "Not Installed",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
extraSections: [
|
|
||||||
{
|
|
||||||
label: "Tools",
|
|
||||||
links: [
|
|
||||||
{ label: "Borgbackup", path: "/clans/1/service/borgbackup" },
|
|
||||||
{ label: "Syncthing", path: "/clans/1/service/syncthing" },
|
|
||||||
{ label: "Mumble", path: "/clans/1/service/mumble" },
|
|
||||||
{ label: "Minecraft", path: "/clans/1/service/minecraft" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Links",
|
|
||||||
links: [
|
|
||||||
{ label: "GitHub", path: "https://github.com/brian-the-dev" },
|
|
||||||
{ label: "Twitter", path: "https://twitter.com/brian_the_dev" },
|
|
||||||
{
|
|
||||||
label: "LinkedIn",
|
|
||||||
path: "https://www.linkedin.com/in/brian-the-dev/",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Instagram",
|
|
||||||
path: "https://www.instagram.com/brian_the_dev/",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const meta: Meta<RouteSectionProps> = {
|
|
||||||
title: "Components/Sidebar/Nav",
|
|
||||||
component: SidebarNav,
|
|
||||||
render: (_: never, context: StoryContext<SidebarNavProps>) => {
|
|
||||||
const history = createMemoryHistory();
|
|
||||||
history.set({ value: "/clans/1/machine/backup" });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style="height: 670px;">
|
|
||||||
<MemoryRouter
|
|
||||||
history={history}
|
|
||||||
root={(props) => (
|
|
||||||
<Suspense>
|
|
||||||
<SidebarNav {...sidebarNavProps} />
|
|
||||||
</Suspense>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Route path="/clans/1/machine/backup" component={(props) => <></>} />
|
|
||||||
</MemoryRouter>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
|
|
||||||
type Story = StoryObj<RouteSectionProps>;
|
|
||||||
|
|
||||||
export const Default: Story = {
|
|
||||||
args: {},
|
|
||||||
};
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import "./SidebarNav.css";
|
|
||||||
import { SidebarNavHeader } from "@/src/components/Sidebar/SidebarNavHeader";
|
|
||||||
import { SidebarNavBody } from "@/src/components/Sidebar/SidebarNavBody";
|
|
||||||
import { MachineStatus } from "@/src/components/MachineStatus/MachineStatus";
|
|
||||||
|
|
||||||
export interface LinkProps {
|
|
||||||
path: string;
|
|
||||||
label?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SectionProps {
|
|
||||||
label: string;
|
|
||||||
links: LinkProps[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MachineProps {
|
|
||||||
label: string;
|
|
||||||
path: string;
|
|
||||||
status: MachineStatus;
|
|
||||||
serviceCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ClanLinkProps {
|
|
||||||
label: string;
|
|
||||||
path: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ClanProps {
|
|
||||||
label: string;
|
|
||||||
settingsPath: string;
|
|
||||||
machines: MachineProps[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SidebarNavProps {
|
|
||||||
clanDetail: ClanProps;
|
|
||||||
clanLinks: ClanLinkProps[];
|
|
||||||
extraSections: SectionProps[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SidebarNav = (props: SidebarNavProps) => {
|
|
||||||
return (
|
|
||||||
<div class="sidebar">
|
|
||||||
<SidebarNavHeader {...props} />
|
|
||||||
<SidebarNavBody {...props} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import "./SidebarNavHeader.css";
|
|
||||||
import Icon from "@/src/components/Icon/Icon";
|
|
||||||
import { DropdownMenu } from "@kobalte/core/dropdown-menu";
|
|
||||||
import { useNavigate } from "@solidjs/router";
|
|
||||||
import { Typography } from "../Typography/Typography";
|
|
||||||
import { createSignal, For } from "solid-js";
|
|
||||||
import { ClanLinkProps, ClanProps } from "@/src/components/Sidebar/SidebarNav";
|
|
||||||
|
|
||||||
export interface SidebarHeaderProps {
|
|
||||||
clanDetail: ClanProps;
|
|
||||||
clanLinks: ClanLinkProps[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SidebarNavHeader = (props: SidebarHeaderProps) => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const [open, setOpen] = createSignal(false);
|
|
||||||
|
|
||||||
const firstChar = props.clanDetail.label.charAt(0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="sidebar-header">
|
|
||||||
<DropdownMenu open={open()} onOpenChange={setOpen} sameWidth={true}>
|
|
||||||
<DropdownMenu.Trigger class="dropdown-trigger">
|
|
||||||
<div class="title">
|
|
||||||
<div class="clan-icon">
|
|
||||||
<Typography
|
|
||||||
hierarchy="label"
|
|
||||||
size="s"
|
|
||||||
weight="bold"
|
|
||||||
inverted={true}
|
|
||||||
>
|
|
||||||
{firstChar.toUpperCase()}
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
<Typography
|
|
||||||
hierarchy="label"
|
|
||||||
size="s"
|
|
||||||
weight="bold"
|
|
||||||
inverted={!open()}
|
|
||||||
>
|
|
||||||
{props.clanDetail.label}
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
<DropdownMenu.Icon>
|
|
||||||
<Icon icon={"CaretDown"} inverted={!open()} size="0.75rem" />
|
|
||||||
</DropdownMenu.Icon>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Portal>
|
|
||||||
<DropdownMenu.Content class="sidebar-dropdown-content">
|
|
||||||
<DropdownMenu.Item
|
|
||||||
class="dropdown-item"
|
|
||||||
onSelect={() => navigate(props.clanDetail.settingsPath)}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
icon="Settings"
|
|
||||||
size="0.75rem"
|
|
||||||
inverted={true}
|
|
||||||
color="tertiary"
|
|
||||||
/>
|
|
||||||
<Typography hierarchy="label" size="xs" weight="medium">
|
|
||||||
Settings
|
|
||||||
</Typography>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Group class="dropdown-group">
|
|
||||||
<DropdownMenu.GroupLabel class="dropdown-group-label">
|
|
||||||
<Typography
|
|
||||||
hierarchy="label"
|
|
||||||
family="mono"
|
|
||||||
size="xs"
|
|
||||||
color="tertiary"
|
|
||||||
>
|
|
||||||
YOUR CLANS
|
|
||||||
</Typography>
|
|
||||||
</DropdownMenu.GroupLabel>
|
|
||||||
<div class="dropdown-group-items">
|
|
||||||
<For each={props.clanLinks}>
|
|
||||||
{(clan) => (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
class="dropdown-item"
|
|
||||||
onSelect={() => navigate(clan.path)}
|
|
||||||
>
|
|
||||||
<Typography hierarchy="label" size="xs" weight="medium">
|
|
||||||
{clan.label}
|
|
||||||
</Typography>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</DropdownMenu.Group>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Portal>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
div.sidebar-pane {
|
div.sidebar-pane {
|
||||||
@apply h-full w-auto max-w-60 border-none;
|
@apply w-full max-w-60 border-none;
|
||||||
|
|
||||||
& > div.header {
|
& > div.header {
|
||||||
@apply flex items-center justify-between px-3 py-2 rounded-t-[0.5rem];
|
@apply flex items-center justify-between px-3 py-2 rounded-t-[0.5rem];
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { Checkbox } from "@/src/components/Form/Checkbox";
|
|||||||
import { Combobox } from "../Form/Combobox";
|
import { Combobox } from "../Form/Combobox";
|
||||||
|
|
||||||
const meta: Meta<SidebarPaneProps> = {
|
const meta: Meta<SidebarPaneProps> = {
|
||||||
title: "Components/Sidebar/Pane",
|
title: "Components/SidebarPane",
|
||||||
component: SidebarPane,
|
component: SidebarPane,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import { callApi } from "@/src/hooks/api";
|
|||||||
import { addClanURI, setActiveClanURI } from "@/src/stores/clan";
|
import { addClanURI, setActiveClanURI } from "@/src/stores/clan";
|
||||||
import { Params, Navigator, useParams } from "@solidjs/router";
|
import { Params, Navigator, useParams } from "@solidjs/router";
|
||||||
|
|
||||||
|
export const encodeBase64 = (value: string) => window.btoa(value);
|
||||||
|
export const decodeBase64 = (value: string) => window.atob(value);
|
||||||
|
|
||||||
export const selectClanFolder = async () => {
|
export const selectClanFolder = async () => {
|
||||||
const req = callApi("get_clan_folder", {});
|
const req = callApi("get_clan_folder", {});
|
||||||
const res = await req.result;
|
const res = await req.result;
|
||||||
@@ -20,12 +23,43 @@ export const selectClanFolder = async () => {
|
|||||||
throw new Error("Illegal state exception");
|
throw new Error("Illegal state exception");
|
||||||
};
|
};
|
||||||
|
|
||||||
export const navigateToClan = (navigate: Navigator, uri: string) => {
|
export const buildClanPath = (clanURI: string) => {
|
||||||
navigate("/clans/" + window.btoa(uri));
|
return "/clans/" + encodeBase64(clanURI);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildMachinePath = (clanURI: string, machineID: string) => {
|
||||||
|
return (
|
||||||
|
"/clans/" + encodeBase64(clanURI) + "/machines/" + encodeBase64(machineID)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const navigateToClan = (navigate: Navigator, clanURI: string) => {
|
||||||
|
const path = buildClanPath(clanURI);
|
||||||
|
console.log("Navigating to clan", clanURI, path);
|
||||||
|
navigate(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const navigateToMachine = (
|
||||||
|
navigate: Navigator,
|
||||||
|
clanURI: string,
|
||||||
|
machineID: string,
|
||||||
|
) => {
|
||||||
|
const path = buildMachinePath(clanURI, machineID);
|
||||||
|
console.log("Navigating to machine", clanURI, machineID, path);
|
||||||
|
navigate(path);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const clanURIParam = (params: Params) => {
|
export const clanURIParam = (params: Params) => {
|
||||||
return window.atob(params.clanURI);
|
return decodeBase64(params.clanURI);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useClanURI = () => clanURIParam(useParams());
|
export const useClanURI = () => clanURIParam(useParams());
|
||||||
|
|
||||||
|
export const machineIDParam = (params: Params) => {
|
||||||
|
return decodeBase64(params.machineID);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useMachineID = (): string => {
|
||||||
|
const params = useParams();
|
||||||
|
return machineIDParam(params);
|
||||||
|
};
|
||||||
|
|||||||
@@ -17,11 +17,14 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
|
|||||||
"Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?",
|
"Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.log("Development mode");
|
||||||
|
}
|
||||||
|
|
||||||
render(
|
render(
|
||||||
() => (
|
() => (
|
||||||
<QueryClientProvider client={client}>
|
<QueryClientProvider client={client}>
|
||||||
{import.meta.env.DEV && <SolidQueryDevtools />}
|
{import.meta.env.DEV && <SolidQueryDevtools initialIsOpen={true} />}
|
||||||
<Router root={Layout}>{Routes}</Router>
|
<Router root={Layout}>{Routes}</Router>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
import { useQuery, UseQueryResult } from "@tanstack/solid-query";
|
import { useQueries, useQuery, UseQueryResult } from "@tanstack/solid-query";
|
||||||
import { callApi, SuccessData } from "../hooks/api";
|
import { callApi, SuccessData } from "../hooks/api";
|
||||||
|
import { encodeBase64 } from "@/src/hooks/clan";
|
||||||
|
|
||||||
|
export type ClanDetails = SuccessData<"get_clan_details">;
|
||||||
export type ListMachines = SuccessData<"list_machines">;
|
export type ListMachines = SuccessData<"list_machines">;
|
||||||
export type MachinesQueryResult = UseQueryResult<ListMachines>;
|
export type MachinesQueryResult = UseQueryResult<ListMachines>;
|
||||||
|
|
||||||
interface MachinesQueryParams {
|
export const useMachinesQuery = (clanURI: string) =>
|
||||||
clanURI: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useMachinesQuery = (props: MachinesQueryParams) =>
|
|
||||||
useQuery<ListMachines>(() => ({
|
useQuery<ListMachines>(() => ({
|
||||||
queryKey: ["clans", props.clanURI, "machines"],
|
queryKey: ["clans", encodeBase64(clanURI), "machines"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const api = callApi("list_machines", {
|
const api = callApi("list_machines", {
|
||||||
flake: {
|
flake: {
|
||||||
identifier: props.clanURI,
|
identifier: clanURI,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const result = await api.result;
|
const result = await api.result;
|
||||||
@@ -25,3 +23,54 @@ export const useMachinesQuery = (props: MachinesQueryParams) =>
|
|||||||
return result.data;
|
return result.data;
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const useClanDetailsQuery = (clanURI: string) =>
|
||||||
|
useQuery<ClanDetails>(() => ({
|
||||||
|
queryKey: ["clans", encodeBase64(clanURI), "details"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const call = callApi("get_clan_details", {
|
||||||
|
flake: {
|
||||||
|
identifier: clanURI,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await call.result;
|
||||||
|
|
||||||
|
if (result.status === "error") {
|
||||||
|
// todo should we create some specific error types?
|
||||||
|
console.error("Error fetching clan details:", result.errors);
|
||||||
|
throw new Error(result.errors[0].message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
uri: clanURI,
|
||||||
|
...result.data,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const useAllClanDetailsQuery = (clanURIs: string[]) =>
|
||||||
|
useQueries(() => ({
|
||||||
|
queries: clanURIs.map((clanURI) => ({
|
||||||
|
queryKey: ["clans", encodeBase64(clanURI), "details"],
|
||||||
|
enabled: !!clanURI,
|
||||||
|
queryFn: async () => {
|
||||||
|
const call = callApi("get_clan_details", {
|
||||||
|
flake: {
|
||||||
|
identifier: clanURI,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await call.result;
|
||||||
|
|
||||||
|
if (result.status === "error") {
|
||||||
|
// todo should we create some specific error types?
|
||||||
|
console.error("Error fetching clan details:", result.errors);
|
||||||
|
throw new Error(result.errors[0].message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
uri: clanURI,
|
||||||
|
...result.data,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|||||||
@@ -11,3 +11,14 @@
|
|||||||
.create-modal {
|
.create-modal {
|
||||||
@apply min-w-96;
|
@apply min-w-96;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-container {
|
||||||
|
}
|
||||||
|
|
||||||
|
div.sidebar {
|
||||||
|
@apply absolute top-10 bottom-20 left-4 w-60;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.sidebar-pane {
|
||||||
|
@apply absolute top-12 bottom-20 left-[16.5rem] w-64;
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,19 +13,14 @@ import "./Clan.css";
|
|||||||
import { Modal } from "@/src/components/Modal/Modal";
|
import { Modal } from "@/src/components/Modal/Modal";
|
||||||
import { TextInput } from "@/src/components/Form/TextInput";
|
import { TextInput } from "@/src/components/Form/TextInput";
|
||||||
import { createForm, FieldValues, reset } from "@modular-forms/solid";
|
import { createForm, FieldValues, reset } from "@modular-forms/solid";
|
||||||
|
import { Sidebar } from "@/src/components/Sidebar/Sidebar";
|
||||||
|
|
||||||
export const Clan: Component<RouteSectionProps> = (props) => {
|
export const Clan: Component<RouteSectionProps> = (props) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<Sidebar />
|
||||||
style={{
|
{props.children}
|
||||||
position: "absolute",
|
<ClanSceneController {...props} />
|
||||||
top: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{props.children}
|
|
||||||
</div>
|
|
||||||
<ClanSceneController />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -89,7 +84,7 @@ const MockCreateMachine = (props: MockProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ClanSceneController = () => {
|
const ClanSceneController = (props: RouteSectionProps) => {
|
||||||
const clanURI = useClanURI();
|
const clanURI = useClanURI();
|
||||||
|
|
||||||
const [dialogHandlers, setDialogHandlers] = createSignal<{
|
const [dialogHandlers, setDialogHandlers] = createSignal<{
|
||||||
@@ -232,7 +227,7 @@ const SceneDataProvider = (props: {
|
|||||||
clanURI: string;
|
clanURI: string;
|
||||||
children: (sceneData: { query: MachinesQueryResult }) => JSX.Element;
|
children: (sceneData: { query: MachinesQueryResult }) => JSX.Element;
|
||||||
}) => {
|
}) => {
|
||||||
const machinesQuery = useMachinesQuery({ clanURI: props.clanURI });
|
const machinesQuery = useMachinesQuery(props.clanURI);
|
||||||
|
|
||||||
// This component can be used to provide scene data or context if needed
|
// This component can be used to provide scene data or context if needed
|
||||||
return props.children({ query: machinesQuery });
|
return props.children({ query: machinesQuery });
|
||||||
|
|||||||
19
pkgs/clan-app/ui/src/routes/Machine/Machine.tsx
Normal file
19
pkgs/clan-app/ui/src/routes/Machine/Machine.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { RouteSectionProps, useNavigate } from "@solidjs/router";
|
||||||
|
import { SidebarPane } from "@/src/components/Sidebar/SidebarPane";
|
||||||
|
import { navigateToClan, useClanURI, useMachineID } from "@/src/hooks/clan";
|
||||||
|
|
||||||
|
export const Machine = (props: RouteSectionProps) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const clanURI = useClanURI();
|
||||||
|
|
||||||
|
const onClose = () => {
|
||||||
|
// go back to clan route
|
||||||
|
navigateToClan(navigate, clanURI);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarPane title={useMachineID()} onClose={onClose}>
|
||||||
|
<h1>Hello world</h1>
|
||||||
|
</SidebarPane>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { RouteDefinition } from "@solidjs/router/dist/types";
|
import type { RouteDefinition } from "@solidjs/router/dist/types";
|
||||||
import { Onboarding } from "@/src/routes/Onboarding/Onboarding";
|
import { Onboarding } from "@/src/routes/Onboarding/Onboarding";
|
||||||
import { Clan } from "@/src/routes/Clan/Clan";
|
import { Clan } from "@/src/routes/Clan/Clan";
|
||||||
|
import { Machine } from "@/src/routes/Machine/Machine";
|
||||||
|
|
||||||
export const Routes: RouteDefinition[] = [
|
export const Routes: RouteDefinition[] = [
|
||||||
{
|
{
|
||||||
@@ -21,25 +22,14 @@ export const Routes: RouteDefinition[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/:clanURI",
|
path: "/:clanURI",
|
||||||
|
component: Clan,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: "/",
|
path: "/",
|
||||||
component: Clan,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/machines",
|
path: "/machines/:machineID",
|
||||||
children: [
|
component: Machine,
|
||||||
{
|
|
||||||
path: "/",
|
|
||||||
component: () => <h1>Machines (Index)</h1>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/:machineID",
|
|
||||||
component: (props) => (
|
|
||||||
<h1>Machine ID: {props.params.machineID}</h1>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,6 +20,14 @@ const [store, setStore] = makePersisted(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const resetStore = () => {
|
||||||
|
setStore({
|
||||||
|
clanURIs: [],
|
||||||
|
activeClanURI: undefined,
|
||||||
|
sceneData: {},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves the active clan URI from the store.
|
* Retrieves the active clan URI from the store.
|
||||||
*
|
*
|
||||||
@@ -92,4 +100,5 @@ export {
|
|||||||
clanURIs,
|
clanURIs,
|
||||||
addClanURI,
|
addClanURI,
|
||||||
removeClanURI,
|
removeClanURI,
|
||||||
|
resetStore,
|
||||||
};
|
};
|
||||||
|
|||||||
1
pkgs/clan-cli/clan_cli/nixpkgs
Symbolic link
1
pkgs/clan-cli/clan_cli/nixpkgs
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/nix/store/q012qk78pwldxl3qjy09nwrx9jlamivm-nixpkgs
|
||||||
Reference in New Issue
Block a user