feat(ui): add sidebar and flesh out app routes
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
div.sidebar {
|
||||
@apply h-full w-auto max-w-60 border-none;
|
||||
@apply w-60 border-none;
|
||||
|
||||
& > 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,16 +1,24 @@
|
||||
import "./SidebarNavBody.css";
|
||||
import "./SidebarBody.css";
|
||||
import { A } from "@solidjs/router";
|
||||
import { Accordion } from "@kobalte/core/accordion";
|
||||
import Icon from "../Icon/Icon";
|
||||
import { Typography } from "@/src/components/Typography/Typography";
|
||||
import {
|
||||
MachineProps,
|
||||
SidebarNavProps,
|
||||
} from "@/src/components/Sidebar/SidebarNav";
|
||||
import { For } from "solid-js";
|
||||
import { For, Suspense } from "solid-js";
|
||||
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) => (
|
||||
<A href={buildMachinePath(props.clanURI, props.machineID)}>
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<Typography
|
||||
@@ -20,7 +28,7 @@ const MachineRoute = (props: MachineProps) => (
|
||||
color="primary"
|
||||
inverted={true}
|
||||
>
|
||||
{props.label}
|
||||
{props.name}
|
||||
</Typography>
|
||||
<MachineStatus status={props.status} />
|
||||
</div>
|
||||
@@ -37,10 +45,16 @@ const MachineRoute = (props: MachineProps) => (
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
</A>
|
||||
);
|
||||
|
||||
export const SidebarNavBody = (props: SidebarNavProps) => {
|
||||
const sectionLabels = props.extraSections.map((section) => section.label);
|
||||
export const SidebarBody = (props: SidebarProps) => {
|
||||
const clanURI = useClanURI();
|
||||
const machineList = useMachinesQuery(clanURI);
|
||||
|
||||
const sectionLabels = (props.staticSections || []).map(
|
||||
(section) => section.title,
|
||||
);
|
||||
|
||||
// controls which sections are open by default
|
||||
// we want them all to be open by default
|
||||
@@ -75,21 +89,27 @@ export const SidebarNavBody = (props: SidebarNavProps) => {
|
||||
</Accordion.Trigger>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content class="content">
|
||||
<Suspense fallback={"Loading..."}>
|
||||
<nav>
|
||||
<For each={props.clanDetail.machines}>
|
||||
{(machine) => (
|
||||
<A href={machine.path}>
|
||||
<MachineRoute {...machine} />
|
||||
</A>
|
||||
<For each={Object.entries(machineList.data || {})}>
|
||||
{([id, machine]) => (
|
||||
<MachineRoute
|
||||
clanURI={clanURI}
|
||||
machineID={id}
|
||||
name={machine.name || id}
|
||||
status="Not Installed"
|
||||
serviceCount={0}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</nav>
|
||||
</Suspense>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
|
||||
<For each={props.extraSections}>
|
||||
<For each={props.staticSections}>
|
||||
{(section) => (
|
||||
<Accordion.Item class="item" value={section.label}>
|
||||
<Accordion.Item class="item" value={section.title}>
|
||||
<Accordion.Header class="header">
|
||||
<Accordion.Trigger class="trigger">
|
||||
<Typography
|
||||
@@ -100,7 +120,7 @@ export const SidebarNavBody = (props: SidebarNavProps) => {
|
||||
inverted={true}
|
||||
color="tertiary"
|
||||
>
|
||||
{section.label}
|
||||
{section.title}
|
||||
</Typography>
|
||||
<Icon
|
||||
icon="CaretDown"
|
||||
@@ -15,10 +15,11 @@ div.sidebar-header {
|
||||
|
||||
transition: all 250ms ease-in-out;
|
||||
|
||||
div.title {
|
||||
div.clan-label {
|
||||
@apply flex items-center gap-2 justify-start;
|
||||
|
||||
& > .clan-icon {
|
||||
@apply flex justify-center items-center;
|
||||
@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 {
|
||||
@apply h-full w-auto max-w-60 border-none;
|
||||
@apply w-full max-w-60 border-none;
|
||||
|
||||
& > div.header {
|
||||
@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";
|
||||
|
||||
const meta: Meta<SidebarPaneProps> = {
|
||||
title: "Components/Sidebar/Pane",
|
||||
title: "Components/SidebarPane",
|
||||
component: SidebarPane,
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@ import { callApi } from "@/src/hooks/api";
|
||||
import { addClanURI, setActiveClanURI } from "@/src/stores/clan";
|
||||
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 () => {
|
||||
const req = callApi("get_clan_folder", {});
|
||||
const res = await req.result;
|
||||
@@ -20,12 +23,43 @@ export const selectClanFolder = async () => {
|
||||
throw new Error("Illegal state exception");
|
||||
};
|
||||
|
||||
export const navigateToClan = (navigate: Navigator, uri: string) => {
|
||||
navigate("/clans/" + window.btoa(uri));
|
||||
export const buildClanPath = (clanURI: string) => {
|
||||
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) => {
|
||||
return window.atob(params.clanURI);
|
||||
return decodeBase64(params.clanURI);
|
||||
};
|
||||
|
||||
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?",
|
||||
);
|
||||
}
|
||||
if (import.meta.env.DEV) {
|
||||
console.log("Development mode");
|
||||
}
|
||||
|
||||
render(
|
||||
() => (
|
||||
<QueryClientProvider client={client}>
|
||||
{import.meta.env.DEV && <SolidQueryDevtools />}
|
||||
{import.meta.env.DEV && <SolidQueryDevtools initialIsOpen={true} />}
|
||||
<Router root={Layout}>{Routes}</Router>
|
||||
</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 { encodeBase64 } from "@/src/hooks/clan";
|
||||
|
||||
export type ClanDetails = SuccessData<"get_clan_details">;
|
||||
export type ListMachines = SuccessData<"list_machines">;
|
||||
export type MachinesQueryResult = UseQueryResult<ListMachines>;
|
||||
|
||||
interface MachinesQueryParams {
|
||||
clanURI: string;
|
||||
}
|
||||
|
||||
export const useMachinesQuery = (props: MachinesQueryParams) =>
|
||||
export const useMachinesQuery = (clanURI: string) =>
|
||||
useQuery<ListMachines>(() => ({
|
||||
queryKey: ["clans", props.clanURI, "machines"],
|
||||
queryKey: ["clans", encodeBase64(clanURI), "machines"],
|
||||
queryFn: async () => {
|
||||
const api = callApi("list_machines", {
|
||||
flake: {
|
||||
identifier: props.clanURI,
|
||||
identifier: clanURI,
|
||||
},
|
||||
});
|
||||
const result = await api.result;
|
||||
@@ -25,3 +23,54 @@ export const useMachinesQuery = (props: MachinesQueryParams) =>
|
||||
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 {
|
||||
@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 { TextInput } from "@/src/components/Form/TextInput";
|
||||
import { createForm, FieldValues, reset } from "@modular-forms/solid";
|
||||
import { Sidebar } from "@/src/components/Sidebar/Sidebar";
|
||||
|
||||
export const Clan: Component<RouteSectionProps> = (props) => {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
}}
|
||||
>
|
||||
<Sidebar />
|
||||
{props.children}
|
||||
</div>
|
||||
<ClanSceneController />
|
||||
<ClanSceneController {...props} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -89,7 +84,7 @@ const MockCreateMachine = (props: MockProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const ClanSceneController = () => {
|
||||
const ClanSceneController = (props: RouteSectionProps) => {
|
||||
const clanURI = useClanURI();
|
||||
|
||||
const [dialogHandlers, setDialogHandlers] = createSignal<{
|
||||
@@ -232,7 +227,7 @@ const SceneDataProvider = (props: {
|
||||
clanURI: string;
|
||||
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
|
||||
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 { Onboarding } from "@/src/routes/Onboarding/Onboarding";
|
||||
import { Clan } from "@/src/routes/Clan/Clan";
|
||||
import { Machine } from "@/src/routes/Machine/Machine";
|
||||
|
||||
export const Routes: RouteDefinition[] = [
|
||||
{
|
||||
@@ -21,25 +22,14 @@ export const Routes: RouteDefinition[] = [
|
||||
},
|
||||
{
|
||||
path: "/:clanURI",
|
||||
children: [
|
||||
{
|
||||
path: "/",
|
||||
component: Clan,
|
||||
},
|
||||
{
|
||||
path: "/machines",
|
||||
children: [
|
||||
{
|
||||
path: "/",
|
||||
component: () => <h1>Machines (Index)</h1>,
|
||||
},
|
||||
{
|
||||
path: "/:machineID",
|
||||
component: (props) => (
|
||||
<h1>Machine ID: {props.params.machineID}</h1>
|
||||
),
|
||||
},
|
||||
],
|
||||
path: "/machines/:machineID",
|
||||
component: Machine,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -20,6 +20,14 @@ const [store, setStore] = makePersisted(
|
||||
},
|
||||
);
|
||||
|
||||
const resetStore = () => {
|
||||
setStore({
|
||||
clanURIs: [],
|
||||
activeClanURI: undefined,
|
||||
sceneData: {},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves the active clan URI from the store.
|
||||
*
|
||||
@@ -92,4 +100,5 @@ export {
|
||||
clanURIs,
|
||||
addClanURI,
|
||||
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