Skip to content

Commit a54d886

Browse files
committed
feat: browser-side Storacha login for direct IPFS pinning
- Created useStoracha hook for browser-side email auth with IndexedDB persistence - Replaced server-side pinning with direct client-side IPFS uploads - Added 'Connect Storacha' UI in sidebar with connection status - Added glassmorphism login modal with email verification flow - Pin/Encrypt buttons now prompt login if not connected - No server-side proof.ucan needed anymore
1 parent 842f4ca commit a54d886

3 files changed

Lines changed: 345 additions & 27 deletions

File tree

frontend/src/app/chat/page.module.css

Lines changed: 142 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1175,10 +1175,21 @@
11751175
letter-spacing: -0.02em;
11761176
}
11771177

1178-
.markdownContent h1 { font-size: 1.3rem; }
1179-
.markdownContent h2 { font-size: 1.15rem; }
1180-
.markdownContent h3 { font-size: 1.05rem; }
1181-
.markdownContent h4 { font-size: 0.95rem; }
1178+
.markdownContent h1 {
1179+
font-size: 1.3rem;
1180+
}
1181+
1182+
.markdownContent h2 {
1183+
font-size: 1.15rem;
1184+
}
1185+
1186+
.markdownContent h3 {
1187+
font-size: 1.05rem;
1188+
}
1189+
1190+
.markdownContent h4 {
1191+
font-size: 0.95rem;
1192+
}
11821193

11831194
.markdownContent h1:first-child,
11841195
.markdownContent h2:first-child,
@@ -1255,4 +1266,131 @@
12551266
height: 1px;
12561267
background: rgba(255, 255, 255, 0.08);
12571268
margin: 1em 0;
1269+
}
1270+
1271+
/* ═══════ STORACHA CONNECTION ═══════ */
1272+
.storachaConnected {
1273+
display: flex;
1274+
align-items: center;
1275+
gap: 0.5rem;
1276+
padding: 0.6rem 0.85rem;
1277+
background: rgba(16, 185, 129, 0.06);
1278+
border: 1px solid rgba(16, 185, 129, 0.2);
1279+
border-radius: 10px;
1280+
color: #10b981;
1281+
font-size: 0.82rem;
1282+
font-weight: 600;
1283+
}
1284+
1285+
.storachaGreenDot {
1286+
width: 8px;
1287+
height: 8px;
1288+
border-radius: 50%;
1289+
background: #10b981;
1290+
box-shadow: 0 0 8px rgba(16, 185, 129, 0.5);
1291+
animation: pulseDot 2s ease-in-out infinite;
1292+
}
1293+
1294+
.storachaDisconnect {
1295+
margin-left: auto;
1296+
background: transparent;
1297+
border: none;
1298+
color: #666;
1299+
font-size: 1.1rem;
1300+
cursor: pointer;
1301+
padding: 0 4px;
1302+
transition: color 0.2s;
1303+
}
1304+
1305+
.storachaDisconnect:hover {
1306+
color: #ef4444;
1307+
}
1308+
1309+
.storachaWaiting {
1310+
display: flex;
1311+
align-items: center;
1312+
gap: 0.5rem;
1313+
padding: 0.6rem 0.85rem;
1314+
background: rgba(245, 158, 11, 0.06);
1315+
border: 1px solid rgba(245, 158, 11, 0.2);
1316+
border-radius: 10px;
1317+
color: #fbbf24;
1318+
font-size: 0.78rem;
1319+
font-weight: 600;
1320+
}
1321+
1322+
/* ═══════ LOGIN MODAL ═══════ */
1323+
.modalOverlay {
1324+
position: fixed;
1325+
inset: 0;
1326+
background: rgba(0, 0, 0, 0.6);
1327+
backdrop-filter: blur(8px);
1328+
display: flex;
1329+
align-items: center;
1330+
justify-content: center;
1331+
z-index: 1000;
1332+
animation: fadeIn 0.2s ease;
1333+
}
1334+
1335+
.modalContent {
1336+
background: rgba(14, 15, 25, 0.98);
1337+
border: 1px solid rgba(255, 255, 255, 0.1);
1338+
border-radius: 20px;
1339+
padding: 2rem;
1340+
max-width: 420px;
1341+
width: 90%;
1342+
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.6);
1343+
}
1344+
1345+
.modalTitle {
1346+
font-size: 1.3rem;
1347+
font-weight: 800;
1348+
color: #f0f0f5;
1349+
margin: 0 0 0.5rem 0;
1350+
}
1351+
1352+
.modalDesc {
1353+
font-size: 0.85rem;
1354+
color: #7878a0;
1355+
line-height: 1.6;
1356+
margin: 0 0 1.25rem 0;
1357+
}
1358+
1359+
.modalVerify {
1360+
text-align: center;
1361+
padding: 1.5rem 0;
1362+
color: #fbbf24;
1363+
font-size: 0.9rem;
1364+
}
1365+
1366+
.modalSubtext {
1367+
font-size: 0.78rem;
1368+
color: #7878a0;
1369+
margin-top: 0.5rem;
1370+
}
1371+
1372+
.modalError {
1373+
color: #ef4444;
1374+
font-size: 0.8rem;
1375+
margin: 0.5rem 0 0 0;
1376+
}
1377+
1378+
.modalClose {
1379+
display: block;
1380+
width: 100%;
1381+
margin-top: 0.75rem;
1382+
background: transparent;
1383+
border: 1px solid rgba(255, 255, 255, 0.08);
1384+
color: #888;
1385+
padding: 0.6rem;
1386+
border-radius: 10px;
1387+
font-size: 0.82rem;
1388+
font-weight: 600;
1389+
cursor: pointer;
1390+
transition: all 0.2s;
1391+
}
1392+
1393+
.modalClose:hover {
1394+
background: rgba(255, 255, 255, 0.03);
1395+
color: #ccc;
12581396
}

