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
218 changes: 218 additions & 0 deletions client/src/components/project/PostUpdateModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { Loader2 } from "lucide-react";
import { web3Enable, web3Accounts, web3FromSource } from "@polkadot/extension-dapp";
import { SiwsMessage } from "@talismn/siws";
import { generateSiwsStatement } from "@/lib/siwsUtils";
import { api, type ApiProjectUpdate } from "@/lib/api";
import { useToast } from "@/hooks/use-toast";

const BODY_MIN = 1;
const BODY_MAX = 2000;

// Mirrors server validateSimpleUrl: accepts www / http / https prefixes.
const isSimpleUrl = (v: string) =>
!v || v.startsWith("www") || v.startsWith("http://") || v.startsWith("https://");

export function PostUpdateModal({
open,
onOpenChange,
projectId,
projectTitle,
connectedAddress,
onPosted,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
projectId: string;
projectTitle: string;
connectedAddress: string;
onPosted: (update: ApiProjectUpdate) => void;
}) {
const [body, setBody] = useState("");
const [linkUrl, setLinkUrl] = useState("");
const [bodyError, setBodyError] = useState<string | null>(null);
const [linkError, setLinkError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const { toast } = useToast();

const reset = () => {
setBody("");
setLinkUrl("");
setBodyError(null);
setLinkError(null);
setSubmitting(false);
};

const validate = (): boolean => {
let ok = true;
const trimmed = body.trim();
if (trimmed.length < BODY_MIN) {
setBodyError("Body can't be empty.");
ok = false;
} else if (trimmed.length > BODY_MAX) {
setBodyError(`Body must be ${BODY_MAX} characters or fewer (currently ${trimmed.length}).`);
ok = false;
} else {
setBodyError(null);
}

const trimmedLink = linkUrl.trim();
if (trimmedLink && !isSimpleUrl(trimmedLink)) {
setLinkError("Link must start with http, https, or www.");
ok = false;
} else {
setLinkError(null);
}

return ok;
};

const handleSubmit = async () => {
if (!validate()) return;
setSubmitting(true);
try {
await web3Enable("Stadium");
const accounts = await web3Accounts();
const account = accounts.find((a) => a.address === connectedAddress) || accounts[0];
if (!account) throw new Error("No wallet account found");

const siws = new SiwsMessage({
domain: window.location.hostname,
uri: window.location.origin,
address: account.address,
nonce: Math.random().toString(36).slice(2),
statement: generateSiwsStatement({ action: "post-update", projectTitle }),
});
const injector = await web3FromSource(account.meta.source);
const signed = (await siws.sign(injector)) as unknown as { signature: string; message?: string };
const messageStr =
typeof signed.message === "string" && signed.message
? signed.message
: (siws as unknown as { toString: () => string }).toString();
const authHeader = btoa(
JSON.stringify({ message: messageStr, signature: signed.signature, address: account.address }),
);

const res = await api.postProjectUpdate(
projectId,
{ body: body.trim(), linkUrl: linkUrl.trim() || null },
authHeader,
);
onPosted(res.data);
toast({ title: "Update posted" });
reset();
onOpenChange(false);
} catch (e) {
const err = e as Error;
toast({
title: "Couldn't post update",
description: err?.message || "Unknown error",
variant: "destructive",
});
setSubmitting(false);
}
};

return (
<Dialog
open={open}
onOpenChange={(v) => {
if (!submitting) {
if (!v) reset();
onOpenChange(v);
}
}}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Post an update</DialogTitle>
<DialogDescription>
Share what's shipped, what's changed, or what you're asking for. Updates are visible to
anyone viewing your project page.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1">
<Label htmlFor="post-update-body">Update</Label>
<Textarea
id="post-update-body"
rows={6}
maxLength={BODY_MAX + 100 /* allow a little overflow so the error fires */}
placeholder="What happened this week?"
value={body}
onChange={(e) => setBody(e.target.value)}
aria-invalid={bodyError ? true : undefined}
aria-describedby="post-update-body-error post-update-body-count"
/>
<div className="flex items-center justify-between text-xs">
<span id="post-update-body-error" className="text-destructive">
{bodyError || ""}
</span>
<span
id="post-update-body-count"
className={
body.trim().length > BODY_MAX ? "text-destructive" : "text-muted-foreground"
}
>
{body.trim().length} / {BODY_MAX}
</span>
</div>
</div>
<div className="space-y-1">
<Label htmlFor="post-update-link">Link (optional)</Label>
<Input
id="post-update-link"
type="url"
placeholder="https://…"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
aria-invalid={linkError ? true : undefined}
aria-describedby="post-update-link-error"
/>
{linkError && (
<p id="post-update-link-error" className="text-xs text-destructive">
{linkError}
</p>
)}
</div>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => {
if (!submitting) {
reset();
onOpenChange(false);
}
}}
disabled={submitting}
>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={submitting}>
{submitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
Posting…
</>
) : (
"Post update"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
157 changes: 157 additions & 0 deletions client/src/components/project/ProjectUpdatesTab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { useCallback, useEffect, useState } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ExternalLink, Loader2, Sparkles, PenSquare } from "lucide-react";
import { api, type ApiProjectUpdate } from "@/lib/api";
import { PostUpdateModal } from "@/components/project/PostUpdateModal";

const formatDateTime = (iso: string) => {
const d = new Date(iso);
return d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
};

const truncateAddress = (addr: string) => {
if (!addr) return "team";
if (addr.length <= 14) return addr;
return `${addr.slice(0, 6)}…${addr.slice(-4)}`;
};

export function ProjectUpdatesTab({
projectId,
projectTitle,
canPost,
connectedAddress,
}: {
projectId: string;
projectTitle: string;
/** True when the connected wallet is a team member of this project (or admin). */
canPost: boolean;
/** The wallet address currently connected, if any. Required for canPost=true. */
connectedAddress?: string;
}) {
const [updates, setUpdates] = useState<ApiProjectUpdate[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [modalOpen, setModalOpen] = useState(false);

const load = useCallback(() => {
let active = true;
setLoading(true);
setError(null);
api
.getProjectUpdates(projectId)
.then((r) => {
if (active) setUpdates(r.data);
})
.catch((e: unknown) => {
if (active) setError(e instanceof Error ? e.message : "Failed to load updates");
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, [projectId]);

useEffect(() => {
const cleanup = load();
return cleanup;
}, [load]);

const handlePosted = (created: ApiProjectUpdate) => {
setUpdates((prev) => [created, ...prev]);
};

const postButton =
canPost && connectedAddress ? (
<div className="flex justify-end">
<Button onClick={() => setModalOpen(true)} className="gap-2">
<PenSquare className="h-4 w-4" aria-hidden="true" />
Post update
</Button>
</div>
) : null;

const modal =
canPost && connectedAddress ? (
<PostUpdateModal
open={modalOpen}
onOpenChange={setModalOpen}
projectId={projectId}
projectTitle={projectTitle}
connectedAddress={connectedAddress}
onPosted={handlePosted}
/>
) : null;

if (loading) {
return (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-10">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
Loading updates…
</div>
);
}

if (error) {
return <p className="text-sm text-destructive py-10">{error}</p>;
}

if (updates.length === 0) {
return (
<div className="space-y-4">
{postButton}
<Card>
<CardContent className="py-10 text-center">
<Sparkles className="mx-auto h-6 w-6 text-muted-foreground" aria-hidden="true" />
<p className="mt-3 text-sm font-medium">Nothing here yet</p>
<p className="mt-1 text-sm text-muted-foreground">
{canPost
? "Post the first update when something ships, pivots, or lands."
: "Your team can post the first update when something ships, pivots, or lands."}
</p>
</CardContent>
</Card>
{modal}
</div>
);
}

return (
<div className="space-y-4">
{postButton}
<ol className="space-y-4" aria-label="Project updates, most recent first">
{updates.map((u) => (
<li key={u.id}>
<Card>
<CardContent className="space-y-3 py-5">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{truncateAddress(u.createdBy)}</span>
<time dateTime={u.createdAt}>{formatDateTime(u.createdAt)}</time>
</div>
<p className="whitespace-pre-line text-sm leading-relaxed">{u.body}</p>
{u.linkUrl && (
<a
href={u.linkUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
>
<ExternalLink className="h-3.5 w-3.5" aria-hidden="true" />
{u.linkUrl}
</a>
)}
</CardContent>
</Card>
</li>
))}
</ol>
{modal}
</div>
);
}
Loading