|
| 1 | +// DrFed: A web-based platform for developing and debugging ActivityPub apps |
| 2 | +// Copyright (C) 2026 DrFed team |
| 3 | +// |
| 4 | +// This program is free software: you can redistribute it and/or modify |
| 5 | +// it under the terms of the GNU Affero General Public License as published by |
| 6 | +// the Free Software Foundation, either version 3 of the License, or |
| 7 | +// (at your option) any later version. |
| 8 | +// |
| 9 | +// This program is distributed in the hope that it will be useful, |
| 10 | +// but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 11 | +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| 12 | +// GNU Affero General Public License for more details. |
| 13 | +// |
| 14 | +// You should have received a copy of the GNU Affero General Public License |
| 15 | +// along with this program. If not, see <https://www.gnu.org/licenses/>. |
| 16 | + |
| 17 | +/* eslint-disable unicorn/no-null -- Kobalte requires null for an empty selection. */ |
| 18 | + |
| 19 | +import { Select } from "@kobalte/core/select"; |
| 20 | +import { TextField } from "@kobalte/core/text-field"; |
| 21 | +import { Title } from "@solidjs/meta"; |
| 22 | +import { |
| 23 | + type RouteDefinition, |
| 24 | + type RouteSectionProps, |
| 25 | + query, |
| 26 | +} from "@solidjs/router"; |
| 27 | +import { graphql } from "relay-runtime"; |
| 28 | +import { Show, createSignal } from "solid-js"; |
| 29 | +import { |
| 30 | + createMutation, |
| 31 | + createPreloadedQuery, |
| 32 | + loadQuery, |
| 33 | + useRelayEnvironment, |
| 34 | +} from "solid-relay"; |
| 35 | +import * as v from "valibot"; |
| 36 | + |
| 37 | +import { showToast } from "~/components/Toast.tsx"; |
| 38 | + |
| 39 | +import type { CreateObjectMutation } from "./__generated__/CreateObjectMutation.graphql.ts"; |
| 40 | +import type { InstanceActorListQuery } from "./__generated__/InstanceActorListQuery.graphql.ts"; |
| 41 | + |
| 42 | +import styles from "~/styles/form.module.css"; |
| 43 | +import objectStyles from "~/styles/object.module.css"; |
| 44 | + |
| 45 | +const instanceActorListQuery = graphql` |
| 46 | + query InstanceActorListQuery($instanceId: ID!) { |
| 47 | + instance: node(id: $instanceId) { |
| 48 | + ... on Instance { |
| 49 | + id |
| 50 | + host |
| 51 | + url |
| 52 | + actors(first: 100) { |
| 53 | + totalCount |
| 54 | + edges { |
| 55 | + node { |
| 56 | + handle |
| 57 | + id |
| 58 | + } |
| 59 | + } |
| 60 | + } |
| 61 | + } |
| 62 | + } |
| 63 | + } |
| 64 | +`; |
| 65 | + |
| 66 | +const createObjectMutation = graphql` |
| 67 | + mutation CreateObjectMutation( |
| 68 | + $actor: ID! |
| 69 | + $contentHtml: String! |
| 70 | + $type: ObjectType! |
| 71 | + $addressing: AddressingInput! |
| 72 | + $name: String |
| 73 | + ) { |
| 74 | + createObject( |
| 75 | + actor: $actor |
| 76 | + contentHtml: $contentHtml |
| 77 | + type: $type |
| 78 | + addressing: $addressing |
| 79 | + name: $name |
| 80 | + ) { |
| 81 | + resultType: __typename |
| 82 | + ... on Object { |
| 83 | + id |
| 84 | + } |
| 85 | + ... on CreateObjectError { |
| 86 | + message |
| 87 | + } |
| 88 | + } |
| 89 | + } |
| 90 | +`; |
| 91 | + |
| 92 | +const recipientSchema = v.pipe(v.string(), v.url()); |
| 93 | +const recipientsSchema = v.optional(v.array(recipientSchema), []); |
| 94 | +const createObjectSchema = v.object({ |
| 95 | + actor: v.pipe(v.string("Select an actor."), v.nonEmpty("Select an actor.")), |
| 96 | + name: v.string(), |
| 97 | + contentHtml: v.pipe( |
| 98 | + v.string(), |
| 99 | + v.check((content) => content.trim() !== "", "Enter content."), |
| 100 | + ), |
| 101 | + type: v.picklist(["Note", "Article"]), |
| 102 | + addressing: v.strictObject({ |
| 103 | + to: recipientsSchema, |
| 104 | + cc: recipientsSchema, |
| 105 | + bto: recipientsSchema, |
| 106 | + bcc: recipientsSchema, |
| 107 | + audience: recipientsSchema, |
| 108 | + }), |
| 109 | +}); |
| 110 | + |
| 111 | +const loadInstanceActorListQuery = query( |
| 112 | + (instanceId: string) => |
| 113 | + loadQuery<InstanceActorListQuery>( |
| 114 | + useRelayEnvironment()(), |
| 115 | + instanceActorListQuery, |
| 116 | + { instanceId }, |
| 117 | + ), |
| 118 | + "InstanceActorListQuery", |
| 119 | +); |
| 120 | + |
| 121 | +export const route = { |
| 122 | + preload({ params }) { |
| 123 | + if (params.instance_id === undefined) { |
| 124 | + throw new Error("Missing instance_id route parameter."); |
| 125 | + } |
| 126 | + return loadInstanceActorListQuery(params.instance_id); |
| 127 | + }, |
| 128 | +} satisfies RouteDefinition; |
| 129 | + |
| 130 | +type RouteData = ReturnType<typeof loadInstanceActorListQuery>; |
| 131 | +interface ActorOption { |
| 132 | + readonly id: string; |
| 133 | + readonly handle: string; |
| 134 | +} |
| 135 | + |
| 136 | +export default function CreateObjectsPage(props: RouteSectionProps<RouteData>) { |
| 137 | + const data = createPreloadedQuery<InstanceActorListQuery>( |
| 138 | + instanceActorListQuery, |
| 139 | + () => props.data, |
| 140 | + ); |
| 141 | + |
| 142 | + const actors = () => data()?.instance?.actors?.edges; |
| 143 | + |
| 144 | + const [currentActor, setCurrentActor] = createSignal<ActorOption | null>( |
| 145 | + null, |
| 146 | + ); |
| 147 | + |
| 148 | + const [commitCreateObject, isCreating] = |
| 149 | + createMutation<CreateObjectMutation>(createObjectMutation); |
| 150 | + const [message, setMessage] = createSignal<string>(""); |
| 151 | + |
| 152 | + const submit = (form: HTMLFormElement) => { |
| 153 | + if (isCreating()) return; |
| 154 | + setMessage(""); |
| 155 | + const formData = new FormData(form); |
| 156 | + const addressingText = formData.get("addressing"); |
| 157 | + let addressing: unknown; |
| 158 | + const nameText = formData.get("name"); |
| 159 | + const name = typeof nameText === "string" ? nameText : ""; |
| 160 | + try { |
| 161 | + addressing = JSON.parse( |
| 162 | + typeof addressingText === "string" ? addressingText : "{}", |
| 163 | + ); |
| 164 | + } catch { |
| 165 | + setMessage("Addressing must be valid JSON."); |
| 166 | + showToast(message(), "fail"); |
| 167 | + return; |
| 168 | + } |
| 169 | + const input = v.safeParse(createObjectSchema, { |
| 170 | + actor: (currentActor() ?? actors()?.[0]?.node)?.id, |
| 171 | + name, |
| 172 | + contentHtml: formData.get("content"), |
| 173 | + type: formData.get("type"), |
| 174 | + addressing, |
| 175 | + }); |
| 176 | + if (!input.success) { |
| 177 | + setMessage(input.issues.map((issue) => issue.message).join("\n")); |
| 178 | + showToast(message(), "fail"); |
| 179 | + return; |
| 180 | + } |
| 181 | + commitCreateObject({ |
| 182 | + variables: input.output, |
| 183 | + onCompleted(response, errors) { |
| 184 | + if (errors != null && errors.length > 0) { |
| 185 | + setMessage(errors.map((error) => error.message).join("\n")); |
| 186 | + showToast(message(), "fail"); |
| 187 | + |
| 188 | + return; |
| 189 | + } |
| 190 | + const result = response.createObject; |
| 191 | + if (result.resultType === "Object") { |
| 192 | + form.reset(); |
| 193 | + setMessage("Object created successfully."); |
| 194 | + showToast(message(), "success"); |
| 195 | + } else { |
| 196 | + setMessage( |
| 197 | + result.resultType === "CreateObjectError" |
| 198 | + ? result.message |
| 199 | + : "Unable to create the object.", |
| 200 | + ); |
| 201 | + showToast(message(), "fail"); |
| 202 | + } |
| 203 | + }, |
| 204 | + onError: (error) => { |
| 205 | + setMessage(error.message); |
| 206 | + showToast(message(), "fail"); |
| 207 | + }, |
| 208 | + }); |
| 209 | + }; |
| 210 | + |
| 211 | + return ( |
| 212 | + <Show |
| 213 | + when={actors()} |
| 214 | + fallback={ |
| 215 | + <main class={styles.page}> |
| 216 | + <p role="alert">Instance not found.</p> |
| 217 | + </main> |
| 218 | + } |
| 219 | + > |
| 220 | + {(actorList) => ( |
| 221 | + <main class={styles.page}> |
| 222 | + <Title>Create Object</Title> |
| 223 | + <section |
| 224 | + class={`${styles.panel} ${objectStyles.panel}`} |
| 225 | + aria-labelledby="create-object-title" |
| 226 | + > |
| 227 | + <header class={styles.header}> |
| 228 | + <h1 id="create-object-title">Create Object</h1> |
| 229 | + <p>Selected Instance: {data()?.instance?.host}</p> |
| 230 | + </header> |
| 231 | + |
| 232 | + <form |
| 233 | + class={styles.form} |
| 234 | + aria-busy={isCreating()} |
| 235 | + onSubmit={(event) => { |
| 236 | + event.preventDefault(); |
| 237 | + submit(event.currentTarget); |
| 238 | + }} |
| 239 | + onReset={() => { |
| 240 | + setCurrentActor(null); |
| 241 | + setMessage(""); |
| 242 | + }} |
| 243 | + > |
| 244 | + <fieldset class={objectStyles.fields} disabled={isCreating()}> |
| 245 | + <div class={objectStyles.identity}> |
| 246 | + <Select<ActorOption> |
| 247 | + class={styles.field} |
| 248 | + disabled={isCreating()} |
| 249 | + value={currentActor() ?? actorList()[0]?.node ?? null} |
| 250 | + onChange={setCurrentActor} |
| 251 | + optionValue="id" |
| 252 | + optionTextValue="handle" |
| 253 | + placeholder="Select Handle" |
| 254 | + options={actorList().map((actor) => actor.node)} |
| 255 | + itemComponent={(itemProps) => ( |
| 256 | + <Select.Item |
| 257 | + class={objectStyles.option} |
| 258 | + item={itemProps.item} |
| 259 | + > |
| 260 | + <Select.ItemLabel> |
| 261 | + {itemProps.item.rawValue.handle} |
| 262 | + </Select.ItemLabel> |
| 263 | + <Select.ItemIndicator aria-hidden="true"> |
| 264 | + ✓ |
| 265 | + </Select.ItemIndicator> |
| 266 | + </Select.Item> |
| 267 | + )} |
| 268 | + > |
| 269 | + <Select.Label class={objectStyles.label}> |
| 270 | + Actor |
| 271 | + </Select.Label> |
| 272 | + <Select.Trigger |
| 273 | + class={`${styles.input} ${objectStyles.control} ${objectStyles.trigger}`} |
| 274 | + type="button" |
| 275 | + > |
| 276 | + <Select.Value<ActorOption>> |
| 277 | + {(state) => state.selectedOption().handle} |
| 278 | + </Select.Value> |
| 279 | + <Select.Icon aria-hidden="true">▾</Select.Icon> |
| 280 | + </Select.Trigger> |
| 281 | + <Select.Portal> |
| 282 | + <Select.Content class={objectStyles.selectContent}> |
| 283 | + <Select.Listbox class={objectStyles.listbox} /> |
| 284 | + </Select.Content> |
| 285 | + </Select.Portal> |
| 286 | + </Select> |
| 287 | + |
| 288 | + <label class={styles.field}> |
| 289 | + <span class={objectStyles.label}>Type</span> |
| 290 | + <select |
| 291 | + class={`${styles.input} ${objectStyles.control}`} |
| 292 | + name="type" |
| 293 | + > |
| 294 | + <option value="Note" selected> |
| 295 | + Note |
| 296 | + </option> |
| 297 | + <option value="Article">Article</option> |
| 298 | + </select> |
| 299 | + </label> |
| 300 | + </div> |
| 301 | + |
| 302 | + <TextField class={styles.field} name="name" defaultValue=""> |
| 303 | + <TextField.Label class={styles.fieldHeading}> |
| 304 | + Name <span class={styles.fieldStatus}>Optional</span> |
| 305 | + </TextField.Label> |
| 306 | + <TextField.Input |
| 307 | + class={styles.input} |
| 308 | + placeholder="Object title" |
| 309 | + /> |
| 310 | + </TextField> |
| 311 | + <TextField class={styles.field} name="content" required> |
| 312 | + <TextField.Label class={styles.fieldHeading}> |
| 313 | + Content <span class={styles.required}>Required</span> |
| 314 | + </TextField.Label> |
| 315 | + <TextField.TextArea |
| 316 | + class={`${styles.input} ${objectStyles.content}`} |
| 317 | + rows={8} |
| 318 | + placeholder="<p>Write your content here.</p>" |
| 319 | + /> |
| 320 | + <TextField.Description class={styles.hint}> |
| 321 | + HTML content is stored as entered. This object will not be |
| 322 | + delivered to remote servers. |
| 323 | + </TextField.Description> |
| 324 | + </TextField> |
| 325 | + <TextField |
| 326 | + class={styles.field} |
| 327 | + name="addressing" |
| 328 | + defaultValue="{}" |
| 329 | + > |
| 330 | + <TextField.Label class={styles.fieldHeading}> |
| 331 | + Addressing <span class={styles.fieldStatus}>JSON</span> |
| 332 | + </TextField.Label> |
| 333 | + <TextField.TextArea |
| 334 | + class={`${styles.input} ${objectStyles.addressing}`} |
| 335 | + rows={4} |
| 336 | + spellcheck={false} |
| 337 | + /> |
| 338 | + <TextField.Description class={styles.hint}> |
| 339 | + Use to, cc, bto, bcc, or audience with arrays of recipient |
| 340 | + URLs. Leave {"{}"} for no recipients. |
| 341 | + </TextField.Description> |
| 342 | + </TextField> |
| 343 | + <Show when={actorList().length === 0}> |
| 344 | + <p class={styles.hint}> |
| 345 | + Create an actor in this instance before creating an object. |
| 346 | + </p> |
| 347 | + </Show> |
| 348 | + <div class={objectStyles.actions}> |
| 349 | + <button class={objectStyles.reset} type="reset"> |
| 350 | + Reset |
| 351 | + </button> |
| 352 | + <button |
| 353 | + class={styles.button} |
| 354 | + type="submit" |
| 355 | + disabled={isCreating() || (actors()?.length ?? 0) === 0} |
| 356 | + > |
| 357 | + {isCreating() ? "Creating…" : "Create Object"} |
| 358 | + </button> |
| 359 | + </div> |
| 360 | + </fieldset> |
| 361 | + </form> |
| 362 | + </section> |
| 363 | + </main> |
| 364 | + )} |
| 365 | + </Show> |
| 366 | + ); |
| 367 | +} |
0 commit comments