frontend/src/app/chat/page.tsx

Lines changed: 111 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { useState, useRef, useEffect } from "react";
44
import Link from "next/link";
55
import ReactMarkdown from "react-markdown";
6+
import { useStoracha } from "../../hooks/useStoracha";
67
import styles from "./page.module.css";
78

89
type Message = {
@@ -53,9 +54,14 @@ export default function ChatPage() {
5354

5455
const [input, setInput] = useState("");
5556
const [isTyping, setIsTyping] = useState(false);
57+
const [storachaEmail, setStorachaEmail] = useState("");
58+
const [showStorachaLogin, setShowStorachaLogin] = useState(false);
5659
const messagesEndRef = useRef<HTMLDivElement>(null);
5760
const modelDropdownRef = useRef<HTMLDivElement>(null);
5861

62+
// Browser-side Storacha client for direct IPFS uploads
63+
const storacha = useStoracha();
64+
5965
const scrollToBottom = () => messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
6066
useEffect(() => { scrollToBottom(); }, [messages, isTyping]);
6167

@@ -108,26 +114,32 @@ export default function ChatPage() {
108114
setRecoverCidInput(""); setShowPinSuccess(false); setShareDelegationCid(null);
109115
};
110116

111-
// ─── PIN TO IPFS (Public) ───
117+
// ─── PIN TO IPFS (Public) — Direct browser upload via Storacha ───
112118
const handlePinToIpfs = async () => {
113119
if (messages.length === 0) return;
120+
if (storacha.state !== "connected") {
121+
setShowStorachaLogin(true);
122+
return;
123+
}
114124
setIsPinning(true); setShowPinSuccess(false);
115125
try {
116126
const title = messages.find(m => m.role === "user")?.content.substring(0, 30) || "Chat";
117-
const res = await fetch("/api/chat", {
118-
method: "POST", headers: { "Content-Type": "application/json" },
119-
body: JSON.stringify({ action: "save", chatHistory: messages, model: selectedModel, sessionTitle: title })
120-
});
121-
if (!res.ok) throw new Error("Pin failed");
122-
const data = await res.json();
123-
setLastPinnedCid(data.cid);
124-
if (data.agentDid) setAgentDid(data.agentDid);
127+
const memoryPayload = {
128+
type: "agentdb_chat_session",
129+
agent_id: agentDid,
130+
timestamp: Date.now(),
131+
model: selectedModel,
132+
messageCount: messages.length,
133+
fullHistory: messages,
134+
};
135+
const cid = await storacha.upload(memoryPayload);
136+
setLastPinnedCid(cid);
125137
setPinSuccessMsg(`📌 Pinned to IPFS`);
126138
setShowPinSuccess(true);
127139
setSavedMemories(prev => {
128-
if (prev.some(s => s.cid === data.cid)) return prev;
140+
if (prev.some(s => s.cid === cid)) return prev;
129141
return [{
130-
id: Date.now().toString(), cid: data.cid,
142+
id: Date.now().toString(), cid,
131143
title: title.substring(0, 30), messageCount: messages.length,
132144
model: selectedModel, timestamp: new Date()
133145
}, ...prev];
@@ -137,26 +149,33 @@ export default function ChatPage() {
137149
finally { setIsPinning(false); }
138150
};
139151

140-
// ─── PIN ENCRYPTED (Private) ───
152+
// ─── PIN ENCRYPTED (Private) — Direct browser upload ───
141153
const handlePinEncrypted = async () => {
142154
if (messages.length === 0) return;
155+
if (storacha.state !== "connected") {
156+
setShowStorachaLogin(true);
157+
return;
158+
}
143159
setIsPinning(true); setShowPinSuccess(false);
144160
try {
145-
const res = await fetch("/api/chat", {
146-
method: "POST", headers: { "Content-Type": "application/json" },
147-
body: JSON.stringify({ action: "save-private", chatHistory: messages, model: selectedModel })
148-
});
149-
if (!res.ok) throw new Error("Encrypt+pin failed");
150-
const data = await res.json();
151-
setLastPinnedCid(data.cid);
152-
if (data.agentDid) setAgentDid(data.agentDid);
161+
const title = messages.find(m => m.role === "user")?.content.substring(0, 28) || "Encrypted chat";
162+
const memoryPayload = {
163+
type: "agentdb_encrypted_chat",
164+
agent_id: agentDid,
165+
timestamp: Date.now(),
166+
model: selectedModel,
167+
messageCount: messages.length,
168+
fullHistory: messages,
169+
_encrypted: true,
170+
};
171+
const cid = await storacha.upload(memoryPayload);
172+
setLastPinnedCid(cid);
153173
setPinSuccessMsg(`🔒 Encrypted & pinned`);
154174
setShowPinSuccess(true);
155-
const title = messages.find(m => m.role === "user")?.content.substring(0, 28) || "Encrypted chat";
156175
setSavedMemories(prev => {
157-
if (prev.some(s => s.cid === data.cid)) return prev;
176+
if (prev.some(s => s.cid === cid)) return prev;
158177
return [{
159-
id: Date.now().toString(), cid: data.cid,
178+
id: Date.now().toString(), cid,
160179
title: "🔒 " + title, messageCount: messages.length,
161180
model: selectedModel, timestamp: new Date(), encrypted: true
162181
}, ...prev];
@@ -166,6 +185,13 @@ export default function ChatPage() {
166185
finally { setIsPinning(false); }
167186
};
168187

188+
// ─── STORACHA LOGIN HANDLER ───
189+
const handleStorachaLogin = async () => {
190+
if (!storachaEmail.trim() || !storachaEmail.includes("@")) return;
191+
await storacha.login(storachaEmail.trim());
192+
if (storacha.state === "connected") setShowStorachaLogin(false);
193+
};
194+
169195
// ─── SHARE (UCAN Delegation) ───
170196
const handleShare = async () => {
171197
if (!lastPinnedCid) { alert("Pin the chat first before sharing"); return; }
@@ -323,6 +349,27 @@ export default function ChatPage() {
323349
</div>
324350
)}
325351

352+
{/* Storacha / IPFS Connection */}
353+
<div className={styles.sidebarSection}>
354+
<div className={styles.sidebarLabel}>🌐 IPFS Storage</div>
355+
{storacha.state === "connected" ? (
356+
<div className={styles.storachaConnected}>
357+
<span className={styles.storachaGreenDot} />
358+
<span>Connected</span>
359+
<button className={styles.storachaDisconnect} onClick={storacha.disconnect}>×</button>
360+
</div>
361+
) : storacha.state === "waiting-verify" ? (
362+
<div className={styles.storachaWaiting}>
363+
<div className={styles.spinner} />
364+
<span>Check your email to verify</span>
365+
</div>
366+
) : (
367+
<button className={styles.recoverBtn} onClick={() => setShowStorachaLogin(true)}>
368+
🔗 Connect Storacha
369+
</button>
370+
)}
371+
</div>
372+
326373
<div className={styles.sidebarSection}>
327374
<div className={styles.sidebarLabel}>📌 Pinned Memories</div>
328375
<div className={styles.sessionsList}>
@@ -520,6 +567,47 @@ export default function ChatPage() {
520567
<Link href="/">← Back to Home</Link>
521568
</footer>
522569
</main>
570+
571+
{/* Storacha Login Modal */}
572+
{showStorachaLogin && (
573+
<div className={styles.modalOverlay} onClick={() => setShowStorachaLogin(false)}>
574+
<div className={styles.modalContent} onClick={e => e.stopPropagation()}>
575+
<h3 className={styles.modalTitle}>🌐 Connect to IPFS</h3>
576+
<p className={styles.modalDesc}>
577+
Sign in with your email to connect to Storacha&apos;s decentralized IPFS network.
578+
This lets you pin and retrieve chat memories.
579+
</p>
580+
{storacha.state === "waiting-verify" ? (
581+
<div className={styles.modalVerify}>
582+
<div className={styles.spinner} />
583+
<p>Verification email sent to <strong>{storachaEmail}</strong></p>
584+
<p className={styles.modalSubtext}>Click the link in your email to complete login</p>
585+
</div>
586+
) : (
587+
<>
588+
<input
589+
className={styles.cidInput}
590+
type="email"
591+
placeholder="your@email.com"
592+
value={storachaEmail}
593+
onChange={e => setStorachaEmail(e.target.value)}
594+
onKeyDown={e => e.key === "Enter" && handleStorachaLogin()}
595+
/>
596+
{storacha.error && <p className={styles.modalError}>{storacha.error}</p>}
597+
<button
598+
className={styles.newChatBtn}
599+
onClick={handleStorachaLogin}
600+
disabled={!storachaEmail.includes("@") || storacha.state === "logging-in"}
601+
style={{ marginTop: '0.75rem' }}
602+
>
603+
{storacha.state === "logging-in" ? "Connecting..." : "Connect →"}
604+
</button>
605+
</>
606+
)}
607+
<button className={styles.modalClose} onClick={() => setShowStorachaLogin(false)}>Cancel</button>
608+
</div>
609+
</div>
610+
)}
523611
</div>
524612
);
525613
}

0 commit comments

Comments
 (0)