diff --git a/packages/web/relay.config.json b/packages/web/relay.config.json index 89f9e7e..1f531fa 100644 --- a/packages/web/relay.config.json +++ b/packages/web/relay.config.json @@ -2,5 +2,10 @@ "src": "./src", "schema": "./schema.graphql", "language": "typescript", + "customScalarTypes": { + "DateTime": "string", + "URL": "string", + "UUID": "string" + }, "eagerEsModules": true } diff --git a/packages/web/src/components/ActorCard.tsx b/packages/web/src/components/ActorCard.tsx new file mode 100644 index 0000000..c7913a0 --- /dev/null +++ b/packages/web/src/components/ActorCard.tsx @@ -0,0 +1,49 @@ +// 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 . + +import { A } from "@solidjs/router"; +import { graphql } from "relay-runtime"; +import { Show } from "solid-js"; +import { createFragment } from "solid-relay"; + +import type { ActorCard_actor$key } from "./__generated__/ActorCard_actor.graphql.ts"; + +import styles from "~/styles/instance.module.css"; + +export const ActorCard = (props: { $actor: ActorCard_actor$key }) => { + const actorData = createFragment( + graphql` + fragment ActorCard_actor on Actor { + handle + id + } + `, + () => props.$actor, + ); + + return ( + + {(actor) => ( + + )} + + ); +}; diff --git a/packages/web/src/components/ActorDetail.tsx b/packages/web/src/components/ActorDetail.tsx index af8be37..52a5ee1 100644 --- a/packages/web/src/components/ActorDetail.tsx +++ b/packages/web/src/components/ActorDetail.tsx @@ -14,32 +14,144 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +import { Title } from "@solidjs/meta"; import { graphql } from "relay-runtime"; -import { Show } from "solid-js"; +import { For, Show } from "solid-js"; import { createFragment } from "solid-relay"; import type { ActorDetail_actor$key } from "./__generated__/ActorDetail_actor.graphql.ts"; +import { CopyButton } from "./CopyButton.tsx"; -import styles from "~/styles/instance.module.css"; +import styles from "~/styles/actor.module.css"; -export const ActorDetail = (props: { $actor: ActorDetail_actor$key }) => { - const actorData = createFragment( +/** + * Read-only identity and federation information for an actor. + * @returns The actor identity and endpoint sections. + */ +export function ActorDetail(props: { $actor: ActorDetail_actor$key }) { + const data = createFragment( graphql` - fragment ActorDetail_actor on Actor { + fragment ActorDetail_actor on Actor @throwOnFieldError { handle + username + uuid + type + created + iri + avatarUrl + inboxUrl + outbox { + iri + } + followers { + iri + } + following { + iri + } + featured { + iri + } + profileUrl + instance { + host + } } `, () => props.$actor, ); - return ( - - {(actor) => ( -
-
- )} + + {(actor) => { + const endpoints = () => [ + { label: "Actor IRI", url: actor().iri }, + { label: "Inbox", url: actor().inboxUrl }, + { label: "Outbox", url: actor().outbox?.iri }, + { label: "Followers", url: actor().followers?.iri }, + { label: "Following", url: actor().following?.iri }, + { label: "Featured", url: actor().featured?.iri }, + { label: "Profile", url: actor().profileUrl }, + ]; + return ( + <> + {actor().handle} — DrFed +
+ + {(url) => ( + + )} + +
+

{actor().type}

+

{actor().handle}

+ +
+
+
+

Identity

+
+
+
Username
+
{actor().username}
+
+
+
Instance
+
{actor().instance.host}
+
+
+
UUID
+
{actor().uuid}
+
+
+
Created
+
+ +
+
+
+
+
+

Federation endpoints

+
+ + {(endpoint) => ( + + {(url) => ( +
+
{endpoint.label}
+
+ {url()} + +
+
+ )} +
+ )} +
+
+
+ + ); + }}
); -}; +} + +// Stored remote URLs are data, not trusted navigation targets. +function httpUrl(value: string): string | undefined { + try { + const url = new URL(value); + return url.protocol === "https:" || url.protocol === "http:" + ? url.href + : undefined; + } catch { + return undefined; + } +} diff --git a/packages/web/src/components/CopyButton.tsx b/packages/web/src/components/CopyButton.tsx new file mode 100644 index 0000000..19210a2 --- /dev/null +++ b/packages/web/src/components/CopyButton.tsx @@ -0,0 +1,49 @@ +// 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 . + +import { createSignal } from "solid-js"; + +import styles from "~/styles/buttons.module.css"; + +/** + * Copy a value to the clipboard and announce the result. + * @returns A copy button with accessible feedback. + */ +export function CopyButton(props: { value: string; label: string }) { + const [message, setMessage] = createSignal(""); + async function copy() { + try { + await navigator.clipboard.writeText(props.value); + setMessage("Copied."); + } catch { + setMessage("Could not copy. Select and copy the text manually."); + } + } + return ( + + + {message()} + + ); +} diff --git a/packages/web/src/routes/actor/[id].tsx b/packages/web/src/routes/actor/[id].tsx new file mode 100644 index 0000000..3c1949a --- /dev/null +++ b/packages/web/src/routes/actor/[id].tsx @@ -0,0 +1,105 @@ +// 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 . + +import { Title } from "@solidjs/meta"; +import { + A, + type RouteDefinition, + type RouteSectionProps, + query, +} from "@solidjs/router"; +import { graphql } from "relay-runtime"; +import { ErrorBoundary, Show, Suspense } from "solid-js"; +import { + createPreloadedQuery, + loadQuery, + useRelayEnvironment, +} from "solid-relay"; + +import { ActorDetail } from "~/components/ActorDetail.tsx"; + +import type { ActorDetailQuery } from "./__generated__/ActorDetailQuery.graphql.ts"; + +import styles from "~/styles/actor.module.css"; + +const actorDetailQuery = graphql` + query ActorDetailQuery($id: ID!) @throwOnFieldError { + node(id: $id) { + ... on Actor @alias(as: "actor") { + ...ActorDetail_actor + } + } + } +`; + +const loadActorDetailQuery = query( + (id: string) => + loadQuery(useRelayEnvironment()(), actorDetailQuery, { + id, + }), + "ActorDetailQuery", +); + +export const route = { + preload({ params }) { + if (params.id === undefined) { + throw new Error("Missing actor ID route parameter."); + } + return loadActorDetailQuery(params.id); + }, +} satisfies RouteDefinition; + +type RouteData = ReturnType; + +export default function ActorDetailPage(props: RouteSectionProps) { + return ( +
+ Actor — DrFed + + ← All instances + + + Unable to load this actor. Please reload the page to try again. +

