Merge pull request 'ui/clan: rework routing concept' (#4385) from scene-progress into main

Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4385
This commit is contained in:
hsjobeki
2025-07-17 11:39:33 +00:00
8 changed files with 194 additions and 37 deletions

View File

@@ -14,35 +14,35 @@ import { StoryContext } from "@kachurun/storybook-solid-vite";
const sidebarNavProps: SidebarNavProps = {
clanLinks: [
{ label: "Brian's Clan", path: "/clan/1" },
{ label: "Dave's Clan", path: "/clan/2" },
{ label: "Mic92's Clan", path: "/clan/3" },
{ 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: "/clan/1/settings",
settingsPath: "/clans/1/settings",
machines: [
{
label: "Backup & Home",
path: "/clan/1/machine/backup",
path: "/clans/1/machine/backup",
serviceCount: 3,
status: "Online",
},
{
label: "Raspberry Pi",
path: "/clan/1/machine/pi",
path: "/clans/1/machine/pi",
serviceCount: 1,
status: "Offline",
},
{
label: "Mom's Laptop",
path: "/clan/1/machine/moms-laptop",
path: "/clans/1/machine/moms-laptop",
serviceCount: 2,
status: "Installed",
},
{
label: "Dad's Laptop",
path: "/clan/1/machine/dads-laptop",
path: "/clans/1/machine/dads-laptop",
serviceCount: 4,
status: "Not Installed",
},
@@ -52,10 +52,10 @@ const sidebarNavProps: SidebarNavProps = {
{
label: "Tools",
links: [
{ label: "Borgbackup", path: "/clan/1/service/borgbackup" },
{ label: "Syncthing", path: "/clan/1/service/syncthing" },
{ label: "Mumble", path: "/clan/1/service/mumble" },
{ label: "Minecraft", path: "/clan/1/service/minecraft" },
{ 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" },
],
},
{
@@ -81,7 +81,7 @@ const meta: Meta<RouteSectionProps> = {
component: SidebarNav,
render: (_: never, context: StoryContext<SidebarNavProps>) => {
const history = createMemoryHistory();
history.set({ value: "/clan/1/machine/backup" });
history.set({ value: "/clans/1/machine/backup" });
return (
<div style="height: 670px;">
@@ -93,7 +93,7 @@ const meta: Meta<RouteSectionProps> = {
</Suspense>
)}
>
<Route path="/clan/1/machine/backup" component={(props) => <></>} />
<Route path="/clans/1/machine/backup" component={(props) => <></>} />
</MemoryRouter>
</div>
);

View File

@@ -1,6 +1,6 @@
import { callApi } from "@/src/hooks/api";
import { addClanURI, setActiveClanURI } from "@/src/stores/clan";
import { Params, Navigator } from "@solidjs/router";
import { Params, Navigator, useParams } from "@solidjs/router";
export const selectClanFolder = async () => {
const req = callApi("get_clan_folder", {});
@@ -21,9 +21,25 @@ export const selectClanFolder = async () => {
};
export const navigateToClan = (navigate: Navigator, uri: string) => {
navigate("/clan/" + window.btoa(uri));
navigate("/clans/" + window.btoa(uri));
};
export const clanURIParam = (params: Params) => {
return window.atob(params.clanURI);
};
export const useMaybeClanURI = () => {
const params = useParams();
if (!params.clanURI) {
return null;
}
const clanURI = clanURIParam(params);
if (!clanURI) {
throw new Error("Could not decode clan URI from params: " + params.clanURI);
}
return clanURI;
};

View File

@@ -2,7 +2,7 @@
import { render } from "solid-js/web";
import "./index.css";
import { QueryClient } from "@tanstack/solid-query";
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query";
import { Routes } from "@/src/routes";
import { Router } from "@solidjs/router";
import { Layout } from "@/src/routes/Layout";
@@ -22,4 +22,11 @@ if (import.meta.env.DEV) {
await import("solid-devtools");
}
render(() => <Router root={Layout}>{Routes}</Router>, root!);
render(
() => (
<QueryClientProvider client={client}>
<Router root={Layout}>{Routes}</Router>
</QueryClientProvider>
),
root!,
);

View File

@@ -1,10 +1,63 @@
import { RouteSectionProps, useParams } from "@solidjs/router";
import { Component } from "solid-js";
import { clanURIParam } from "@/src/hooks/clan";
import { RouteSectionProps } from "@solidjs/router";
import { Component, JSX } from "solid-js";
import { useMaybeClanURI } from "@/src/hooks/clan";
import { CubeScene } from "@/src/scene/cubes";
import { useQuery, UseQueryResult } from "@tanstack/solid-query";
import { callApi, SuccessData } from "@/src/hooks/api";
export const Clan: Component<RouteSectionProps> = (props) => {
const params = useParams();
const clanURI = clanURIParam(params);
return <CubeScene />;
export const Clans: Component<RouteSectionProps> = (props) => {
return (
<>
<div
style={{
position: "absolute",
top: 0,
}}
>
{props.children}
</div>
<ClanSwitchDog />
</>
);
};
const ClanSwitchDog = () => {
const maybeClanURI = useMaybeClanURI();
return (
<SceneDataProvider clanURI={maybeClanURI}>
{({ query }) => <CubeScene cubesQuery={query} />}
</SceneDataProvider>
);
};
export type ListMachines = SuccessData<"list_machines">;
const SceneDataProvider = (props: {
clanURI: string | null;
children: (sceneData: { query: UseQueryResult<ListMachines> }) => JSX.Element;
}) => {
const machinesQuery = useQuery<ListMachines>(() => ({
queryKey: ["clans", props.clanURI, "machines"],
enabled: !!props.clanURI,
queryFn: async () => {
if (!props.clanURI) {
return {};
}
const api = callApi("list_machines", {
flake: {
identifier: props.clanURI,
},
});
const result = await api.result;
if (result.status === "error") {
console.error("Error fetching machines:", result.errors);
return {};
}
return result.data;
},
}));
// This component can be used to provide scene data or context if needed
return props.children({ query: machinesQuery });
};

View File

@@ -8,7 +8,7 @@ export const Layout: Component<RouteSectionProps> = (props) => {
// check for an active clan uri and redirect to it on first load
const activeURI = activeClanURI();
if (!props.location.pathname.startsWith("/clan/") && activeURI) {
if (!props.location.pathname.startsWith("/clans/") && activeURI) {
navigateToClan(navigate, activeURI);
} else {
navigate("/");

View File

@@ -1,6 +1,6 @@
import type { RouteDefinition } from "@solidjs/router/dist/types";
import { Onboarding } from "@/src/routes/Onboarding/Onboarding";
import { Clan } from "@/src/routes/Clan/Clan";
import { Clans } from "@/src/routes/Clan/Clan";
export const Routes: RouteDefinition[] = [
{
@@ -8,7 +8,42 @@ export const Routes: RouteDefinition[] = [
component: Onboarding,
},
{
path: "/clan/:clanURI",
component: Clan,
path: "/clans",
component: Clans,
children: [
{
path: "/",
component: () => (
<h1>
Clans (index) - (Doesnt really exist, just to keep the scene
mounted)
</h1>
),
},
{
path: "/:clanURI",
children: [
{
path: "/",
component: (props) => <h1>ClanID: {props.params.clanURI}</h1>,
},
{
path: "/machines",
children: [
{
path: "/",
component: () => <h1>Machines (Index)</h1>,
},
{
path: "/:machineID",
component: (props) => (
<h1>Machine ID: {props.params.machineID}</h1>
),
},
],
},
],
},
],
},
];

View File

@@ -13,6 +13,9 @@ import * as THREE from "three";
import { Toolbar } from "../components/Toolbar/Toolbar";
import { ToolbarButton } from "../components/Toolbar/ToolbarButton";
import { Divider } from "../components/Divider/Divider";
import { UseQueryResult } from "@tanstack/solid-query";
import { ListMachines } from "../routes/Clan/Clan";
import { callApi } from "../hooks/api";
function garbageCollectGroup(group: THREE.Group) {
for (const child of group.children) {
@@ -53,7 +56,8 @@ function getFloorPosition(
return intersection.toArray() as [number, number, number];
}
export function CubeScene() {
export function CubeScene(props: { cubesQuery: UseQueryResult<ListMachines> }) {
// sceneData.cubesQuer
let container: HTMLDivElement;
let scene: THREE.Scene;
let camera: THREE.PerspectiveCamera;
@@ -131,6 +135,10 @@ export function CubeScene() {
const CREATE_BASE_COLOR = 0x636363;
const CREATE_BASE_EMISSIVE = 0xc5fad7;
function getDefaultPosition(): [number, number, number] {
return [0, 0, 0];
}
function getGridPosition(
id: string,
index: number,
@@ -348,8 +356,10 @@ export function CubeScene() {
}
// === Add/Delete Cube API ===
function addCube() {
const id = crypto.randomUUID();
function addCube(id: string | undefined = undefined) {
if (!id) {
id = crypto.randomUUID();
}
// Add to creating set first
setCreatingIds((prev) => new Set([...prev, id]));
@@ -505,6 +515,16 @@ export function CubeScene() {
const grid = new THREE.GridHelper(1000, 1000 / 1, 0xe1edef, 0xe1edef);
createEffect(() => {
if (props.cubesQuery.data) {
for (const machineId of Object.keys(props.cubesQuery.data)) {
console.log("Received: ", machineId);
setNextPosition(new THREE.Vector3(...getDefaultPosition()));
addCube(machineId);
}
}
});
onMount(() => {
// Scene setup
scene = new THREE.Scene();
@@ -687,19 +707,19 @@ export function CubeScene() {
// Snap to grid
const snapped = new THREE.Vector3(
Math.round(point.x / GRID_SIZE) * GRID_SIZE,
BASE_HEIGHT / 2,
0,
Math.round(point.z / GRID_SIZE) * GRID_SIZE,
);
if (!initBase) {
// Create initial base mesh if it doesn't exist
initBase = createCubeBase(
[snapped.x, BASE_HEIGHT / 2, snapped.z],
[snapped.x, 0, snapped.z],
1,
CREATE_BASE_COLOR,
CREATE_BASE_EMISSIVE, // Emissive color
);
} else {
initBase.position.set(snapped.x, BASE_HEIGHT / 2, snapped.z);
initBase.position.set(snapped.x, 0, snapped.z);
}
scene.remove(initBase); // Remove any existing base mesh
scene.add(initBase);
@@ -786,7 +806,28 @@ export function CubeScene() {
if (initBase) {
scene.remove(initBase); // Remove the base mesh after adding cube
setWorldMode("view");
addCube();
const res = callApi("create_machine", {
opts: {
clan_dir: {
identifier: "/home/johannes/git/tmp/my-clan",
},
machine: {
name: "sara",
},
},
});
res.result.then(() => {
props.cubesQuery.refetch();
const pos = nextBasePosition();
if (!pos) {
console.error("No next position set for new cube");
return;
}
positionMap.set("sara", pos);
addCube("sara");
});
}
return;
}

View File

@@ -4,6 +4,11 @@ import { makePersisted } from "@solid-primitives/storage";
interface ClanStoreType {
clanURIs: string[];
activeClanURI?: string;
sceneData?: {
[clanURI: string]: {
[machineId: string]: { position: [number, number] };
};
};
}
const [store, setStore] = makePersisted(
@@ -22,7 +27,7 @@ const [store, setStore] = makePersisted(
* @function
* @returns {string} The URI of the active clan.
*/
const activeClanURI = (): string | undefined => store.activeClanURI;
const activeClanURI = () => store.activeClanURI;
/**
* Updates the active Clan URI in the store.