feat(ui): enable switching between clans

This commit is contained in:
Brian McGee
2025-08-20 15:18:20 +01:00
parent 9834f413cc
commit 349da24b29
8 changed files with 162 additions and 33 deletions

View File

@@ -18,7 +18,6 @@ import {
OperationNames,
OperationResponse,
} from "@/src/hooks/api";
import { mac } from "valibot";
const defaultClanURI = "/home/brian/clans/my-clan";

View File

@@ -5,8 +5,12 @@ import { useNavigate } from "@solidjs/router";
import { Typography } from "../Typography/Typography";
import { createSignal, For, Suspense } from "solid-js";
import { useClanListQuery } from "@/src/hooks/queries";
import { navigateToClan, useClanURI } from "@/src/hooks/clan";
import { clanURIs } from "@/src/stores/clan";
import {
navigateToClan,
navigateToOnboarding,
useClanURI,
} from "@/src/hooks/clan";
import { clanURIs, setActiveClanURI } from "@/src/stores/clan";
import { Button } from "../Button/Button";
export const SidebarHeader = () => {
@@ -81,7 +85,7 @@ export const SidebarHeader = () => {
ghost
size="xs"
startIcon="Plus"
onClick={() => navigate("/?addClan=true")}
onClick={() => navigateToOnboarding(navigate, true)}
>
Add
</Button>
@@ -92,9 +96,9 @@ export const SidebarHeader = () => {
<Suspense fallback={"Loading..."}>
<DropdownMenu.Item
class="dropdown-item"
onSelect={() =>
navigateToClan(navigate, clan.data!.uri)
}
onSelect={() => {
setActiveClanURI(clan.data!.uri);
}}
>
<Typography
hierarchy="label"

View File

@@ -36,6 +36,9 @@ export const navigateToClan = (navigate: Navigator, clanURI: string) => {
navigate(path);
};
export const navigateToOnboarding = (navigate: Navigator, addClan: boolean) =>
navigate(`/${addClan ? "?addClan=true" : ""}`);
export const navigateToMachine = (
navigate: Navigator,
clanURI: string,

View File

@@ -27,10 +27,6 @@ export type ClanListQueryResult = UseQueryResult<ClanDetailsWithURI>[];
export const useMachinesQuery = (clanURI: string) => {
const client = useApiClient();
if (!clanURI) {
throw new Error("useMachinesQuery: clanURI is undefined");
}
return useQuery<ListMachines>(() => ({
queryKey: ["clans", encodeBase64(clanURI), "machines"],
queryFn: async () => {

View File

@@ -0,0 +1,24 @@
.modal {
@apply w-screen max-w-2xl h-fit flex flex-col;
.content {
@apply flex flex-col gap-2;
}
.header {
@apply flex items-center justify-between;
}
.clans {
@apply flex flex-col gap-2;
}
.clan {
@apply flex items-center justify-between;
@apply px-4 py-5 bg-def-2;
.meta {
@apply flex flex-col gap-1;
}
}
}

View File

@@ -0,0 +1,91 @@
import { Modal } from "../../components/Modal/Modal";
import cx from "classnames";
import styles from "./ListClansModal.module.css";
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 { activeClanURI, clanURIs, setActiveClanURI } from "@/src/stores/clan";
import { useClanListQuery } from "@/src/hooks/queries";
export interface ClansModalProps {
onClose: () => void;
}
export const ListClansModal = (props: ClansModalProps) => {
const navigate = useNavigate();
const query = useClanListQuery(clanURIs());
// we only want clans we could interrogate successfully
// todo how to surface the ones that failed to users?
const clanList = () => query.filter((it) => it.isSuccess);
const selectClan = (uri: string) => () => {
if (uri == activeClanURI()) {
navigateToClan(navigate, uri);
} else {
setActiveClanURI(uri);
}
};
return (
<Modal
title="Select Clan"
open
onClose={props.onClose}
class={cx(styles.modal)}
>
<div class={cx(styles.content)}>
<div class={cx(styles.header)}>
<Typography
hierarchy="label"
family="mono"
size="xs"
color="tertiary"
transform="uppercase"
>
Your Clans
</Typography>
<Button
hierarchy="secondary"
ghost
size="s"
startIcon="Plus"
onClick={() => {
props.onClose();
navigateToOnboarding(navigate, true);
}}
>
Add Clan
</Button>
</div>
<ul class={cx(styles.clans)}>
<For each={clanList()}>
{(clan) => (
<li class={cx(styles.clan)}>
<div class={cx(styles.meta)}>
<Typography hierarchy="label" weight="bold" size="default">
{clan.data.name}
</Typography>
<Typography hierarchy="body" size="s">
{clan.data.description}
</Typography>
</div>
<Button
hierarchy="secondary"
ghost
icon="CaretRight"
onClick={selectClan(clan.data.uri)}
/>
</li>
)}
</For>
</ul>
</div>
</Modal>
);
};

View File

@@ -33,6 +33,10 @@ main#welcome {
@apply w-16;
@apply absolute bottom-28 left-1/2 transform -translate-x-1/2;
}
button.list-clans {
@apply absolute bottom-28 right-0 transform -translate-x-1/2;
}
}
& > div.container {

View File

@@ -4,6 +4,7 @@ import {
createSignal,
Match,
Setter,
Show,
Switch,
} from "solid-js";
import {
@@ -36,6 +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";
type State = "welcome" | "setup" | "creating";
@@ -59,14 +61,32 @@ const SetupSchema = v.object({
type SetupForm = v.InferInput<typeof SetupSchema>;
const background = (props: { state: State; form: FormStore<SetupForm> }) => (
<div class="background">
<div class="layer-1" />
<div class="layer-2" />
<div class="layer-3" />
<Logo variant="Clan" inverted={true} />
</div>
);
const background = (props: { state: State; form: FormStore<SetupForm> }) => {
// controls whether the list clans modal is displayed
const [showModal, setShowModal] = createSignal(false);
return (
<div class="background">
<div class="layer-1" />
<div class="layer-2" />
<div class="layer-3" />
<Logo variant="Clan" inverted />
<Button
class="list-clans"
hierarchy="primary"
ghost
size="s"
startIcon="Grid"
onClick={() => setShowModal(true)}
>
All Clans
</Button>
<Show when={showModal()}>
<ListClansModal onClose={() => setShowModal(false)} />
</Show>
</div>
);
};
const welcome = (props: {
setState: Setter<State>;
@@ -147,17 +167,13 @@ export const Onboarding: Component<RouteSectionProps> = (props) => {
const activeURI = activeClanURI();
console.log("onboarding params", searchParams.addClan);
if (!searchParams.addClan && activeURI) {
// the user has already selected a clan, so we should navigate to it
console.log("active clan detected, navigating to it", activeURI);
navigateToClan(navigate, activeURI);
}
const [state, setState] = createSignal<State>(
searchParams.addClan ? "setup" : "welcome",
);
const [state, setState] = createSignal<State>("welcome");
// used to display an error in the welcome screen in the event of a failed
// clan creation
@@ -280,15 +296,7 @@ export const Onboarding: Component<RouteSectionProps> = (props) => {
hierarchy="secondary"
ghost={true}
icon="ArrowLeft"
onClick={() => {
// if we arrived here by way of adding a clan then we return to the active clan
if (searchParams.addClan && activeURI) {
navigateToClan(navigate, activeURI);
}
// otherwise we return to the initial welcome screen
setState("welcome");
}}
onClick={() => setState("welcome")}
/>
<Typography hierarchy="headline" size="default" weight="bold">
Setup