Merge pull request 'ui/clan-switching' (#4844) from ui/clan-switching into main

Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4844
This commit is contained in:
brianmcgee
2025-08-20 15:46:11 +00:00
9 changed files with 245 additions and 28 deletions

View File

@@ -11,6 +11,13 @@ 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";
import { ApiClientProvider } from "@/src/hooks/ApiClient";
import {
ApiCall,
OperationArgs,
OperationNames,
OperationResponse,
} from "@/src/hooks/api";
const defaultClanURI = "/home/brian/clans/my-clan";
@@ -24,10 +31,16 @@ const queryData = {
europa: {
name: "Europa",
machineClass: "nixos",
state: {
status: "online",
},
},
ganymede: {
name: "Ganymede",
machineClass: "nixos",
state: {
status: "out_of_sync",
},
},
},
},
@@ -40,10 +53,16 @@ const queryData = {
callisto: {
name: "Callisto",
machineClass: "nixos",
state: {
status: "not_installed",
},
},
amalthea: {
name: "Amalthea",
machineClass: "nixos",
state: {
status: "offline",
},
},
},
},
@@ -56,10 +75,16 @@ const queryData = {
thebe: {
name: "Thebe",
machineClass: "nixos",
state: {
status: "online",
},
},
sponde: {
name: "Sponde",
machineClass: "nixos",
state: {
status: "online",
},
},
},
},
@@ -123,6 +148,18 @@ export default meta;
type Story = StoryObj<RouteSectionProps>;
const mockFetcher = <K extends OperationNames>(
method: K,
args: OperationArgs<K>,
) =>
({
uuid: "mock",
result: Promise.reject<OperationResponse<K>>("not implemented"),
cancel: async () => {
throw new Error("not implemented");
},
}) satisfies ApiCall<K>;
export const Default: Story = {
args: {},
decorators: [
@@ -141,16 +178,28 @@ export const Default: Story = {
["clans", encodeBase64(clanURI), "details"],
clan.details,
);
const machines = clan.machines || {};
queryClient.setQueryData(
["clans", encodeBase64(clanURI), "machines"],
clan.machines || {},
machines,
);
Object.entries(machines).forEach(([name, machine]) => {
queryClient.setQueryData(
["clans", encodeBase64(clanURI), "machine", name, "state"],
machine.state,
);
});
});
return (
<QueryClientProvider client={queryClient}>
<Story />
</QueryClientProvider>
<ApiClientProvider client={{ fetch: mockFetcher }}>
<QueryClientProvider client={queryClient}>
<Story />
</QueryClientProvider>
</ApiClientProvider>
);
},
],

View File

@@ -58,6 +58,7 @@ div.sidebar-header {
@apply px-1;
.dropdown-group-label {
@apply flex items-baseline justify-between w-full;
}
.dropdown-group-items {

View File

@@ -5,8 +5,13 @@ 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 = () => {
const navigate = useNavigate();
@@ -71,9 +76,19 @@ export const SidebarHeader = () => {
family="mono"
size="xs"
color="tertiary"
transform="uppercase"
>
YOUR CLANS
Your Clans
</Typography>
<Button
hierarchy="secondary"
ghost
size="xs"
startIcon="Plus"
onClick={() => navigateToOnboarding(navigate, true)}
>
Add
</Button>
</DropdownMenu.GroupLabel>
<div class="dropdown-group-items">
<For each={allClans}>
@@ -81,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,9 +4,14 @@ import {
createSignal,
Match,
Setter,
Show,
Switch,
} from "solid-js";
import { RouteSectionProps, useNavigate } from "@solidjs/router";
import {
RouteSectionProps,
useNavigate,
useSearchParams,
} from "@solidjs/router";
import "./Onboarding.css";
import { Typography } from "@/src/components/Typography/Typography";
import { Button } from "@/src/components/Button/Button";
@@ -32,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";
@@ -55,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>;
@@ -75,9 +99,17 @@ const welcome = (props: {
const selectFolder = async () => {
setLoading(true);
const uri = await selectClanFolder();
setLoading(false);
navigateToClan(navigate, uri);
try {
const uri = await selectClanFolder();
setLoading(false);
navigateToClan(navigate, uri);
} catch (e) {
// todo display error, currently we don't get anything to distinguish between cancel or an actual error
} finally {
// stop the loading state of the button
setLoading(false);
}
};
return (
@@ -127,7 +159,7 @@ const welcome = (props: {
</div>
<Button
hierarchy="primary"
ghost={true}
ghost
loading={loading()}
onClick={selectFolder}
>
@@ -139,9 +171,11 @@ const welcome = (props: {
export const Onboarding: Component<RouteSectionProps> = (props) => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const activeURI = activeClanURI();
if (activeURI) {
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);