diff --git a/app/[hypercertId]/page.tsx b/app/[hypercertId]/page.tsx index 7de61d5..b379891 100644 --- a/app/[hypercertId]/page.tsx +++ b/app/[hypercertId]/page.tsx @@ -1,165 +1,152 @@ "use client"; -import { DatePicker } from "@/components/date-range-picker"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Spinner } from "@/components/ui/spinner"; -import { Textarea } from "@/components/ui/textarea"; -import { Record } from "@/lexicons/types/org/hypercerts/claim/record"; import { useOAuthContext } from "@/providers/OAuthProviderSSR"; -import { Label } from "@radix-ui/react-label"; import { useParams, useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import { toast } from "sonner"; +import ContributionsView from "@/components/hypercert-contribution-view"; +import HypercertDetailsView from "@/components/hypercert-detail-view"; +import { Collections, HypercertRecordData } from "@/lib/types"; +import EvidenceView from "@/components/hypercert-evidence-view"; +import RightsView from "@/components/hypercert-rights-view"; +import LocationView from "@/components/hypercert-location-view"; +import HypercertEvaluationForm from "@/components/evaluation-form"; +import { Button } from "@/components/ui/button"; -export default function EditHypercertIdPage() { +export default function HypercertDetailsPage() { const params = useParams<{ hypercertId: string }>(); const hypercertId = params.hypercertId; const router = useRouter(); - const { atProtoAgent, session } = useOAuthContext(); - const [title, setTitle] = useState(""); - const [shortDescription, setShortDescription] = useState(""); - const [workScope, setWorkScope] = useState(""); - const [workTimeframeFrom, setWorkTimeframeFrom] = useState(null); - const [workTimeframeTo, setWorkTimeframeTo] = useState(null); - const [saving, setSaving] = useState(false); + const [certData, setCertData] = useState(); const [loading, setLoading] = useState(true); + const [showEvaluationForm, setShowEvaluationForm] = useState(false); useEffect(() => { + if (!atProtoAgent || !session || !hypercertId) { + router.push("/"); + } + }, [atProtoAgent, session, hypercertId, router]); + + useEffect(() => { + let cancelled = false; async function fetchHypercert() { + if (!atProtoAgent || !hypercertId) return; + setLoading(true); try { - const response = await atProtoAgent?.com.atproto.repo.getRecord({ + const response = await atProtoAgent.com.atproto.repo.getRecord({ repo: atProtoAgent.assertDid, - collection: "org.hypercerts.claim.record", + collection: Collections.claim, rkey: hypercertId, }); - console.log(response); - const record = response?.data?.value as Record; - - if (record) { - setTitle(record.title || ""); - setShortDescription(record.shortDescription || ""); - setWorkScope(record.workScope || ""); - setWorkTimeframeFrom( - record.workTimeframeFrom ? new Date(record.workTimeframeFrom) : null - ); - setWorkTimeframeTo( - record.workTimeFrameTo ? new Date(record.workTimeFrameTo) : null - ); - } + if (!cancelled) setCertData(response?.data as HypercertRecordData); } catch (error) { console.error("Error fetching hypercert:", error); toast.error("Failed to load hypercert"); } finally { - setLoading(false); + if (!cancelled) setLoading(false); } } fetchHypercert(); + return () => { + cancelled = true; + }; }, [atProtoAgent, hypercertId]); - if (!atProtoAgent || !session || !hypercertId) { - router.push("/"); - } - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - try { - if (!atProtoAgent || !session) return; - setSaving(true); - - const record = { - $type: "org.hypercerts.claim.record", - title, - shortDescription, - workScope, - workTimeframeFrom: workTimeframeFrom?.toISOString() || null, - workTimeFrameTo: workTimeframeTo?.toISOString() || null, - createdAt: new Date().toISOString(), - }; - - await atProtoAgent.com.atproto.repo.putRecord({ - repo: atProtoAgent.assertDid, - collection: "org.hypercerts.claim.record", - rkey: hypercertId, - record, - }); - - toast.success("Hypercert updated successfully!"); - } catch (error) { - console.error("Error updating hypercert:", error); - toast.error( - error instanceof Error ? error.message : "Failed to update hypercert" - ); - } finally { - setSaving(false); - } - }; - if (loading) { return ( -
+
); } - return ( -
-
- - setTitle(e.target.value)} - placeholder="Enter the hypercert name" - required - /> -
- -
- - -
-
- - setFile(e.target.files?.[0])} - type="file" - placeholder="Add Background Image" - required - > -
-
- - +
+ {/* Header */} +
+
+
+

+ Hypercert Starter Demo +

+ View Only +
+

+ A minimal demo using{" "} + AT Protocol and{" "} + Hypercerts lexicons to create, + edit, and view hypercert claims. +

+
+ +
+ + +
-
- - + + {/* Session / DID */} + + + Your Session + + {session + ? "Signed in with an active AT Protocol session." + : "You are not signed in."} + + + +
+ DID:  + {userDid || "—"} +
+ {!session && ( +

+ Sign in to create new hypercerts and see your list under{" "} + + /my-hypercerts + + . +

+ )} +
+
+ + {/* Quick links */} +
+ + + Quick Links + + Jump straight into common actions. + + + +
+
+
Create a Hypercert
+

+ Start a new claim with title, description, scope, image, and + timeframe. +

+
+ +
+ +
+
+
View My Hypercerts
+

+ Browse the records you’ve created in this demo. +

+
+ +
+
+
+ + {/* How it works */} + + + How This Demo Works + + AT Proto + Hypercerts, in a nutshell. + + + +
+ +

+ AT Protocol handles + identity and data via records on your repo. We interact through + an authenticated{" "} + + atProtoAgent + + . +

+
+
+ +

+ Hypercerts Lexicons define + the record shapes like{" "} + + org.hypercerts.claim + {" "} + and{" "} + + org.hypercerts.claim.contribution + + . Validation is done with the generated TypeScript helpers. +

+
+
+ +

+ You can create a claim at{" "} + /create,{" "} + view your claims at{" "} + + /my-hypercerts + + , and edit a specific claim + at{" "} + + /[hypercertId]/edit + + . +

+
+
+
- - + + {/* Developer notes */} + + + Developer Notes + + Where things live and what to expect. + + + +
    +
  • + Records are written to your AT Proto repo using{" "} + + com.atproto.repo.createRecord + + , updated via{" "} + putRecord, + and read via{" "} + getRecord. +
  • +
  • + Claims use the{" "} + + org.hypercerts.claim + {" "} + schema (title, shortDescription, workScope, image, timeframe). +
  • +
  • + Contributions use{" "} + + org.hypercerts.claim.contribution + {" "} + and can be linked from a claim via strong refs. +
  • +
  • + The edit page is view/edit capable. For a pure read-only + presentation, use a view page that only loads and renders record + fields. +
  • +
+ + + +
+ + +
+
+
+
); } diff --git a/components/blob-display.tsx b/components/blob-display.tsx new file mode 100644 index 0000000..99170b3 --- /dev/null +++ b/components/blob-display.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { SmallBlob, Uri } from "@/lexicons/types/org/hypercerts/defs"; +import { getBlobURL, getPDSlsURI } from "@/lib/utils"; +import { $Typed, BlobRef } from "@atproto/api"; +import { ReactNode } from "react"; +import { URILink } from "./uri-link"; + +export function BlobDisplay({ + content, + did, +}: { + content: $Typed | $Typed | { $type: string }; + did?: string; +}): ReactNode { + if (!content) return "—"; + const type = content.$type as string | undefined; + + if (type === "app.certified.defs#uri") { + const uri = (content as $Typed).uri; + return ; + } + + if (["smallBlob", "largeBlob", "blob"].includes(type || "")) { + const blobRef = (content as $Typed).blob; + return ( +

+ Blob-based data + {blobRef && ( + <> + {" · "} + + + )} +

+ ); + } + + return "—"; +} diff --git a/components/contributions-form.tsx b/components/contributions-form.tsx new file mode 100644 index 0000000..44a9683 --- /dev/null +++ b/components/contributions-form.tsx @@ -0,0 +1,225 @@ +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import * as Claim from "@/lexicons/types/org/hypercerts/claim/activity"; +import * as Contribution from "@/lexicons/types/org/hypercerts/claim/contribution"; +import { + createContribution, + getHypercert, + updateHypercert, +} from "@/lib/queries"; +import { + buildStrongRef, + validateContribution, + validateHypercert, +} from "@/lib/utils"; +import { useOAuthContext } from "@/providers/OAuthProviderSSR"; +import { ProfileView } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; +import { Trash } from "lucide-react"; +import { FormEventHandler, useState } from "react"; +import { toast } from "sonner"; +import { DatePicker } from "./date-range-picker"; +import FormFooter from "./form-footer"; +import FormInfo from "./form-info"; +import UserAvatar from "./user-avatar"; +import UserSelection from "./user-selection"; + +export default function HypercertContributionForm({ + hypercertId, + onBack, + onNext, +}: { + hypercertId: string; + onBack?: () => void; + onNext?: () => void; +}) { + const { atProtoAgent } = useOAuthContext(); + const [role, setRole] = useState(""); + const [contributors, setContributors] = useState([]); + const [description, setDescription] = useState(""); + const [workTimeframeFrom, setWorkTimeframeFrom] = useState(); + const [workTimeframeTo, setWorkTimeframeTo] = useState(); + const [saving, setSaving] = useState(false); + + const addContributor = (user: ProfileView) => { + const isAdded = contributors.find( + (contributor) => contributor.did === user.did + ); + if (!isAdded) { + setContributors((prev) => [...prev, user]); + } + }; + + const removeContributor = (user: ProfileView) => { + const filtered = contributors.filter( + (contributor) => contributor.did !== user.did + ); + setContributors(filtered); + }; + + const handleContributionCreation = async ( + hypercertRef: ReturnType + ) => { + if (!atProtoAgent) return; + const mappedContributors = contributors + .filter((contributor) => !!contributor) + .map(({ did }) => did); + const contributionRecord = { + $type: "org.hypercerts.claim.contribution", + hypercert: hypercertRef || undefined, + role, + contributors: mappedContributors.length ? mappedContributors : undefined, + description: description || undefined, + workTimeframeFrom: workTimeframeFrom?.toISOString(), + workTimeframeTo: workTimeframeTo?.toISOString(), + createdAt: new Date().toISOString(), + }; + + const isValidContribution = validateContribution(contributionRecord); + if (!isValidContribution.success || !mappedContributors.length) { + toast.error(isValidContribution.error || "Invalid contribution record"); + return; + } + const response = await createContribution( + atProtoAgent, + contributionRecord as Contribution.Record + ); + return response; + }; + + const handleHypercertUpdate = async ( + contributionData: Awaited>, + hypercertRecord: Claim.Record + ) => { + const contributionCid = contributionData?.data?.cid; + const contributionURI = contributionData?.data?.uri; + if (!contributionCid || !contributionURI) return; + const updatedHypercert = { + ...hypercertRecord, + contributions: [buildStrongRef(contributionCid, contributionURI)], + }; + const isValidHypercert = validateHypercert(updatedHypercert); + if (!isValidHypercert.success) { + toast.error(isValidHypercert.error || "Invalid updated hypercert"); + return; + } + await updateHypercert( + hypercertId, + atProtoAgent!, + updatedHypercert as Claim.Record + ); + toast.success("Contribution updated and linked!"); + onNext?.(); + }; + + const handleSubmit: FormEventHandler = async (e) => { + e.preventDefault(); + if (!atProtoAgent) return; + const hypercertInfo = await getHypercert(hypercertId, atProtoAgent); + const hypercertRef = buildStrongRef( + hypercertInfo.data.cid, + hypercertInfo.data.uri + ); + const hypercertRecord = (hypercertInfo.data.value || {}) as Claim.Record; + try { + setSaving(true); + const contributionData = await handleContributionCreation(hypercertRef); + await handleHypercertUpdate(contributionData, hypercertRecord); + } catch (error) { + console.error("Error saving contribution:", error); + toast.error("Failed to update contribution"); + } finally { + setSaving(false); + } + }; + + return ( + +
+
+ + setRole(e.target.value)} + maxLength={100} + required + /> +
+
+ +
+ +
+ +
+ {contributors.map((contributor) => ( +
+ + +
+ ))} +
+
+
+ +
+ +