Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,14 @@
"preview": "vite preview"
},
"dependencies": {
"@kobalte/core": "^0.13.13",
"@formisch/solid": "1.0.0",
"@kobalte/core": "^0.13.13",
"@solidjs/meta": "^0.29.4",
"@solidjs/router": "^1.0.0",
"@solidjs/start": "^2.0.0",
"nitro": "3.0.260610-beta",
"relay-runtime": "^21.0.1",
"seroval": "1.5.4",
"solid-js": "^1.9.5",
Comment thread
2chanhaeng marked this conversation as resolved.
"solid-relay": "1.0.0-beta.29",
"valibot": "1.4.2",
Expand Down
2 changes: 2 additions & 0 deletions packages/web/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { Suspense } from "solid-js";
import { RelayEnvironmentProvider } from "solid-relay";

import { HeaderAccountButton } from "./components/HeaderAccountButton.tsx";
import { ToastRegion } from "./components/Toast.tsx";
import { createRelayEnvironment } from "./RelayEnvironment.ts";

import styles from "./styles/app.module.css";
Expand All @@ -37,6 +38,7 @@ export default function App() {
root={(props) => (
<MetaProvider>
<Title>DrFed</Title>
<ToastRegion />
<div class={styles.shell}>
<header class={styles.header}>
<div class={styles.headerInner}>
Expand Down
88 changes: 88 additions & 0 deletions packages/web/src/components/Toast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// DrFed: A web-based platform for developing and debugging ActivityPub apps
// Copyright (C) 2026 DrFed team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.

import { Toast, toaster } from "@kobalte/core/toast";
import { onMount } from "solid-js";
import { Portal } from "solid-js/web";
import * as v from "valibot";

import styles from "~/styles/toast.module.css";

const pendingToastKey = "drfed:toast:";

const toastMessage = v.pipe(v.string(), v.nonEmpty());
const storedToastSchema = v.pipe(
v.string(),
v.parseJson(),
v.object({
message: toastMessage,
status: v.picklist(["success", "fail"]),
}),
);

type ToastStatus = "success" | "fail";

export function ToastRegion() {
onMount(() => {
const key = pendingToastKey + globalThis.location.pathname;
try {
const stored = sessionStorage.getItem(key);
sessionStorage.removeItem(key);
const result = v.safeParse(storedToastSchema, stored);
if (result.success) {
showToast(result.output.message, result.output.status);
}
} catch {
// Browser storage is unavailable.
}
});

return (
<Portal>
<Toast.Region class={styles.region} duration={5000}>
<Toast.List class={styles.list} />
</Toast.Region>
</Portal>
);
}

export function showToast(message: string, status: ToastStatus): number {
return toaster.show((props) => (
<Toast toastId={props.toastId} class={`${styles.toast} ${styles[status]}`}>
<Toast.Title class={styles.title}>{message}</Toast.Title>
<Toast.CloseButton class={styles.close} aria-label="Dismiss notification">
<span aria-hidden="true">×</span>
</Toast.CloseButton>
</Toast>
));
}

/** Load a fresh page and show a one-time notification there. */
export function navigateWithToast(
path: string,
message: string,
status: ToastStatus,
): void {
try {
sessionStorage.setItem(
pendingToastKey + path,
JSON.stringify({ message, status }),
);
} catch {
// A blocked storage API must not prevent successful navigation.
}
globalThis.location.assign(path);
}
187 changes: 175 additions & 12 deletions packages/web/src/routes/workspace/create/[instance_id]/actors.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.

import { Field, Form, type SubmitHandler, createForm } from "@formisch/solid";
import { Button } from "@kobalte/core/button";
import { NumberField } from "@kobalte/core/number-field";
import { Title } from "@solidjs/meta";
Expand All @@ -23,21 +24,31 @@ import {
query,
} from "@solidjs/router";
import { graphql } from "relay-runtime";
import { Show, createSignal } from "solid-js";
import {
createMutation,
createPreloadedQuery,
loadQuery,
useRelayEnvironment,
} from "solid-relay";
import * as v from "valibot";

import type { GetHostQuery } from "./__generated__/GetHostQuery.graphql.tsx";
import { navigateWithToast } from "~/components/Toast.tsx";

import type { GenerateActorsMutation } from "./__generated__/GenerateActorsMutation.graphql.ts";
import type { GetHostQuery } from "./__generated__/GetHostQuery.graphql.ts";

import styles from "~/styles/form.module.css";

const getHostQuery = graphql`
query GetHostQuery($instanceId: ID!) {
instance: node(id: $instanceId) {
... on Instance {
id
host
localInstance {
slug
}
}
}
}
Expand All @@ -62,12 +73,125 @@ export const route = {

type RouteData = ReturnType<typeof loadInstanceQuery>;

const generateActorsMutation = graphql`
mutation GenerateActorsMutation($instance: ID!, $size: Int!) {
generateActors(instance: $instance, size: $size) {
resultType: __typename
... on CreateActorsSuccess {
actors {
id
uuid
username
handle
iri
}
}
... on CreateActorsError {
type
message
}
}
}
`;
const generateActorsSchema = v.object({
size: v.pipe(
v.number("Enter the number of actors."),
v.integer("The number of actors must be a whole number."),
v.minValue(1, "Create at least one actor."),
),
});

export default function CreateActorsPage(props: RouteSectionProps<RouteData>) {
const [errorMessage, setErrorMessage] = createSignal<string>();
const [commitGenerateActors, isGeneratingActors] =
createMutation<GenerateActorsMutation>(generateActorsMutation);

const data = createPreloadedQuery<GetHostQuery>(
getHostQuery,
() => props.data,
);

const generateActorsForm = createForm({
schema: generateActorsSchema,
initialInput: {
size: 1,
},
});

const submit: SubmitHandler<typeof generateActorsSchema> = ({ size }) => {
const { id: instance, host, localInstance } = data()?.instance ?? {};
const slug = localInstance?.slug;

if (instance === undefined || slug === undefined) {
setErrorMessage("Instance not found.");
return;
}
setErrorMessage(undefined);
commitGenerateActors({
variables: { instance, size },
onCompleted: (response, errors) => {
Comment thread
sij411 marked this conversation as resolved.
const graphQLErrors = errors ?? [];
if (graphQLErrors.length > 0) {
setErrorMessage(
graphQLErrors.map((error) => error.message).join("\n"),
);
return;
}

// TODO: Replace current codes to dictionary looking up
const result = response.generateActors;
switch (result.resultType) {
case "CreateActorsSuccess": {
navigateWithToast(
`/instance/${encodeURIComponent(slug)}`,
`Successfully created actors on ${host}.`,
"success",
);
return;
}
case "CreateActorsError": {
switch (result.type) {
case "InvalidSize": {
setErrorMessage("Enter at least one actor.");
return;
}
case "InstanceNotFound": {
setErrorMessage(
"The selected instance could not be found or is no longer available.",
);
return;
}
case "TooManyActors": {
setErrorMessage(result.message);
return;
}
case "%future added value": {
setErrorMessage(
"The server returned an unsupported actor-generation error.",
);
return;
}
default: {
setErrorMessage("Unable to generate actors.");
return;
}
}
}
Comment thread
sij411 marked this conversation as resolved.
case "%other": {
setErrorMessage("Unable to generate actors.");
return;
}
Comment thread
sij411 marked this conversation as resolved.
default: {
setErrorMessage("Unable to generate actors.");
}
}
},
onError: (error) => {
setErrorMessage(error.message);
},
});
};

return (
<main class={styles.page}>
<Title>Create Actors</Title>
Expand All @@ -76,19 +200,58 @@ export default function CreateActorsPage(props: RouteSectionProps<RouteData>) {
<h1 id="create-actors-title">Create Actors</h1>
<p>Selected Instance: {data()?.instance?.host}</p>
</header>
<form class={styles.form}>
<NumberField class={styles.field} name="actors" minValue={1}>
<NumberField.Label>The Number of Actors</NumberField.Label>
<NumberField.Input class={styles.input} placeholder="5" required />
<NumberField.Description class={styles.hint}>
DrFed generates actors in {data()?.instance?.host}.
</NumberField.Description>
</NumberField>

<Button class={styles.button} type="submit">
<Form class={styles.form} of={generateActorsForm} onSubmit={submit}>
<Field of={generateActorsForm} path={["size"]}>
{(field) => (
<NumberField
class={styles.field}
name={field.props.name}
value={Number.isNaN(field.input) ? "" : (field.input ?? "")}
minValue={1}
onRawValueChange={(value) => {
field.onInput(Number.isNaN(value) ? undefined : value);
}}
>
<NumberField.Label class={styles.field}>Size</NumberField.Label>
<NumberField.Input
ref={field.props.ref}
onFocus={field.props.onFocus}
onBlur={field.props.onBlur}
class={styles.input}
aria-invalid={Boolean(field.errors)}
/>
<NumberField.Description class={styles.hint}>
DrFed generates actors in {data()?.instance?.host}
</NumberField.Description>
<Show when={field.errors}>
{(errors) => (
<span
class={`${styles.notice} ${styles.error}`}
role="alert"
>
{errors()[0]}
</span>
)}
</Show>
</NumberField>
)}
</Field>
<Button
class={styles.button}
type="submit"
disabled={
isGeneratingActors() ||
data()?.instance?.localInstance?.slug === undefined
}
>
Create Actors
</Button>
</form>
</Form>
<Show when={errorMessage()}>
<p class={`${styles.notice} ${styles.error}`} role="alert">
{errorMessage()}
</p>
</Show>
</section>
</main>
);
Expand Down
Loading