feat(ui): introduce a top-level Clan context

This commit is contained in:
Brian McGee
2025-08-21 12:15:42 +01:00
parent a77af2d379
commit f985187999
7 changed files with 160 additions and 122 deletions

View File

@@ -5,15 +5,20 @@ import { Typography } from "@/src/components/Typography/Typography";
import { Button } from "@/src/components/Button/Button";
import { navigateToClan, navigateToOnboarding } from "@/src/hooks/clan";
import { useNavigate } from "@solidjs/router";
import { For } from "solid-js";
import { For, Show } from "solid-js";
import { activeClanURI, clanURIs, setActiveClanURI } from "@/src/stores/clan";
import { useClanListQuery } from "@/src/hooks/queries";
import { Alert } from "@/src/components/Alert/Alert";
export interface ClansModalProps {
export interface ListClansModalProps {
onClose: () => void;
error?: {
title: string;
description: string;
};
}
export const ListClansModal = (props: ClansModalProps) => {
export const ListClansModal = (props: ListClansModalProps) => {
const navigate = useNavigate();
const query = useClanListQuery(clanURIs());
@@ -38,6 +43,14 @@ export const ListClansModal = (props: ClansModalProps) => {
class={cx(styles.modal)}
>
<div class={cx(styles.content)}>
<Show when={props.error}>
<Alert
type="error"
title={props.error?.title || ""}
description={props.error?.description}
/>
</Show>
<div class={cx(styles.header)}>
<Typography
hierarchy="label"

View File

@@ -3,11 +3,12 @@ import { A } from "@solidjs/router";
import { Accordion } from "@kobalte/core/accordion";
import Icon from "../Icon/Icon";
import { Typography } from "@/src/components/Typography/Typography";
import { For } from "solid-js";
import { For, useContext } from "solid-js";
import { MachineStatus } from "@/src/components/MachineStatus/MachineStatus";
import { buildMachinePath, useClanURI } from "@/src/hooks/clan";
import { useMachinesQuery, useMachineStateQuery } from "@/src/hooks/queries";
import { useMachineStateQuery } from "@/src/hooks/queries";
import { SidebarProps } from "./Sidebar";
import { ClanContext } from "@/src/routes/Clan/Clan";
interface MachineProps {
clanURI: string;
@@ -56,7 +57,11 @@ const MachineRoute = (props: MachineProps) => {
export const SidebarBody = (props: SidebarProps) => {
const clanURI = useClanURI();
const machineList = useMachinesQuery(clanURI);
const ctx = useContext(ClanContext);
if (!ctx) {
throw new Error("ClanContext not found");
}
const sectionLabels = (props.staticSections || []).map(
(section) => section.title,
@@ -96,7 +101,7 @@ export const SidebarBody = (props: SidebarProps) => {
</Accordion.Header>
<Accordion.Content class="content">
<nav>
<For each={Object.entries(machineList.data || {})}>
<For each={Object.entries(ctx.machinesQuery.data || {})}>
{([id, machine]) => (
<MachineRoute
clanURI={clanURI}

View File

@@ -3,15 +3,15 @@ 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 { useClanListQuery } from "@/src/hooks/queries";
import { createSignal, For, Suspense, useContext } from "solid-js";
import {
navigateToClan,
navigateToOnboarding,
useClanURI,
} from "@/src/hooks/clan";
import { clanURIs, setActiveClanURI } from "@/src/stores/clan";
import { setActiveClanURI } from "@/src/stores/clan";
import { Button } from "../Button/Button";
import { ClanContext } from "@/src/routes/Clan/Clan";
export const SidebarHeader = () => {
const navigate = useNavigate();
@@ -19,10 +19,19 @@ export const SidebarHeader = () => {
const [open, setOpen] = createSignal(false);
// get information about the current active clan
const clanURI = useClanURI();
const allClans = useClanListQuery(clanURIs());
const ctx = useContext(ClanContext);
const activeClan = () => allClans.find(({ data }) => data?.uri === clanURI);
if (!ctx) {
throw new Error("SidebarContext not found");
}
const clanURI = useClanURI();
const clanChar = () =>
ctx?.activeClanQuery?.data?.name.charAt(0).toUpperCase();
const clanName = () => ctx?.activeClanQuery?.data?.name;
const clans = () => ctx.otherClanQueries.filter((clan) => !clan.isError);
return (
<div class="sidebar-header">
@@ -37,7 +46,7 @@ export const SidebarHeader = () => {
weight="bold"
inverted={true}
>
{activeClan()?.data?.name.charAt(0).toUpperCase()}
{clanChar()}
</Typography>
</div>
<Typography
@@ -46,7 +55,7 @@ export const SidebarHeader = () => {
weight="bold"
inverted={!open()}
>
{activeClan()?.data?.name}
{clanName()}
</Typography>
</div>
<DropdownMenu.Icon>
@@ -91,7 +100,7 @@ export const SidebarHeader = () => {
</Button>
</DropdownMenu.GroupLabel>
<div class="dropdown-group-items">
<For each={allClans}>
<For each={clans()}>
{(clan) => (
<Suspense fallback={"Loading..."}>
<DropdownMenu.Item

View File

@@ -157,7 +157,7 @@ export const useMachineDetailsQuery = (
export const useClanDetailsQuery = (clanURI: string) => {
const client = useApiClient();
return useQuery<ClanDetails>(() => ({
return useQuery<ClanDetailsWithURI>(() => ({
queryKey: ["clans", encodeBase64(clanURI), "details"],
queryFn: async () => {
const call = client.fetch("get_clan_details", {

View File

@@ -1,13 +1,14 @@
import { RouteSectionProps } from "@solidjs/router";
import { RouteSectionProps, useNavigate } from "@solidjs/router";
import {
Component,
JSX,
Show,
createContext,
createEffect,
createMemo,
createSignal,
on,
onMount,
Show,
useContext,
} from "solid-js";
import {
buildMachinePath,
@@ -16,13 +17,14 @@ import {
} from "@/src/hooks/clan";
import { CubeScene } from "@/src/scene/cubes";
import {
ClanListQueryResult,
ClanDetailsWithURI,
MachinesQueryResult,
useClanDetailsQuery,
useClanListQuery,
useMachinesQuery,
} from "@/src/hooks/queries";
import { callApi } from "@/src/hooks/api";
import { store, setStore, clanURIs } from "@/src/stores/clan";
import { clanURIs, setStore, store } from "@/src/stores/clan";
import { produce } from "solid-js/store";
import { Button } from "@/src/components/Button/Button";
import { Splash } from "@/src/scene/splash";
@@ -32,15 +34,36 @@ 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";
import { useNavigate } from "@solidjs/router";
import { UseQueryResult } from "@tanstack/solid-query";
export const ClanContext = createContext<{
machinesQuery: MachinesQueryResult;
activeClanQuery: UseQueryResult<ClanDetailsWithURI>;
otherClanQueries: UseQueryResult<ClanDetailsWithURI>[];
}>();
export const Clan: Component<RouteSectionProps> = (props) => {
const clanURI = useClanURI();
const activeClanQuery = useClanDetailsQuery(clanURI);
const otherClanQueries = useClanListQuery(
clanURIs().filter((uri) => uri !== clanURI),
);
const machinesQuery = useMachinesQuery(clanURI);
return (
<>
<ClanContext.Provider
value={{
machinesQuery,
activeClanQuery,
otherClanQueries,
}}
>
<Sidebar class={cx(styles.sidebar)} />
{props.children}
<ClanSceneController {...props} />
</>
</ClanContext.Provider>
);
};
@@ -98,7 +121,10 @@ const ClanSceneController = (props: RouteSectionProps) => {
const clanURI = useClanURI();
const navigate = useNavigate();
const machinesQuery = useMachinesQuery(clanURI);
const ctx = useContext(ClanContext);
if (!ctx) {
throw new Error("ClanContext not found");
}
const [dialogHandlers, setDialogHandlers] = createSignal<{
resolve: ({ id }: { id: string }) => void;
@@ -133,7 +159,7 @@ const ClanSceneController = (props: RouteSectionProps) => {
}
// trigger a refetch of the machines query
machinesQuery.refetch();
ctx.machinesQuery.refetch();
return { id: values.name };
};
@@ -177,20 +203,22 @@ const ClanSceneController = (props: RouteSectionProps) => {
}),
);
return (
<SceneDataProvider clanURI={clanURI}>
{({ clansQuery, machinesQuery }) => {
// a combination of the individual clan details query status and the machines query status
// the cube scene needs the machines query, the sidebar needs the clans query and machines query results
// so we wait on both before removing the loader to avoid any loading artefacts
const isLoading = (): boolean => {
// check if the active clan query is still loading
if (ctx.activeClanQuery.isLoading) {
return true;
}
// check the machines query first
if (machinesQuery.isLoading) {
if (ctx.machinesQuery.isLoading) {
return true;
}
// otherwise iterate the clans query and return early if we find a queries that is still loading
for (const query of clansQuery) {
for (const query of ctx.otherClanQueries) {
if (query.isLoading) {
return true;
}
@@ -221,7 +249,7 @@ const ClanSceneController = (props: RouteSectionProps) => {
</Show>
<div
class={cx({
[styles.fadeOut]: !machinesQuery.isLoading && loadingCooldown(),
[styles.fadeOut]: !ctx.machinesQuery.isLoading && loadingCooldown(),
})}
>
<Splash />
@@ -231,7 +259,7 @@ const ClanSceneController = (props: RouteSectionProps) => {
selectedIds={selectedIds}
onSelect={onMachineSelect}
isLoading={isLoading()}
cubesQuery={machinesQuery}
cubesQuery={ctx.machinesQuery}
onCreate={onCreate}
sceneStore={() => {
const clanURI = useClanURI();
@@ -258,21 +286,4 @@ const ClanSceneController = (props: RouteSectionProps) => {
/>
</>
);
}}
</SceneDataProvider>
);
};
const SceneDataProvider = (props: {
clanURI: string;
children: (sceneData: {
clansQuery: ClanListQueryResult;
machinesQuery: MachinesQueryResult;
}) => JSX.Element;
}) => {
const clansQuery = useClanListQuery(clanURIs());
const machinesQuery = useMachinesQuery(props.clanURI);
// This component can be used to provide scene data or context if needed
return props.children({ clansQuery, machinesQuery });
};

View File

@@ -37,7 +37,7 @@ import { HostFileInput } from "@/src/components/Form/HostFileInput";
import { callApi } from "@/src/hooks/api";
import { Creating } from "./Creating";
import { useApiClient } from "@/src/hooks/ApiClient";
import { ListClansModal } from "@/src/routes/Onboarding/ListClansModal";
import { ListClansModal } from "@/src/components/ListClansModal/ListClansModal";
type State = "welcome" | "setup" | "creating";