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() {
-
+
+ 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"}
+
+
+
+ )}
+
+ {/* Grid */}
+ {loading ? (
+
+
+
+ ) : snippets.length === 0 ? (
+
+
+ No snippets yet. Create your first one!
+
+ {!showForm && (
+
+ )}
+
+ ) : (
+ <>
+
+ {snippets.map((snippet) => {
+ const verificationStatus = verificationStatuses[snippet.id] || {
+ verified: false,
+ };
+ const isOwner =
+ wallet?.publicKey && snippet.owner_wallet_address
+ ? wallet.publicKey.toUpperCase() === snippet.owner_wallet_address.toUpperCase()
+ : false;
+
+ return (
+
+
+
+
+
+
+ {snippet.title}
+
+
+ {snippet.description || "No description"}
+
+
+
+ {verificationStatus.verified && (
+
+ )}
+
+
+
+ {snippet.language}
+
+ {isOwner && !verificationStatus.verified && (
+
+ Owns snippet — ready to verify
+
+ )}
+
+
+
+
+ {snippet.code.slice(0, 200)}
+ {snippet.code.length > 200 ? "..." : ""}
+
+
+ {Array.isArray(snippet.tags) && snippet.tags.length > 0 && (
+
+ {snippet.tags.map((tag) => (
+
+ #{tag}
+
+ ))}
+
+ )}
+
+ Created: {new Date(snippet.created_at).toLocaleDateString()}
+
+
+
+
fetchSnippets()}
+ />
+ {snippet.owner_wallet_address && (
+
+ )}
+ {!verificationStatus.verified && (
+ {
+ setOffset(0);
+ setHasMore(true);
+ fetchSnippets();
+ }}
+ className="flex-1"
+ />
+ )}
+
+
+
+
+
+ );
+ })}
+
+
+ {/* Load More Button */}
+ {hasMore && (
+
+
+
+ )}
+
+ {!hasMore && snippets.length > 0 && (
+
+ Showing all {total} snippets
+
+ )}
+ >
+ )}
+
+
+
+ );
+}
diff --git a/components/RecentlyViewedSnippets.tsx b/components/RecentlyViewedSnippets.tsx
new file mode 100644
index 0000000..9663995
--- /dev/null
+++ b/components/RecentlyViewedSnippets.tsx
@@ -0,0 +1,163 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import Link from "next/link";
+import { formatDistanceToNow } from "date-fns";
+import { Clock, FileCode2, Trash2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Card } from "@/components/ui/card";
+import {
+ clearRecentSnippets,
+ getRecentSnippets,
+ type RecentSnippetEntry,
+} from "@/lib/recent-snippets-storage";
+
+function EmptyState() {
+ return (
+
+
+
+ No recently viewed snippets
+
+
+ Open a snippet from Snippets or Collections and it will show up here for
+ quick access.
+
+
+
+
+
+ );
+}
+
+export function RecentlyViewedSnippets() {
+ const [items, setItems] = useState([]);
+ const [hydrated, setHydrated] = useState(false);
+
+ const refresh = useCallback(() => {
+ setItems(getRecentSnippets());
+ setHydrated(true);
+ }, []);
+
+ useEffect(() => {
+ refresh();
+
+ const onStorage = (event: StorageEvent) => {
+ if (event.key === null || event.key === "codely_recent_snippets") {
+ refresh();
+ }
+ };
+ const onLocalChange = () => refresh();
+
+ window.addEventListener("storage", onStorage);
+ window.addEventListener("codely:recent-snippets-changed", onLocalChange);
+ return () => {
+ window.removeEventListener("storage", onStorage);
+ window.removeEventListener(
+ "codely:recent-snippets-changed",
+ onLocalChange,
+ );
+ };
+ }, [refresh]);
+
+ const handleClear = () => {
+ if (items.length === 0) return;
+ if (!confirm("Clear your recently viewed snippets history?")) return;
+ clearRecentSnippets();
+ setItems([]);
+ };
+
+ if (!hydrated) {
+ return (
+
+
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ Recently Viewed
+
+
+ Jump back into snippets you opened recently.
+
+
+
+
+
+ {items.length === 0 ? (
+
+ ) : (
+
+ {items.map((snippet) => (
+
+
+
+
+
+ {snippet.title}
+
+
+ {snippet.language}
+
+
+ {snippet.description ? (
+
+ {snippet.description}
+
+ ) : (
+
No description
+ )}
+
+ Viewed{" "}
+ {formatDistanceToNow(new Date(snippet.viewedAt), {
+ addSuffix: true,
+ })}
+
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx
index fdba562..998983b 100644
--- a/components/Sidebar.tsx
+++ b/components/Sidebar.tsx
@@ -13,15 +13,16 @@ import {
Menu,
X,
Star,
+ LayoutDashboard,
} from "lucide-react";
import { cn } from "@/lib/utils";
const NAV_ITEMS = [
{ label: "Home", href: "/", icon: Home },
+ { label: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
{ label: "Snippets", href: "/snippets", icon: FileCode2 },
{ label: "Collections", href: "/collections", icon: Layers },
{ label: "Favorites", href: "/favorites", icon: Star },
- { label: "Collections", href: "/collections", icon: Layers },
];
export function Sidebar() {
diff --git a/components/WalletConnect.tsx b/components/WalletConnect.tsx
index afc4fa5..18c1a71 100644
--- a/components/WalletConnect.tsx
+++ b/components/WalletConnect.tsx
@@ -1,6 +1,6 @@
"use client";
-import React, { useState, useEffect, useMemo, useCallback } from "react";
+import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Wallet } from "lucide-react";
import {
@@ -245,226 +245,3 @@ export function WalletButton() {
>
);
}
-
- const wallet = useWallet();
- const [showModal, setShowModal] = useState(false);
- const [showStatusDialog, setShowStatusDialog] = useState(false);
- const [balance, setBalance] = useState(null);
- const [balanceLoading, setBalanceLoading] = useState(false);
- const [faucetLoading, setFaucetLoading] = useState(false);
-
- if (!wallet) return null;
-
- const {
- connected,
- publicKey,
- connecting,
- reconnecting,
- connect,
- disconnect,
- error,
- clearError,
- token,
- } = wallet;
-
- const fetchWalletBalance = async (address: string) => {
- setBalanceLoading(true);
- try {
- const response = await fetch(
- `https://horizon-testnet.stellar.org/accounts/${encodeURIComponent(address)}`
- );
- if (!response.ok) {
- setBalance("0.00");
- return;
- }
-
- const account = await response.json();
- const nativeBalance = account.balances?.find(
- (item: any) => item.asset_type === "native"
- )?.balance;
- setBalance(nativeBalance ?? "0.00");
- } catch (err) {
- console.error("Error fetching wallet balance:", err);
- setBalance("0.00");
- } finally {
- setBalanceLoading(false);
- }
- };
-
- useEffect(() => {
- if (connected && publicKey) {
- fetchWalletBalance(publicKey);
- }
- }, [connected, publicKey]);
-
- const handleRequestFaucet = async () => {
- if (!publicKey) {
- return;
- }
-
- setFaucetLoading(true);
- try {
- const response = await fetch("/api/wallet/faucet", {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- ...(token ? { Authorization: `Bearer ${token}` } : {}),
- },
- });
-
- const result = await response.json();
- if (!response.ok) {
- throw new Error(
- result?.error || "Failed to request Stellar testnet tokens."
- );
- }
-
- await fetchWalletBalance(publicKey);
- } catch (err: any) {
- console.error("Faucet request failed:", err);
- } finally {
- setFaucetLoading(false);
- }
- };
-
- const handleDisconnect = async () => {
- setShowStatusDialog(false);
- setBalance(null);
- await disconnect();
- };
-
- const handleWalletSelect = async (walletType: WalletProviderType) => {
- setShowModal(false);
- await connect(walletType);
- };
-
- const handleOpenModal = () => {
- if (clearError) clearError();
- setShowModal(true);
- };
-
- if (connected && publicKey) {
- return (
- <>
-
-
-
- >
- );
- }
-
- return (
- <>
- {/* Use WalletConnectButton for the trigger, giving reconnection feedback */}
-
-
-
- >
- );
-}
\ No newline at end of file
diff --git a/lib/recent-snippets-storage.test.ts b/lib/recent-snippets-storage.test.ts
new file mode 100644
index 0000000..5a162d9
--- /dev/null
+++ b/lib/recent-snippets-storage.test.ts
@@ -0,0 +1,114 @@
+/**
+ * @jest-environment node
+ */
+
+class MemoryStorage {
+ private store = new Map();
+
+ getItem(key: string) {
+ return this.store.has(key) ? this.store.get(key)! : null;
+ }
+
+ setItem(key: string, value: string) {
+ this.store.set(key, String(value));
+ }
+
+ removeItem(key: string) {
+ this.store.delete(key);
+ }
+
+ clear() {
+ this.store.clear();
+ }
+}
+
+const memoryStorage = new MemoryStorage();
+
+Object.defineProperty(globalThis, "localStorage", {
+ value: memoryStorage,
+ configurable: true,
+});
+
+Object.defineProperty(globalThis, "window", {
+ value: {
+ localStorage: memoryStorage,
+ dispatchEvent: () => true,
+ },
+ configurable: true,
+});
+
+import {
+ RECENT_SNIPPETS_STORAGE_KEY,
+ MAX_RECENT_SNIPPETS,
+ clearRecentSnippets,
+ getRecentSnippets,
+ recordRecentSnippet,
+} from "./recent-snippets-storage";
+
+describe("recent-snippets-storage", () => {
+ beforeEach(() => {
+ memoryStorage.clear();
+ });
+
+ it("returns an empty list when nothing is stored", () => {
+ expect(getRecentSnippets()).toEqual([]);
+ });
+
+ it("records a snippet and returns it as most recent", () => {
+ const result = recordRecentSnippet({
+ id: "a",
+ title: "Alpha",
+ language: "typescript",
+ description: "desc",
+ });
+
+ expect(result).toHaveLength(1);
+ expect(result[0]).toMatchObject({
+ id: "a",
+ title: "Alpha",
+ language: "typescript",
+ description: "desc",
+ });
+ expect(result[0].viewedAt).toBeTruthy();
+ expect(getRecentSnippets()).toEqual(result);
+ });
+
+ it("dedupes by id and moves the viewed snippet to the front", () => {
+ recordRecentSnippet({ id: "a", title: "A", language: "js" });
+ recordRecentSnippet({ id: "b", title: "B", language: "py" });
+ recordRecentSnippet({ id: "a", title: "A updated", language: "js" });
+
+ const recent = getRecentSnippets();
+ expect(recent.map((s) => s.id)).toEqual(["a", "b"]);
+ expect(recent[0].title).toBe("A updated");
+ });
+
+ it("caps history at MAX_RECENT_SNIPPETS", () => {
+ for (let i = 0; i < MAX_RECENT_SNIPPETS + 5; i++) {
+ recordRecentSnippet({
+ id: `id-${i}`,
+ title: `Snippet ${i}`,
+ language: "javascript",
+ });
+ }
+
+ const recent = getRecentSnippets();
+ expect(recent).toHaveLength(MAX_RECENT_SNIPPETS);
+ expect(recent[0].id).toBe(`id-${MAX_RECENT_SNIPPETS + 4}`);
+ });
+
+ it("clears history", () => {
+ recordRecentSnippet({ id: "a", title: "A", language: "js" });
+ clearRecentSnippets();
+ expect(getRecentSnippets()).toEqual([]);
+ expect(localStorage.getItem(RECENT_SNIPPETS_STORAGE_KEY)).toBeNull();
+ });
+
+ it("ignores corrupt localStorage payloads", () => {
+ localStorage.setItem(RECENT_SNIPPETS_STORAGE_KEY, "{not-json");
+ expect(getRecentSnippets()).toEqual([]);
+
+ localStorage.setItem(RECENT_SNIPPETS_STORAGE_KEY, JSON.stringify([{ id: 1 }]));
+ expect(getRecentSnippets()).toEqual([]);
+ });
+});
diff --git a/lib/recent-snippets-storage.ts b/lib/recent-snippets-storage.ts
new file mode 100644
index 0000000..22b9c76
--- /dev/null
+++ b/lib/recent-snippets-storage.ts
@@ -0,0 +1,95 @@
+/**
+ * Persist recently viewed snippets in localStorage so history survives reloads.
+ */
+
+export const RECENT_SNIPPETS_STORAGE_KEY = "codely_recent_snippets";
+export const MAX_RECENT_SNIPPETS = 12;
+
+export interface RecentSnippetEntry {
+ id: string;
+ title: string;
+ language: string;
+ description?: string;
+ viewedAt: string; // ISO 8601
+}
+
+function isBrowser(): boolean {
+ return typeof window !== "undefined" && typeof localStorage !== "undefined";
+}
+
+function isValidEntry(value: unknown): value is RecentSnippetEntry {
+ if (!value || typeof value !== "object") return false;
+ const entry = value as Record;
+ return (
+ typeof entry.id === "string" &&
+ entry.id.length > 0 &&
+ typeof entry.title === "string" &&
+ typeof entry.language === "string" &&
+ typeof entry.viewedAt === "string"
+ );
+}
+
+/** Read recent snippets from localStorage (most recent first). */
+export function getRecentSnippets(): RecentSnippetEntry[] {
+ if (!isBrowser()) return [];
+
+ try {
+ const raw = localStorage.getItem(RECENT_SNIPPETS_STORAGE_KEY);
+ if (!raw) return [];
+
+ const parsed = JSON.parse(raw);
+ if (!Array.isArray(parsed)) return [];
+
+ return parsed.filter(isValidEntry).slice(0, MAX_RECENT_SNIPPETS);
+ } catch {
+ return [];
+ }
+}
+
+/**
+ * Record (or bump) a snippet in recent history.
+ * Dedupes by id and keeps the newest entry at the front.
+ */
+export function recordRecentSnippet(
+ entry: Omit & { viewedAt?: string },
+): RecentSnippetEntry[] {
+ if (!isBrowser()) return [];
+
+ const nextEntry: RecentSnippetEntry = {
+ id: entry.id,
+ title: entry.title,
+ language: entry.language,
+ description: entry.description,
+ viewedAt: entry.viewedAt ?? new Date().toISOString(),
+ };
+
+ const existing = getRecentSnippets().filter((item) => item.id !== nextEntry.id);
+ const next = [nextEntry, ...existing].slice(0, MAX_RECENT_SNIPPETS);
+
+ try {
+ localStorage.setItem(RECENT_SNIPPETS_STORAGE_KEY, JSON.stringify(next));
+ } catch {
+ // Quota / private mode — ignore persistence failure
+ }
+
+ if (typeof window !== "undefined") {
+ window.dispatchEvent(new CustomEvent("codely:recent-snippets-changed"));
+ }
+
+ return next;
+}
+
+/** Clear the entire recent snippets history. */
+export function clearRecentSnippets(): void {
+ if (!isBrowser()) return;
+
+ try {
+ localStorage.removeItem(RECENT_SNIPPETS_STORAGE_KEY);
+ } catch {
+ // ignore
+ }
+
+ if (typeof window !== "undefined") {
+ window.dispatchEvent(new CustomEvent("codely:recent-snippets-changed"));
+ }
+}