diff --git a/packages/web/package.json b/packages/web/package.json index 684533e..32c3936 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -47,6 +47,7 @@ }, "dependencies": { "@kobalte/core": "^0.13.13", + "@formisch/solid": "1.0.0", "@solidjs/meta": "^0.29.4", "@solidjs/router": "^1.0.0", "@solidjs/start": "^2.0.0", @@ -54,6 +55,7 @@ "relay-runtime": "^21.0.1", "solid-js": "^1.9.5", "solid-relay": "1.0.0-beta.29", + "valibot": "1.4.2", "vite": "^8.2.0" }, "devDependencies": { diff --git a/packages/web/src/app.css b/packages/web/src/app.css new file mode 100644 index 0000000..679629b --- /dev/null +++ b/packages/web/src/app.css @@ -0,0 +1,378 @@ +/* +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 . +*/ + +* { + box-sizing: border-box; +} + +html { + background: var(--bg); + color: var(--ink); + font-family: var(--font-sans); + font-size: var(--text-body); +} + +body { + margin: 0; +} + +button, +input { + font: inherit; +} + +.app-shell { + min-height: 100svh; +} + +.app-header { + background: color-mix(in srgb, var(--bg) 92%, transparent); + border-bottom: 1px solid var(--line); + position: sticky; + top: 0; + z-index: 10; +} + +.app-header-inner { + align-items: center; + display: flex; + height: var(--header-h); + margin: 0 auto; + max-width: var(--container); + padding: 0 var(--gutter); +} + +.brand { + align-items: center; + color: var(--ink); + display: flex; + font-family: var(--font-serif); + font-size: 1.25rem; + font-weight: 700; + gap: 0.65rem; + letter-spacing: var(--tracking-heading); + text-decoration: none; +} + +.brand img { + border-radius: 50%; +} + +.app-nav { + align-self: stretch; + display: flex; + gap: 0.25rem; + margin-left: clamp(1.5rem, 5vw, 4rem); +} + +.app-nav a, +.header-action { + align-items: center; + color: var(--ink-soft); + display: flex; + font-size: 0.9rem; + font-weight: 600; + padding: 0 0.85rem; + position: relative; + text-decoration: none; +} + +.app-nav a:hover, +.header-action:hover, +.app-nav a.is-active, +.header-action.is-active { + color: var(--ink); +} + +.app-nav a.is-active::after { + background: var(--accent); + bottom: -1px; + content: ""; + height: 3px; + left: 0.85rem; + position: absolute; + right: 0.85rem; +} + +.header-action { + border: 1px solid var(--line-strong); + border-radius: var(--radius-pill); + margin-left: auto; + min-height: 2.4rem; +} + +.header-action.is-active { + background: var(--accent-tint); + border-color: var(--accent-soft); +} + +.app-header a:focus-visible { + outline: 3px solid var(--accent-soft); + outline-offset: 0.2rem; +} + +.app-content { + margin: 0 auto; + max-width: var(--container); + min-height: calc(100svh - var(--header-h)); + padding: clamp(2rem, 5vw, 4rem) var(--gutter); +} + +.app-content > main:not(.form-page) { + width: 100%; +} + +.app-content > main:not(.form-page) :is(h1, h2, p) { + margin-top: 0; +} + +.app-content h1, +.app-content h2 { + font-family: var(--font-serif); + letter-spacing: var(--tracking-heading); +} + +.app-content h1 { + font-size: var(--text-heading-2); + line-height: var(--leading-heading); +} + +.app-content p { + color: var(--ink-soft); + line-height: var(--leading-body); +} + +.form-page { + align-items: center; + display: flex; + justify-content: center; + min-height: calc(100svh - var(--header-h) - clamp(4rem, 10vw, 8rem)); +} + +.panel { + background: var(--card); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: var(--shadow-sm); +} + +.form-panel { + max-width: 28rem; + padding: clamp(1.5rem, 4vw, 2rem); + width: 100%; +} + +.panel-header { + border-bottom: 1px solid var(--line); + margin: 0 calc(clamp(1.5rem, 4vw, 2rem) * -1) 1.5rem; + padding: 0 clamp(1.5rem, 4vw, 2rem) 1.5rem; +} + +.panel-header h1 { + font-size: 1.65rem; + margin: 0 0 0.5rem; +} + +.panel-header p { + font-size: 0.95rem; + margin: 0; +} + +.form-panel form { + display: grid; + gap: 1rem; +} + +.field { + display: grid; + gap: 0.5rem; +} + +.field-heading { + align-items: baseline; + display: flex; + font-size: 0.9rem; + font-weight: 650; + justify-content: space-between; +} + +.field-status { + color: var(--ink-faint); + font-family: var(--font-mono); + font-size: 0.68rem; + font-weight: 500; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.field-status.required { + color: var(--accent-strong); +} + +.field input { + background: var(--surface-2); + border: 1px solid var(--line-strong); + border-radius: var(--radius-sm); + color: var(--ink); + min-width: 0; + padding: 0.9rem 1rem; + transition: + border-color var(--duration-fast) var(--ease-standard), + box-shadow var(--duration-fast) var(--ease-standard); +} + +.field input::placeholder { + color: var(--ink-faint); +} + +.field input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-tint); + outline: none; +} + +.field input:read-only { + border-style: dashed; + color: var(--ink-soft); + cursor: default; + font-family: var(--font-mono); +} + +.field-hint { + color: var(--ink-faint); + font-size: 0.78rem; + line-height: 1.45; + margin: 0; +} + +.field-error { + color: var(--danger); + font-size: 0.78rem; + line-height: 1.45; + margin: 0; +} + +.field input[aria-invalid="true"] { + border-color: var(--danger); +} + +.field input[aria-invalid="true"]:focus { + border-color: var(--danger); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 20%, transparent); +} + +.button { + align-items: center; + border: 0; + border-radius: var(--radius-sm); + cursor: pointer; + display: inline-flex; + font-weight: 700; + justify-content: center; + min-height: 2.9rem; + padding: 0.75rem 1.1rem; +} + +.button:focus-visible { + outline: 3px solid var(--accent-soft); + outline-offset: 0.2rem; +} + +.button.primary { + background: var(--accent); + color: var(--on-accent); + transition: + background var(--duration-fast) var(--ease-standard), + transform var(--duration-fast) var(--ease-standard); +} + +.button.primary:hover:not(:disabled) { + background: var(--accent-strong); +} + +.button:disabled { + cursor: wait; + opacity: 0.65; +} + +.notice { + background: var(--accent-tint); + border-radius: var(--radius-sm); + color: var(--ink); + line-height: 1.5; + margin: 1.25rem 0 0; + padding: 0.85rem 1rem; +} + +.notice.error { + background: color-mix(in srgb, var(--danger) 12%, var(--surface)); + border-left: 3px solid var(--danger); + color: var(--danger); +} + +.notice.success { + background: color-mix(in srgb, var(--success) 12%, var(--surface)); + border-left: 3px solid var(--success); + color: var(--success); +} + +.notice.warning { + background: color-mix(in srgb, var(--warning) 12%, var(--surface)); + border-left: 3px solid var(--warning); + color: var(--warning); +} + +@media (max-width: 600px) { + .app-header-inner { + padding: 0 1rem; + } + + .brand span { + display: none; + } + + .app-nav { + margin-left: 0.75rem; + } + + .app-nav a, + .header-action { + padding-inline: 0.65rem; + } + + .app-nav a.is-active::after { + left: 0.65rem; + right: 0.65rem; + } + + .app-content { + padding: 1.5rem 1rem 3rem; + } + + .form-page { + align-items: flex-start; + min-height: auto; + } +} + +@media (prefers-reduced-motion: reduce) { + .button.primary, + .field input { + transition: none; + } +} diff --git a/packages/web/src/routes/sign-in.tsx b/packages/web/src/routes/sign-in.tsx index 8190302..774736d 100644 --- a/packages/web/src/routes/sign-in.tsx +++ b/packages/web/src/routes/sign-in.tsx @@ -14,6 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +import { Field, Form, type SubmitHandler, createForm } from "@formisch/solid"; import { Alert } from "@kobalte/core/alert"; import { Button } from "@kobalte/core/button"; import { TextField } from "@kobalte/core/text-field"; @@ -21,6 +22,7 @@ import { Title } from "@solidjs/meta"; import { graphql } from "relay-runtime"; import { Match, Show, Switch, createSignal } from "solid-js"; import { createMutation } from "solid-relay"; +import * as v from "valibot"; import type { SignInMutation } from "./__generated__/SignInMutation.graphql.ts"; @@ -34,6 +36,15 @@ const signInMutation = graphql` } `; +const signInSchema = v.object({ + email: v.pipe( + v.string(), + v.trim(), + v.nonEmpty("Enter a valid email address."), + v.email("Enter a valid email address."), + ), +}); + interface SignInResult { message: string; status: "error" | "success"; @@ -43,21 +54,13 @@ export default function SignInPage() { const [result, setResult] = createSignal(); const [commitSignIn, isSigningIn] = createMutation(signInMutation); + const signInForm = createForm({ + schema: signInSchema, + initialInput: { email: "" }, + }); - const submit = (event: SubmitEvent & { currentTarget: HTMLFormElement }) => { - event.preventDefault(); - + const submit: SubmitHandler = ({ email }) => { const verifyUrl = `${globalThis.location.origin}/confirm/{token}?code={code}`; - const formData = new FormData(event.currentTarget); - const email = formData.get("email"); - if (typeof email !== "string" || email === "") { - setResult({ - message: "Enter a valid email address.", - status: "error", - }); - return; - } - setResult(undefined); commitSignIn({ variables: { email, verifyUrl }, @@ -96,22 +99,45 @@ export default function SignInPage() {

Enter your email address to receive a secure sign-in link.

-
- - - Email address - - Required - - - - + + + {(field) => ( + + + Email address + + Required + + + + + {(errors) => ( + + {errors()[0]} + + )} + + + )} + -
+ {(formResult) => ( diff --git a/packages/web/src/routes/workspace/create/instance.tsx b/packages/web/src/routes/workspace/create/instance.tsx index 7063487..ca36f90 100644 --- a/packages/web/src/routes/workspace/create/instance.tsx +++ b/packages/web/src/routes/workspace/create/instance.tsx @@ -15,6 +15,7 @@ // along with this program. If not, see . import { faker } from "@faker-js/faker"; +import { Field, Form, type SubmitHandler, createForm } from "@formisch/solid"; import { Button } from "@kobalte/core/button"; import { TextField } from "@kobalte/core/text-field"; import { Title } from "@solidjs/meta"; @@ -22,6 +23,7 @@ import { useNavigate } from "@solidjs/router"; import { graphql } from "relay-runtime"; import { Show, createSignal } from "solid-js"; import { createMutation } from "solid-relay"; +import * as v from "valibot"; import type { CreateInstanceMutation } from "./__generated__/CreateInstanceMutation.graphql.ts"; @@ -41,22 +43,33 @@ const createInstanceMutation = graphql` } `; +const createInstanceSchema = v.object({ + slug: v.pipe( + v.string(), + v.trim(), + v.minLength(4, "The slug must contain at least 4 characters."), + v.maxLength(63, "The slug must contain at most 63 characters."), + v.regex( + /^[a-z0-9-]+$/u, + "The slug can contain only lowercase letters, numbers, and hyphens.", + ), + ), +}); + export default function CreateInstancePage() { const navigate = useNavigate(); const [errorMessage, setErrorMessage] = createSignal(); const [commitCreateInstance, isCreatingInstance] = createMutation(createInstanceMutation); - const submit = (event: SubmitEvent & { currentTarget: HTMLFormElement }) => { - event.preventDefault(); - - const formData = new FormData(event.currentTarget); - const slug = formData.get("slug"); - if (typeof slug !== "string" || slug === "") { - setErrorMessage("Enter a valid slug."); - return; - } + const createInstanceForm = createForm({ + schema: createInstanceSchema, + initialInput: { + slug: `${faker.word.noun()}-${faker.word.noun()}-${faker.word.noun()}`.toLowerCase(), + }, + }); + const submit: SubmitHandler = ({ slug }) => { setErrorMessage(undefined); commitCreateInstance({ variables: { slug }, @@ -110,26 +123,48 @@ export default function CreateInstancePage() {

Review the generated identifier for your new instance.

-
- - - Slug - Generated · Read only - - - - - - DrFed generates this identifier automatically. It cannot be - edited. - - - + + + {(field) => ( + + + Slug + Generated · Read only + + + + + + DrFed generates this identifier automatically. It cannot be + edited. + + + + {(errors) => ( + + {errors()[0]} + + )} + + + )} + -
- +