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
200 changes: 185 additions & 15 deletions src/components/pages/wallet/documents/detail.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import { Download, PlayCircle, Upload } from "lucide-react";
import {
Archive,
Download,
MoreVertical,
PlayCircle,
Trash2,
Upload,
} from "lucide-react";

import { api } from "@/utils/api";
import useAppWallet from "@/hooks/useAppWallet";
Expand All @@ -10,6 +17,21 @@ import { toast } from "@/hooks/use-toast";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import FileDrop from "./file-drop";
import PageHeader from "@/components/ui/page-header";
import WalletDetailSkeleton from "@/components/pages/wallet/wallet-detail-skeleton";
import DocumentStatusBadge from "./status-badge";
Expand All @@ -28,6 +50,8 @@ export default function PageDocumentDetail() {
const documentId = router.query.documentId as string;
const { appWallet } = useAppWallet();
const [uploading, setUploading] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const [typedTitle, setTypedTitle] = useState("");

const utils = api.useUtils();
const { data: document, isLoading } = api.document.getById.useQuery(
Expand Down Expand Up @@ -59,6 +83,32 @@ export default function PageDocumentDetail() {
onError: (error) => toastError(error, "Could not upload the version"),
});

const archiveDocument = api.document.archiveDocument.useMutation({
onSuccess: async () => {
await refresh();
toast({
title: "Document archived",
description: "History is kept — it just leaves active use.",
});
},
onError: (error) => toastError(error, "Could not archive the document"),
});

const deleteDocument = api.document.deleteDocument.useMutation({
onSuccess: async (result) => {
await utils.document.listByWallet.invalidate({ walletId });
toast({
title: "Document deleted",
description:
result.signatureCount > 0
? `${result.signatureCount} signature${result.signatureCount === 1 ? "" : "s"} were destroyed with it.`
: "It carried no signatures.",
});
await router.push(`/wallets/${walletId}/documents`);
},
onError: (error) => toastError(error, "Could not delete the document"),
});

const exportProof = api.document.exportProof.useMutation({
onSuccess: (proof) => {
const blob = new Blob([JSON.stringify(proof, null, 2)], {
Expand Down Expand Up @@ -102,13 +152,49 @@ export default function PageDocumentDetail() {
);
}

// Signatures across every version. Zero means this is a draft nobody has
// acted on, and deleting it destroys nothing anyone relied on; above zero the
// dialog demands the title be retyped.
const signatureCount = document.versions.reduce(
(total, version) => total + version.reviews.length,
0,
);

return (
<main className="mx-auto flex w-full max-w-5xl flex-1 flex-col gap-4 p-3 sm:p-4 md:gap-6 lg:gap-8 lg:p-8">
<PageHeader
pageTitle={document.title}
backUrl={`/wallets/${walletId}/documents`}
>
<DocumentStatusBadge status={document.status} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline" aria-label="Document actions">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
disabled={
document.status === "Archived" || archiveDocument.isPending
}
onClick={() => archiveDocument.mutate({ documentId })}
>
<Archive className="mr-2 h-4 w-4" />
Archive
</DropdownMenuItem>
<DropdownMenuItem
className="text-red-500 focus:text-red-500"
onClick={() => {
setTypedTitle("");
setConfirmingDelete(true);
}}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete permanently
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</PageHeader>

{document.description && (
Expand All @@ -123,23 +209,26 @@ export default function PageDocumentDetail() {
</CardHeader>
<CardContent className="flex flex-col gap-2">
<p className="text-sm text-muted-foreground">
Uploading a new version supersedes the current one and starts a fresh
round at zero approvals — approval is bound to the content hash, not
the title.
Uploading a new version supersedes the current one and starts a
fresh round at zero approvals — approval is bound to the content
hash, not the title.
</p>
<Input
type="file"
disabled={uploading || uploadVersion.isPending}
onChange={(e) => void onUploadFile(e.target.files?.[0] ?? null)}
<FileDrop
busy={uploading || uploadVersion.isPending}
onFile={(file) => void onUploadFile(file)}
/>
</CardContent>
</Card>

<div className="flex flex-col gap-3">
{document.versions.map((version) => {
const snapshot = version.signerSnapshot;
const approvals = version.reviews.filter((r) => r.action === "approve");
const rejections = version.reviews.filter((r) => r.action === "reject");
const approvals = version.reviews.filter(
(r) => r.action === "approve",
);
const rejections = version.reviews.filter(
(r) => r.action === "reject",
);
const acted = new Set(version.reviews.map((r) => r.signerAddress));
const missing =
snapshot?.signersAddresses.filter((a) => !acted.has(a)) ?? [];
Expand All @@ -157,7 +246,9 @@ export default function PageDocumentDetail() {
size="sm"
variant="outline"
disabled={startReview.isPending}
onClick={() => startReview.mutate({ versionId: version.id })}
onClick={() =>
startReview.mutate({ versionId: version.id })
}
>
<PlayCircle className="mr-2 h-4 w-4" />
Start review
Expand All @@ -177,7 +268,9 @@ export default function PageDocumentDetail() {
size="sm"
variant="outline"
disabled={exportProof.isPending}
onClick={() => exportProof.mutate({ versionId: version.id })}
onClick={() =>
exportProof.mutate({ versionId: version.id })
}
>
<Download className="mr-2 h-4 w-4" />
Proof
Expand All @@ -204,7 +297,8 @@ export default function PageDocumentDetail() {
<div className="flex flex-col gap-2">
<span className="font-medium">
{approvals.length} of {snapshot.requiredSigners} approvals
{rejections.length > 0 && ` · ${rejections.length} rejected`}
{rejections.length > 0 &&
` · ${rejections.length} rejected`}
</span>
{version.reviews.map((review) => (
<div key={review.id} className="flex flex-col">
Expand Down Expand Up @@ -260,9 +354,85 @@ export default function PageDocumentDetail() {

<p className="flex items-center gap-2 text-xs text-muted-foreground">
<Upload className="h-3 w-3" />
An exported proof is an approval attestation by this wallet&apos;s signers.
It is not a qualified electronic signature.
An exported proof is an approval attestation by this wallet&apos;s
signers. It is not a qualified electronic signature.
</p>

<Dialog open={confirmingDelete} onOpenChange={setConfirmingDelete}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete this document?</DialogTitle>
<DialogDescription asChild>
<div className="space-y-2 text-sm">
<p>
This removes every version, every signature, the signer
snapshots, the attestation chain and the document&apos;s audit
trail. It cannot be undone.
</p>
{signatureCount > 0 ? (
<p className="text-red-500">
{signatureCount} signature
{signatureCount === 1 ? " has" : "s have"} been given on
this document. Deleting it destroys the evidence that
{signatureCount === 1
? " that person"
: " those people"}{" "}
approved anything. Archive keeps all of it.
</p>
) : (
<p>
No one has signed this document, so nothing is being erased
beyond the draft itself.
</p>
)}
</div>
</DialogDescription>
</DialogHeader>

{signatureCount > 0 && (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
Type{" "}
<span className="font-medium text-foreground">
{document.title}
</span>{" "}
to confirm.
</p>
<Input
value={typedTitle}
onChange={(e) => setTypedTitle(e.target.value)}
placeholder={document.title}
aria-label="Type the document title to confirm deletion"
/>
</div>
)}

<DialogFooter>
<Button
variant="outline"
onClick={() => setConfirmingDelete(false)}
>
Cancel
</Button>
<Button
variant="destructive"
disabled={
deleteDocument.isPending ||
(signatureCount > 0 && typedTitle !== document.title)
}
onClick={() =>
deleteDocument.mutate({
documentId,
...(signatureCount > 0 ? { confirmTitle: typedTitle } : {}),
})
}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete permanently
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</main>
);
}
91 changes: 91 additions & 0 deletions src/components/pages/wallet/documents/file-drop.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { useRef, useState } from "react";
import { FileUp, Loader2 } from "lucide-react";

/**
* File picker for a new document version.
*
* Replaces a bare `<input type="file">`, which renders the browser's own
* "Choose file / No file chosen" control — unstyleable, out of place against
* the rest of the UI, and silent about what is actually going to happen.
*
* The reassurance matters more than the styling: with `storageMode: hashOnly`
* the file is hashed in the browser and only the digest is sent, so the bytes
* never leave the machine. That is a genuinely surprising property and worth
* saying on the control itself rather than in documentation nobody opens.
*/
export default function FileDrop({
onFile,
busy,
disabled,
}: {
onFile: (file: File) => void;
/** Hashing or uploading in flight. */
busy?: boolean;
disabled?: boolean;
}) {
const inputRef = useRef<HTMLInputElement | null>(null);
const [dragging, setDragging] = useState(false);
const inert = disabled || busy;

const take = (file: File | null | undefined) => {
if (!file || inert) return;
onFile(file);
};

return (
<div
onDragOver={(e) => {
e.preventDefault();
if (!inert) setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
take(e.dataTransfer.files?.[0]);
}}
className={`rounded-lg border border-dashed p-6 text-center transition-colors ${
dragging
? "border-primary bg-primary/5"
: "border-border bg-muted/30 hover:border-muted-foreground/40"
} ${inert ? "opacity-60" : ""}`}
>
<input
ref={inputRef}
type="file"
className="sr-only"
disabled={inert}
onChange={(e) => {
take(e.target.files?.[0]);
// Reset so re-picking the same file fires change again.
e.target.value = "";
}}
/>

<div className="flex flex-col items-center gap-2">
{busy ? (
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
) : (
<FileUp className="h-6 w-6 text-muted-foreground" />
)}

<div className="text-sm">
<button
type="button"
disabled={inert}
onClick={() => inputRef.current?.click()}
className="font-medium text-primary underline underline-offset-2 disabled:no-underline disabled:opacity-60"
>
{busy ? "Hashing…" : "Choose a file"}
</button>
<span className="text-muted-foreground"> or drop it here</span>
</div>

<p className="max-w-sm text-xs leading-relaxed text-muted-foreground">
Hashed in your browser — only the SHA-256 digest is sent. The file
itself never leaves this machine.
</p>
</div>
</div>
);
}
Loading
Loading