Skip to content
Open
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
55 changes: 45 additions & 10 deletions src/components/forum/ThreadDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export const ThreadDetail: React.FC<ThreadDetailProps> = ({
courseId,
threadId,
)

const queryClient = useQueryClient()
const [replyContent, setReplyContent] = useState("")
const [isSubmitting, setIsSubmitting] = useState(false)
Expand All @@ -42,12 +43,15 @@ export const ThreadDetail: React.FC<ThreadDetailProps> = ({
try {
setIsSubmitting(true)
await replyToThread(courseId, threadId, replyContent)

await queryClient.invalidateQueries({
queryKey: ["forum", "thread", courseId, threadId],
})

await queryClient.invalidateQueries({
queryKey: ["forum", "threads", courseId],
})

setReplyContent("")
} catch (err) {
console.error("Failed to post reply", err)
Expand All @@ -58,11 +62,14 @@ export const ThreadDetail: React.FC<ThreadDetailProps> = ({

const handleDeleteThread = async () => {
if (!confirm("Are you sure you want to delete this thread?")) return

try {
await deleteThread(courseId, threadId)

await queryClient.invalidateQueries({
queryKey: ["forum", "threads", courseId],
})

onBack()
} catch (err) {
console.error("Failed to delete thread", err)
Expand All @@ -71,8 +78,10 @@ export const ThreadDetail: React.FC<ThreadDetailProps> = ({

const handleDeleteReply = async (replyId: number) => {
if (!confirm("Are you sure you want to delete this reply?")) return

try {
await deleteReply(courseId, replyId)

await queryClient.invalidateQueries({
queryKey: ["forum", "thread", courseId, threadId],
})
Expand All @@ -82,7 +91,11 @@ export const ThreadDetail: React.FC<ThreadDetailProps> = ({
}

if (isLoading) {
return <div className="text-white/60 animate-pulse">Loading discussion...</div>
return (
<div className="text-white/60 animate-pulse">
Loading discussion...
</div>
)
}

if (error || !thread) {
Expand All @@ -91,6 +104,7 @@ export const ThreadDetail: React.FC<ThreadDetailProps> = ({
<Button variant="secondary" size="sm" onClick={onBack}>
← Back to Discussions
</Button>

<div className="text-red-400 bg-red-400/10 p-4 rounded-xl border border-red-400/20">
Thread not found or failed to load.
</div>
Expand All @@ -110,7 +124,9 @@ export const ThreadDetail: React.FC<ThreadDetailProps> = ({
</button>

<div className="flex justify-between items-start gap-4 mb-6">
<h2 className="text-3xl font-bold text-white">{thread.title}</h2>
<h2 className="text-3xl font-bold text-white">
{thread.title}
</h2>

{(isAdmin || currentAddress === thread.author_address) && (
<button
Expand Down Expand Up @@ -147,7 +163,11 @@ export const ThreadDetail: React.FC<ThreadDetailProps> = ({
ctaLabel={currentAddress ? "Add a reply" : "Connect Wallet"}
onCtaClick={async () => {
if (currentAddress) {
const el = document.querySelector<HTMLTextAreaElement>("textarea[placeholder*='Type your response']")
const el =
document.querySelector<HTMLTextAreaElement>(
"textarea[placeholder*='Type your response']",
)

el?.focus()
} else {
await connectWallet()
Expand All @@ -163,18 +183,26 @@ export const ThreadDetail: React.FC<ThreadDetailProps> = ({
>
<div className="flex justify-between items-start">
<div className="flex items-center gap-3 text-xs text-white/50">
<WalletAddressPill address={reply.author_address} />
<WalletAddressPill
address={reply.author_address}
/>
<span>•</span>
<span>
{new Date(reply.created_at).toLocaleString()}
{new Date(
reply.created_at,
).toLocaleString()}
</span>
</div>

{(isAdmin || currentAddress === reply.author_address) && (
{(isAdmin ||
currentAddress ===
reply.author_address) && (
<button
type="button"
className="text-white/30 hover:text-red-400 transition-colors px-2 py-1"
onClick={() => handleDeleteReply(reply.id)}
onClick={() =>
handleDeleteReply(reply.id)
}
title="Delete reply"
aria-label="Delete reply"
>
Expand All @@ -184,7 +212,9 @@ export const ThreadDetail: React.FC<ThreadDetailProps> = ({
</div>

<div className="prose prose-sm prose-invert max-w-none text-white/70">
<ReactMarkdown>{reply.content}</ReactMarkdown>
<ReactMarkdown>
{reply.content}
</ReactMarkdown>
</div>
</div>
))}
Expand All @@ -200,7 +230,9 @@ export const ThreadDetail: React.FC<ThreadDetailProps> = ({
<textarea
placeholder="Type your response here (Markdown supported)..."
value={replyContent}
onChange={(e) => setReplyContent(e.target.value)}
onChange={(e) =>
setReplyContent(e.target.value)
}
rows={4}
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder:text-white/30 focus:outline-hidden focus:border-brand-cyan transition-colors font-mono text-sm"
/>
Expand All @@ -210,7 +242,9 @@ export const ThreadDetail: React.FC<ThreadDetailProps> = ({
variant="primary"
size="sm"
onClick={handleReply}
disabled={isSubmitting || !replyContent.trim()}
disabled={
isSubmitting || !replyContent.trim()
}
>
{isSubmitting ? "Posting..." : "Post Reply"}
</Button>
Expand All @@ -227,3 +261,4 @@ export const ThreadDetail: React.FC<ThreadDetailProps> = ({
</div>
)
}
}
121 changes: 62 additions & 59 deletions src/components/forum/ThreadList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,64 +149,67 @@ export const ThreadList: React.FC<ThreadListProps> = ({
</div>
)}

<div className="space-y-4">
{threads?.map((thread) => (
<button
key={thread.id}
type="button"
onClick={() => onSelectThread(thread.id)}
className="w-full text-left glass-card p-5 rounded-2xl border border-white/10 hover:border-brand-cyan/50 transition-all flex flex-col gap-3 group"
>
<div className="flex justify-between items-start gap-4">
<h4 className="text-lg font-bold group-hover:text-brand-cyan transition-colors line-clamp-1">
{thread.title}
</h4>
<div className="flex items-center gap-3 shrink-0">
<span className="text-xs text-white/40">
{new Date(thread.created_at).toLocaleDateString()}
</span>
{(isAdmin || currentAddress === thread.author_address) && (
<span
role="button"
tabIndex={0}
className="text-white/30 hover:text-red-400 transition-colors px-2 py-1"
onClick={(e) => {
e.stopPropagation()
void handleDelete(
e as unknown as React.MouseEvent,
thread.id,
)
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
e.stopPropagation()
void handleDelete(
e as unknown as React.MouseEvent,
thread.id,
)
}
}}
aria-label="Delete thread"
>
×
</span>
)}
</div>
</div>
<div className="text-sm text-white/60 line-clamp-2 prose prose-invert max-w-none">
<ReactMarkdown>{thread.content}</ReactMarkdown>
</div>
<div className="flex items-center justify-between mt-2 pt-3 border-t border-white/5">
<WalletAddressPill address={thread.author_address} />
<div className="text-xs font-semibold px-3 py-1 bg-white/5 rounded-full text-brand-cyan">
{thread.reply_count}{" "}
{thread.reply_count === 1 ? "reply" : "replies"}
</div>
</div>
</button>
))}
<div className="space-y-4">
{threads?.map((thread) => (
<button
key={thread.id}
type="button"
onClick={() => onSelectThread(thread.id)}
className="w-full text-left glass-card p-5 rounded-2xl border border-white/10 hover:border-brand-cyan/50 transition-all flex flex-col gap-3 group"
>
<div className="flex justify-between items-start gap-4">
<h4 className="text-lg font-bold group-hover:text-brand-cyan transition-colors line-clamp-1">
{thread.title}
</h4>

<div className="flex items-center gap-3 shrink-0">
<span className="text-xs text-white/40">
{new Date(thread.created_at).toLocaleDateString()}
</span>

{(isAdmin || currentAddress === thread.author_address) && (
<span
role="button"
tabIndex={0}
className="text-white/30 hover:text-red-400 transition-colors px-2 py-1"
onClick={(e) => {
e.stopPropagation()
void handleDelete(
e as unknown as React.MouseEvent,
thread.id,
)
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
e.stopPropagation()
void handleDelete(
e as unknown as React.MouseEvent,
thread.id,
)
}
}}
aria-label="Delete thread"
>
×
</span>
)}
</div>
</div>

<div className="text-sm text-white/60 line-clamp-2 prose prose-invert max-w-none">
<ReactMarkdown>{thread.content}</ReactMarkdown>
</div>

<div className="flex items-center justify-between mt-2 pt-3 border-t border-white/5">
<WalletAddressPill address={thread.author_address} />

<div className="text-xs font-semibold px-3 py-1 bg-white/5 rounded-full text-brand-cyan">
{thread.reply_count}{" "}
{thread.reply_count === 1 ? "reply" : "replies"}
</div>
</div>
</div>
)
</button>
))}
</div>
}
67 changes: 67 additions & 0 deletions src/hooks/useScholarNft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,18 @@ async function queryGetMetadata(
}
}

async function queryAllScholars(contractId: string): Promise<string[]> {
try {
const { scValToNative } = await import("@stellar/stellar-sdk")
const retval = await simulateCall(contractId, "get_all_scholars")
if (!retval) return []
return scValToNative(retval) as string[]
} catch (e) {
console.error("Error simulating get_all_scholars call:", e)
return []
}
}

// ---------------------------------------------------------------------------
// IPFS metadata fetch
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -374,3 +386,58 @@ export function useScholarNft(

return { credential: data!, status: "success", error: null }
}

export interface UseUserScholarNftsResult {
credentials: CredentialData[]
isLoading: boolean
error: string | null
refetch: () => void
}

export function useUserScholarNfts(
walletAddress: string | undefined,
): UseUserScholarNftsResult {
const contractId = CONTRACT_IDS.scholarNft
const { data, isLoading, error, refetch } = useQuery({
queryKey: ["user-scholar-nfts", walletAddress],
queryFn: async () => {
if (!walletAddress || !contractId) return []

// 1. Query all scholar addresses from contract
const scholars = await queryAllScholars(contractId)

// 2. Filter to find the 1-based token IDs belonging to this wallet
const tokenIds: number[] = []
scholars.forEach((addr, index) => {
if (addr === walletAddress) {
tokenIds.push(index + 1)
}
})

if (tokenIds.length === 0) return []

// 3. Fetch metadata/IPFS details in parallel for each owned token ID
const fetchPromises = tokenIds.map(async (id) => {
try {
return await fetchCredentialData(id.toString())
} catch (err) {
console.error(`Failed to fetch credential data for token ID ${id}`, err)
return null
}
})

const results = await Promise.all(fetchPromises)
return results.filter((item): item is CredentialData => item !== null)
},
enabled: Boolean(walletAddress && contractId),
staleTime: 60_000,
retry: false,
})

return {
credentials: data ?? [],
isLoading,
error: error ? (error as Error).message || "Failed to fetch user Scholar NFTs" : null,
refetch,
}
}
23 changes: 23 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,29 @@ body {
.light ::-webkit-scrollbar-thumb:hover {
background: rgba(0, 150, 210, 0.4);
}
/* ── Newly Earned NFT Glow Animation ─────────────────────────────────────── */
@keyframes newNftGlow {
0%,
100% {
box-shadow:
0 0 15px rgba(0, 210, 255, 0.35),
inset 0 0 10px rgba(0, 210, 255, 0.15);
border-color: rgba(0, 210, 255, 0.6);
}

50% {
box-shadow:
0 0 30px rgba(142, 45, 226, 0.75),
inset 0 0 15px rgba(142, 45, 226, 0.3);
border-color: rgba(142, 45, 226, 0.9);
}
}

.new-nft-card {
animation: newNftGlow 3s infinite alternate ease-in-out;
border: 2px solid transparent !important;
}

/* ── Driver.js Custom Styling ────────────────────────────────────────────── */
.driver-popover {
background-color: var(--color-glass-bg) !important;
Expand Down
Loading
Loading