+ } + > + Loading actor…}> + + +
+
+ ); +} + +function ActorDetailContent(props: { data: RouteData }) { + const data = createPreloadedQuery( + actorDetailQuery, + () => props.data, + ); + const actor = () => { + const node = data()?.node; + return node?.actor; + }; + return ( + + Actor not found.

}> + {(value) => } +
+
+ ); +} diff --git a/packages/web/src/routes/instance/[slug].tsx b/packages/web/src/routes/instance/[slug].tsx index 131ebd3..e16527b 100644 --- a/packages/web/src/routes/instance/[slug].tsx +++ b/packages/web/src/routes/instance/[slug].tsx @@ -29,7 +29,7 @@ import { useRelayEnvironment, } from "solid-relay"; -import { ActorDetail } from "~/components/ActorDetail.tsx"; +import { ActorCard } from "~/components/ActorCard.tsx"; import type { InstanceDetailQuery } from "./__generated__/InstanceDetailQuery.graphql.ts"; @@ -47,7 +47,7 @@ const instanceDetailQuery = graphql` totalCount edges { node { - ...ActorDetail_actor + ...ActorCard_actor } } } @@ -165,7 +165,7 @@ export default function InstanceDetailPage(
- {(edge) => } + {(edge) => }
diff --git a/packages/web/src/styles/actor.module.css b/packages/web/src/styles/actor.module.css new file mode 100644 index 0000000..77a87e5 --- /dev/null +++ b/packages/web/src/styles/actor.module.css @@ -0,0 +1,117 @@ +/* +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 . +*/ + +.page { + display: grid; + gap: 1.5rem; + padding-bottom: 3rem; +} +.backLink { + color: var(--ink-soft); + justify-self: start; + font-weight: 700; + text-decoration: none; +} +.header { + display: flex; + align-items: center; + gap: 1rem; + border-bottom: 1px solid var(--line); + padding-bottom: 1.25rem; +} +.header > div { + min-width: 0; +} +.header h1 { + margin: 0; + font-family: var(--font-mono); + font-size: clamp(1.25rem, 4vw, 2rem); + overflow-wrap: anywhere; +} +.avatar { + border-radius: var(--radius-sm); + object-fit: cover; + flex-shrink: 0; +} +.label { + color: var(--accent-strong); + font-weight: 700; + margin: 0 0 0.5rem; +} +.panel { + background: var(--card); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: clamp(1rem, 3vw, 1.75rem); +} +.panel h2 { + margin: 0; + font-size: 1.3rem; +} +.fields { + margin: 1.25rem 0 0; +} +.fields > div { + display: grid; + grid-template-columns: 8rem minmax(0, 1fr); + gap: 1rem; + padding: 1rem 0; + border-top: 1px solid var(--line); +} +.fields dt { + color: var(--ink-soft); + font-weight: 700; +} +.fields dd { + margin: 0; + font-family: var(--font-mono); + font-size: 0.9rem; + overflow-wrap: anywhere; +} +.fields a { + color: var(--ink); + text-underline-offset: 0.2rem; +} +.endpoint { + display: flex; + align-items: baseline; + gap: 0.75rem; +} +.endpoint > a { + flex: 1; + min-width: 0; +} +.endpoint > :last-child { + flex-shrink: 0; + flex-wrap: wrap; + max-width: 40%; + margin-top: 0; +} +.page a:focus-visible { + outline: 3px solid var(--accent-soft); + outline-offset: 0.2rem; +} +@media (max-width: 520px) { + .fields > div { + grid-template-columns: 1fr; + gap: 0.5rem; + } + .header { + align-items: start; + } +} diff --git a/packages/web/src/styles/buttons.module.css b/packages/web/src/styles/buttons.module.css new file mode 100644 index 0000000..d82ea84 --- /dev/null +++ b/packages/web/src/styles/buttons.module.css @@ -0,0 +1,37 @@ +/* +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 . +*/ + +.copyControl { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.5rem; + font-size: 0.8rem; +} +.copyControl button { + background: var(--card); + color: var(--ink); + border: 1px solid var(--line); + border-radius: var(--radius-sm); + padding: 0.35rem 0.65rem; + cursor: pointer; +} +.copyControl button:focus-visible { + outline: 3px solid var(--accent-soft); + outline-offset: 0.2rem; +} diff --git a/packages/web/src/styles/instance.module.css b/packages/web/src/styles/instance.module.css index ba80e9a..3372baf 100644 --- a/packages/web/src/styles/instance.module.css +++ b/packages/web/src/styles/instance.module.css @@ -171,7 +171,7 @@ along with this program. If not, see . width: 0.85rem; } -.actorCard p { +.actorCard a { font-family: var(--font-mono); font-size: 0.9rem; margin: 0; @@ -180,7 +180,8 @@ along with this program. If not, see . .backLink:focus-visible, .createButton:focus-visible, -.endpointList a:focus-visible { +.endpointList a:focus-visible, +.actorCard a:focus-visible { outline: 3px solid var(--accent-soft); outline-offset: 0.2rem; }