diff --git a/example-apps/dashnote/src/App.tsx b/example-apps/dashnote/src/App.tsx index 14726b6..68b27cd 100644 --- a/example-apps/dashnote/src/App.tsx +++ b/example-apps/dashnote/src/App.tsx @@ -1,9 +1,11 @@ import { useEffect, useMemo, useState } from "react"; import { Toaster } from "sonner"; +import { ActivityPanel } from "./components/ActivityPanel"; import { AppShell } from "./components/AppShell"; import { HowItWorks } from "./components/HowItWorks"; import { LoginModal } from "./components/LoginModal"; +import { NotesToolbar } from "./components/NotesToolbar"; import { NotesWorkspace } from "./components/NotesWorkspace"; import { OperationResultNotice } from "./components/OperationResultNotice"; import { SettingsPanel } from "./components/SettingsPanel"; @@ -33,6 +35,7 @@ function App() { const { status, sdk, enterReadOnly, viewAsRemembered } = session; const [tab, setTab] = useState("notes"); const [loginOpen, setLoginOpen] = useState(false); + const [activityOpen, setActivityOpen] = useState(false); const mobileFullBleed = tab === "notes"; @@ -41,6 +44,17 @@ function App() { else if (status === "browsing" && !sdk) void viewAsRemembered(); }, [enterReadOnly, viewAsRemembered, status, sdk]); + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "l") { + e.preventDefault(); + setActivityOpen((v) => !v); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + const header = useMemo(() => screenCopy[tab], [tab]); return ( @@ -56,30 +70,26 @@ function App() { onLoginOpen={() => setLoginOpen(true)} mobileFullBleed={mobileFullBleed} > -
-
- Dash Platform Notes Tutorial -
-

- {header.title} -

-

- {header.subtitle} -

-
+ {tab === "notes" ? ( + setActivityOpen(true)} + /> + ) : tab === "how-it-works" ? null : ( +
+

+ {header.title} +

+

+ {header.subtitle} +

+
+ )}
@@ -105,6 +115,10 @@ function App() { setLoginOpen(false)} /> + setActivityOpen(false)} + /> ); } diff --git a/example-apps/dashnote/src/components/ActivityPanel.tsx b/example-apps/dashnote/src/components/ActivityPanel.tsx new file mode 100644 index 0000000..c350178 --- /dev/null +++ b/example-apps/dashnote/src/components/ActivityPanel.tsx @@ -0,0 +1,116 @@ +import { useEffect } from "react"; + +import { formatRelativeTime } from "../lib/format"; +import { useSession } from "../session/useSession"; + +interface ActivityPanelProps { + open: boolean; + onClose: () => void; +} + +export function ActivityPanel({ open, onClose }: ActivityPanelProps) { + const { activityLog, clearActivityLog } = useSession(); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, onClose]); + + if (!open) return null; + + return ( +
+ +
+ ); +} diff --git a/example-apps/dashnote/src/components/AppShell.tsx b/example-apps/dashnote/src/components/AppShell.tsx index 618e454..5ad9111 100644 --- a/example-apps/dashnote/src/components/AppShell.tsx +++ b/example-apps/dashnote/src/components/AppShell.tsx @@ -21,12 +21,28 @@ interface AppShellProps { function LogoAvatar() { return (
+ > + +
); } diff --git a/example-apps/dashnote/src/components/HowItWorks.tsx b/example-apps/dashnote/src/components/HowItWorks.tsx index d519af9..231a13d 100644 --- a/example-apps/dashnote/src/components/HowItWorks.tsx +++ b/example-apps/dashnote/src/components/HowItWorks.tsx @@ -1,53 +1,314 @@ +const REPO = + "https://github.com/dashpay/platform-tutorials/blob/main/example-apps/dashnote/src/dash/"; +const APP_REPO = + "https://github.com/dashpay/platform-tutorials/blob/main/example-apps/dashnote/"; + +type PipelineGlyph = "ui" | "helper" | "sdk" | "platform"; + +const PIPELINE: Array<{ + label: string; + sub: string; + glyph: PipelineGlyph; + href?: string; +}> = [ + { + label: "UI", + sub: "NoteEditor.tsx", + glyph: "ui", + href: "https://github.com/dashpay/platform-tutorials/blob/main/example-apps/dashnote/src/components/NoteEditor.tsx", + }, + { + label: "Helper", + sub: "src/dash/*.ts", + glyph: "helper", + href: "https://github.com/dashpay/platform-tutorials/tree/main/example-apps/dashnote/src/dash", + }, + { + label: "Evo SDK", + sub: "@dashevo/evo-sdk", + glyph: "sdk", + href: "https://www.npmjs.com/package/@dashevo/evo-sdk", + }, + { + label: "Platform", + sub: "testnet", + glyph: "platform", + href: "https://testnet.platform-explorer.com/", + }, +]; + +function PipelineGlyphIcon({ kind }: { kind: PipelineGlyph }) { + const common = { + width: 14, + height: 14, + viewBox: "0 0 24 24", + fill: "none" as const, + stroke: "currentColor", + strokeWidth: 2, + strokeLinecap: "round" as const, + strokeLinejoin: "round" as const, + "aria-hidden": true, + }; + switch (kind) { + case "ui": + return ( + + + + + ); + case "helper": + return ( + + + + ); + case "sdk": + return ( + + + + + ); + case "platform": + return ( + + + + ); + } +} + +const OPS = [ + { op: "Create a note", file: "createNote.ts", sdk: "documents.create" }, + { + op: "Update a note", + file: "updateNote.ts", + sdk: "documents.get → replace", + }, + { op: "Delete a note", file: "deleteNote.ts", sdk: "documents.delete" }, + { op: "List my notes", file: "queries.ts", sdk: "documents.query" }, + { op: "Register a contract", file: "contract.ts", sdk: "contracts.publish" }, +]; + +const READING_ORDER = [ + { file: "src/dash/contract.ts", caption: "Schema + indices." }, + { file: "src/dash/createNote.ts", caption: "Simplest mutation." }, + { + file: "src/dash/updateNote.ts", + caption: "Fetch → bump revision → replace.", + }, + { + file: "src/components/NotesWorkspace.tsx", + caption: "Cache + revalidation.", + }, +]; + +const SAMPLE_CODE = `const document = new Document({ + properties: { title, message }, + documentTypeName: "note", + dataContractId, + ownerId, +}); + +await sdk.documents.create({ + document, identityKey, signer, +});`; + +const SECTION_LABEL = + "text-[10px] font-semibold uppercase tracking-[0.14em] text-ink-3"; +const SECTION_INTRO = "mt-2 text-[12.5px] leading-5 text-ink-2"; + export function HowItWorks() { return ( -
-
-
- Product model +
+ {/* 0. Page header */} +
+
+ How Dashnote works
-

- Editable notes on a tutorial contract -

-
-

- Dashnote keeps the tutorial document type name as note, - then adds an optional title plus a required{" "} - message. -

-

- Each save uses document replacement, so the UI can show the current - revision number and the Platform-provided $createdAt{" "} - and $updatedAt timestamps. -

-

- v1 does not reconstruct earlier note bodies. History here means the - current document state plus metadata about when it was created and - last updated. -

-
-
+

+ A walkthrough of every SDK call this app makes +

+

+ Dashnote stores each entry as a single note document with + an optional title and a required message. + Every operation follows the same four-step path: a React component + calls a one-file helper in src/dash/, the helper calls + the Evo SDK, and the SDK writes to a Platform node on testnet. The + files in src/dash/ are the lesson — everything else is + plumbing. +

+ -
-
- Code map -
-
-

- src/dash/contract.ts defines the note schema and - registration flow. -

-

- src/dash/createNote.ts,{" "} - src/dash/updateNote.ts, and{" "} - src/dash/deleteNote.ts each wrap one Platform mutation. -

-

- src/dash/queries.ts handles the note list and note - detail reads, while src/components/NotesWorkspace.tsx{" "} - owns the notebook UI state. -

+ {/* 1. Data flow */} +
+
Data flow
+
+ {PIPELINE.map((step, i, arr) => { + const cardClass = + "block rounded-xl border border-line bg-bg p-4 text-accent"; + const cardBody = ( + <> + +
+ {step.label} +
+
+ {step.sub} +
+ + ); + return ( +
+ {step.href ? ( + + {cardBody} + + ) : ( +
{cardBody}
+ )} + {i < arr.length - 1 && ( + + )} +
+ ); + })}
-
-
+ + + {/* 2. Operations table + inline code peek */} +
+
+
+
Platform operations
+

+ Each row links to a one-file helper in src/dash/. + Read these as the canonical example of each SDK call. Every update + is a full document replacement with an incremented{" "} + $revision, so the UI can show that alongside the + Platform-provided $createdAt and{" "} + $updatedAt timestamps. +

+
+ {OPS.map((o) => ( + + {o.op} + {o.sdk} + + ))} +
+ +
+
+
+ + src/dash/createNote.ts + + + View full file → + +
+

+ The shortest mutation in the app — building a{" "} + Document and handing it to{" "} + sdk.documents.create. +

+
+
+            {SAMPLE_CODE}
+          
+
+
+ + {/* 3. Suggested reading order */} +
+
Recommended source files
+

+ Read these in order to build up the mental model: schema first, then + the simplest write, then the read-bump-replace pattern, and finally + the UI layer that owns caching and revalidation. +

+
    + {READING_ORDER.map((item, i) => ( +
  1. + + + + {item.file} — {item.caption} + + +
  2. + ))} +
+
+ + {/* 4. Continue to tutorial */} + + + + + + + + Continue to the Dashnote tutorial + + on docs.dash.org + + + + +
); } diff --git a/example-apps/dashnote/src/components/IdentityCard.tsx b/example-apps/dashnote/src/components/IdentityCard.tsx index 8833c9e..e17fc7a 100644 --- a/example-apps/dashnote/src/components/IdentityCard.tsx +++ b/example-apps/dashnote/src/components/IdentityCard.tsx @@ -37,7 +37,6 @@ export function IdentityCard({ const isBrowsing = status === "browsing"; const isReadonly = status === "readonly"; const isConnected = isReadonly || isAuthed || isBrowsing; - const hasIdentity = isAuthed || isBrowsing; const [menuOpen, setMenuOpen] = useState(false); const containerRef = useRef(null); @@ -71,16 +70,7 @@ export function IdentityCard({ > Sign in -
- +
{status === "connecting" ? "Connecting..." @@ -93,27 +83,53 @@ export function IdentityCard({ ); } - // Read-only mode has nothing to put in a menu (no identity → no Settings - // target, no Login/Switch entry, no Log out), so the card goes straight - // to the login modal on click — matching the pre-menu behavior. - if (isReadonly) { + // Logged out — either without (readonly) or with (browsing) a remembered + // identity hint. Both variants click straight to the login modal; the + // browsing variant additionally decorates the card with the cached + // DPNS name + avatar so returning users see who they were. + if (isReadonly || isBrowsing) { return ( ); @@ -128,45 +144,40 @@ export function IdentityCard({ aria-expanded={menuOpen} className="group w-full border-t border-line pt-3.5 text-left" > - {hasIdentity && ( - <> -
- - {isAuthed ? "Signed in" : "Read-only"} - - - Menu - +
+ + Signed in + + + Menu + +
+
+
+
+
+ {dpnsName + ? `@${dpnsName}` + : identityId + ? truncateId(identityId, 6) + : "Identity"}
-
-
-
-
- {dpnsName - ? `@${dpnsName}` - : identityId - ? truncateId(identityId, 6) - : "Identity"} -
-
- {dpnsName && identityId - ? truncateId(identityId, 6) - : contractId - ? `contract ${truncateId(contractId, 6)}` - : "No contract"} -
-
+
+ {dpnsName && identityId + ? truncateId(identityId, 6) + : contractId + ? `contract ${truncateId(contractId, 6)}` + : "No contract"}
- - )} +
+
-
- +
- {isAuthed ? "Authenticated" : "Browsing (read-only)"} + Full access
@@ -196,21 +207,19 @@ export function IdentityCard({ }} className="rounded-md px-2 py-1.5 text-left text-[12px] font-medium text-ink-2 transition hover:bg-surface-2 hover:text-ink" > - {isAuthed ? "Switch identity" : "Sign in"} + Switch identity + + - {isAuthed && ( - - )}
)}
diff --git a/example-apps/dashnote/src/components/LoginModal.tsx b/example-apps/dashnote/src/components/LoginModal.tsx index 2519c00..094b88f 100644 --- a/example-apps/dashnote/src/components/LoginModal.tsx +++ b/example-apps/dashnote/src/components/LoginModal.tsx @@ -65,7 +65,38 @@ export function LoginModal({ open, onClose }: LoginModalProps) { } return ( - + + + + +
+
+ Sign in to Dashnote +
+
+ Connects to testnet +
+
+
+ } + >
{showRememberedPanel && session.rememberedIdentityId && (
)} + {isReadOnly && ( +
+ {isDesktop && hasSelection && contractReady && ( +
+ {note ? ( +
+ + $createdAt + {formatTimestamp(note.createdAt)} + + + $updatedAt + {formatTimestamp(note.updatedAt)} + +
+ ) : ( +
+ Platform metadata appears after the first save. +
+ )} +
+ + {messageBytes.toLocaleString()} /{" "} + {FIELD_BYTE_LIMIT.toLocaleString()} B + +
+ +
+
+
+ )} + setJsonOpen(false)} + /> ); } diff --git a/example-apps/dashnote/src/components/NoteJsonDrawer.tsx b/example-apps/dashnote/src/components/NoteJsonDrawer.tsx new file mode 100644 index 0000000..18bc152 --- /dev/null +++ b/example-apps/dashnote/src/components/NoteJsonDrawer.tsx @@ -0,0 +1,76 @@ +import { useEffect } from "react"; + +import type { NoteRecord } from "../dash/queries"; + +interface NoteJsonDrawerProps { + open: boolean; + note: NoteRecord | null; + contractId: string | null; + onClose: () => void; +} + +export function NoteJsonDrawer({ + open, + note, + contractId, + onClose, +}: NoteJsonDrawerProps) { + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, onClose]); + + if (!open || !note) return null; + + const payload = { + $id: note.id, + $type: "note", + $ownerId: note.ownerId, + $dataContractId: contractId, + $revision: note.revision, + $createdAt: note.createdAt, + $updatedAt: note.updatedAt, + title: note.title, + message: note.message, + }; + + return ( +
+ +
+ ); +} diff --git a/example-apps/dashnote/src/components/NoteList.tsx b/example-apps/dashnote/src/components/NoteList.tsx index f2db34e..981076d 100644 --- a/example-apps/dashnote/src/components/NoteList.tsx +++ b/example-apps/dashnote/src/components/NoteList.tsx @@ -1,8 +1,7 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import type { NoteRecord } from "../dash/queries"; import { - formatCompactTimestamp, formatRelativeTime, noteDisplayTitle, notePreview, @@ -16,6 +15,7 @@ interface NoteListProps { onSelect: (noteId: string) => void; onNew: () => void; canCreate: boolean; + newButtonLabel?: string; } export function NoteList({ @@ -26,8 +26,26 @@ export function NoteList({ onSelect, onNew, canCreate, + newButtonLabel = "New note", }: NoteListProps) { const [search, setSearch] = useState(""); + const searchRef = useRef(null); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key !== "/") return; + const target = e.target; + const editable = + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + (target instanceof HTMLElement && target.isContentEditable); + if (editable) return; + e.preventDefault(); + searchRef.current?.focus(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); const filteredNotes = useMemo(() => { const q = search.trim().toLowerCase(); @@ -90,7 +108,7 @@ export function NoteList({ onClick={onNew} className="rounded-full bg-accent px-3 py-1.5 text-[12px] font-semibold text-bg transition hover:bg-accent-dim max-md:hidden" > - New note + {newButtonLabel} )}
@@ -114,12 +132,19 @@ export function NoteList({ setSearch(event.target.value)} placeholder="Search" className="w-full rounded-full border border-line bg-bg px-9 py-2 text-[13px] text-ink outline-none transition focus:border-accent-dim" /> +
@@ -165,28 +190,33 @@ export function NoteList({ key={note.id} type="button" onClick={() => onSelect(note.id)} - className={`block w-full rounded-[18px] border px-3 py-3 text-left transition ${ + className={`relative block w-full overflow-hidden rounded-lg px-3 py-3 text-left transition ${ active - ? "border-accent bg-surface-2 shadow-[0_16px_35px_-28px_rgba(0,0,0,0.5)]" - : "border-transparent bg-transparent hover:border-line hover:bg-surface-2" + ? "bg-surface-2" + : "bg-transparent hover:bg-surface-2" }`} > -
-
-
+ {active && ( +