diff --git a/app/collections/page.tsx b/app/collections/page.tsx index d356b62..65ec67f 100644 --- a/app/collections/page.tsx +++ b/app/collections/page.tsx @@ -11,6 +11,7 @@ import { Input } from "@/components/ui/input"; import { Layers, ChevronRight, Search, Globe, Lock, ExternalLink } from "lucide-react"; import Link from "next/link"; import { toast } from "sonner"; +import { recordRecentSnippet } from "@/lib/recent-snippets-storage"; interface Collection { id: string; @@ -343,7 +344,17 @@ export default function CollectionsPage() {

{snippet.language}

- + + recordRecentSnippet({ + id: snippet.id, + title: snippet.title, + language: snippet.language, + description: snippet.description, + }) + } + > diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx new file mode 100644 index 0000000..e1012af --- /dev/null +++ b/app/dashboard/page.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { Sidebar } from "@/components/Sidebar"; +import { RecentlyViewedSnippets } from "@/components/RecentlyViewedSnippets"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Layers, Star, FileCode2 } from "lucide-react"; + +export default function DashboardPage() { + return ( +
+ + +
+
+
+
+
+ +
+
+
+

Dashboard

+

+ Your recently viewed snippets and quick links. +

+
+
+ + + + + + + + + +
+
+ + +
+
+
+ ); +} diff --git a/app/snippets/page.tsx b/app/snippets/page.tsx index e69de29..7cb7d39 100644 --- a/app/snippets/page.tsx +++ b/app/snippets/page.tsx @@ -0,0 +1,767 @@ +"use client"; + +import React from "react"; +import { useState, useEffect, useRef } from "react"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Trash2, Copy, Plus, Star } from "lucide-react"; +import { Sidebar } from "@/components/Sidebar"; +import Loader from "@/components/ui/loader"; +import { VersionHistoryPanel } from "@/components/VersionHistory"; +import { PermissionsManager } from "@/components/PermissionsManager"; +import VerificationBadge from "@/components/verification-badge"; +import VerifyOwnershipButton from "@/components/verify-ownership-button"; +import { useWallet } from "@/components/WalletConnect"; +import { recordRecentSnippet } from "@/lib/recent-snippets-storage"; + +const LANGUAGES = [ + "javascript", + "typescript", + "python", + "java", + "csharp", + "cpp", + "go", + "rust", + "php", + "ruby", + "sql", + "html", + "css", + "bash", +]; + +interface Snippet { + id: string; + title: string; + description: string; + code: string; + language: string; + tags: string[]; + owner_wallet_address: string | null; + created_at: string; + updated_at: string; +} + +// Paginated response interface +interface PaginatedResponse { + data: Snippet[]; + total: number; + limit: number; + offset: number; + hasMore: boolean; +} + +interface VerificationStatus { + verified: boolean; + walletAddress?: string; + verifiedAt?: string; +} + +const DEFAULT_LIMIT = 20; + +export default function SnippetsPage() { + const wallet = useWallet(); + const [snippets, setSnippets] = useState([]); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(true); + const [total, setTotal] = useState(0); + const [offset, setOffset] = useState(0); + const [showForm, setShowForm] = useState(false); + const [editingId, setEditingId] = useState(null); + const [formData, setFormData] = useState({ + title: "", + description: "", + code: "", + language: "javascript", + tags: "", + }); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [verificationStatuses, setVerificationStatuses] = useState>({}); + const [favoriteStatuses, setFavoriteStatuses] = useState>({}); + const openedHashRef = useRef(null); + + useEffect(() => { + fetchSnippets(); + }, []); + + const fetchVerificationStatuses = async (snippetList: Snippet[]) => { + try { + const statuses = await Promise.all( + snippetList.map(async (snippet) => { + try { + const res = await fetch(`/api/snippets/${snippet.id}/verification-status`); + if (!res.ok) { + return [snippet.id, { verified: false } as VerificationStatus] as const; + } + const json = await res.json(); + return [ + snippet.id, + { + verified: Boolean(json.verification), + walletAddress: json.verification?.wallet_address, + verifiedAt: json.verification?.verified_at, + } as VerificationStatus, + ] as const; + } catch (err) { + console.error("Failed to fetch verification status for snippet:", snippet.id, err); + return [snippet.id, { verified: false } as VerificationStatus] as const; + } + }), + ); + + setVerificationStatuses((prev) => ({ ...prev, ...Object.fromEntries(statuses) })); + } catch (err) { + console.error("Failed to load verification statuses:", err); + } + }; + + const fetchFavoriteStatuses = async (snippetIds: string[]) => { + try { + const res = await fetch("/api/favorites/status", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ snippetIds }), + }); + if (res.ok) { + const statuses = await res.json(); + setFavoriteStatuses((prev) => ({ ...prev, ...statuses })); + } + } catch (err) { + console.error("Failed to fetch favorite statuses:", err); + } + }; + + const toggleFavorite = async (snippetId: string) => { + try { + const res = await fetch("/api/favorites", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ snippetId }), + }); + if (res.ok) { + const result = await res.json(); + setFavoriteStatuses((prev) => ({ ...prev, [snippetId]: result.favorited })); + } + } catch (err) { + console.error("Failed to toggle favorite:", err); + } + }; + + const fetchSnippets = async (loadMore = false) => { + try { + if (loadMore) { + setLoadingMore(true); + } else { + setLoading(true); + setVerificationStatuses({}); + } + + const currentOffset = loadMore ? offset : 0; + const res = await fetch(`/api/snippets?limit=${DEFAULT_LIMIT}&offset=${currentOffset}`); + + if (!res.ok) throw new Error("Failed to fetch snippets"); + + const data: PaginatedResponse = await res.json(); + + if (loadMore) { + setSnippets(prev => [...prev, ...data.data]); + } else { + setSnippets(data.data); + } + + setTotal(data.total); + setHasMore(data.hasMore); + setOffset(currentOffset + data.data.length); + await fetchVerificationStatuses(data.data); + await fetchFavoriteStatuses(data.data.map((s) => s.id)); + } catch (e) { + console.error(e); + } finally { + setLoading(false); + setLoadingMore(false); + } + }; + + const handleLoadMore = () => { + if (!loadingMore && hasMore) { + fetchSnippets(true); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSaving(true); + setError(null); + try { + const payload = { + ...formData, + tags: formData.tags + .split(",") + .map((t) => t.trim()) + .filter(Boolean), + }; + const res = await fetch( + editingId ? `/api/snippets/${editingId}` : "/api/snippets", + { + method: editingId ? "PUT" : "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }, + ); + if (!res.ok) throw new Error("Failed to save snippet"); + + // Reset pagination and fetch fresh data + setOffset(0); + setHasMore(true); + await fetchSnippets(); + handleCancel(); + } catch (e: any) { + console.error(e); + setError(e.message || "Failed to save snippet. Please try again."); + } finally { + setSaving(false); + } + }; + + const handleEdit = (s: Snippet) => { + setEditingId(s.id); + setFormData({ + title: s.title, + description: s.description, + code: s.code, + language: s.language, + tags: Array.isArray(s.tags) ? s.tags.join(", ") : "", + }); + setShowForm(true); + recordRecentSnippet({ + id: s.id, + title: s.title, + language: s.language, + description: s.description, + }); + }; + + // Reopen a snippet linked via /snippets#id (dashboard / collections deep links) + useEffect(() => { + if (loading) return; + + let cancelled = false; + + const openFromHash = async () => { + const id = window.location.hash.replace(/^#/, ""); + if (!id || openedHashRef.current === id) return; + + const local = snippets.find((s) => s.id === id); + if (local) { + openedHashRef.current = id; + handleEdit(local); + requestAnimationFrame(() => { + document.getElementById(`snippet-${id}`)?.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + }); + return; + } + + // Snippet may be beyond the currently loaded page — fetch by id + try { + const res = await fetch(`/api/snippets/${id}`); + if (!res.ok || cancelled) return; + const snippet = (await res.json()) as Snippet; + if (!snippet?.id || cancelled) return; + openedHashRef.current = id; + handleEdit(snippet); + } catch (e) { + console.error("Failed to open snippet from hash:", e); + } + }; + + openFromHash(); + const onHashChange = () => { + openedHashRef.current = null; + openFromHash(); + }; + window.addEventListener("hashchange", onHashChange); + return () => { + cancelled = true; + window.removeEventListener("hashchange", onHashChange); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [loading, snippets]); + + const handleDelete = async (id: string) => { + if (!confirm("Delete this snippet?")) return; + + let signatureData = null; + + // Request wallet signature if connected + if (wallet?.connected && wallet?.signAction) { + try { + signatureData = await wallet.signAction("delete_snippet", id); + } catch (err: any) { + console.error("Signature failed:", err); + alert(`Signature required to delete snippet: ${err.message}`); + return; // Abort deletion if signature fails + } + } + + try { + const headers: Record = { + "Content-Type": "application/json" + }; + + // Include signature headers if available + if (signatureData) { + headers["x-wallet-signature"] = signatureData.signature; + headers["x-wallet-nonce"] = signatureData.nonce; + headers["x-wallet-timestamp"] = signatureData.timestamp.toString(); + headers["x-wallet-address"] = wallet.publicKey; + } + + // Add standard auth header if token exists + if (wallet?.token) { + headers["Authorization"] = `Bearer ${wallet.token}`; + } + + const res = await fetch(`/api/snippets/${id}`, { + method: "DELETE", + headers + }); + + if (!res.ok) { + const errorData = await res.json().catch(() => null); + throw new Error(errorData?.error || errorData?.message || "Failed to delete snippet"); + } + + // Reset pagination and fetch fresh data + setOffset(0); + setHasMore(true); + await fetchSnippets(); + } catch (e: any) { + console.error(e); + alert(e.message || "An error occurred"); + } + }; + + const handleCopy = async (code: string) => { + try { + await navigator.clipboard.writeText(code); + } catch (e) { + console.error(e); + } + }; + + const handleCancel = () => { + setShowForm(false); + setEditingId(null); + setError(null); + setFormData({ + title: "", + description: "", + code: "", + language: "javascript", + tags: "", + }); + }; + + return ( +
+ + +
+
+
+
+
+ +
+ {/* Page heading row */} +
+

My Snippets

+ {!showForm && ( + + )} +
+ + {/* Form */} + {showForm && ( + +
+
+

+ {editingId ? "Edit Snippet" : "Add New Snippet"} +

+
+ {error && ( +
+ {error} +
+ )} +
+ + + setFormData({ + ...formData, + title: e.target.value, + }) + } + className="bg-slate-800/60 +border border-purple-500/30 +text-white +placeholder:text-slate-400 +focus:border-purple-400 +focus:ring-2 +focus:ring-purple-500/30 +transition-all duration-200" + required + /> +
+
+ +