diff --git a/frontend/src/components/TextPreview.jsx b/frontend/src/components/TextPreview.jsx
new file mode 100644
index 0000000..6befaab
--- /dev/null
+++ b/frontend/src/components/TextPreview.jsx
@@ -0,0 +1,46 @@
+import { useEffect, useState } from 'react'
+
+/**
+ * Renders the primary_media artifact of a text/document entry as plain text.
+ *
+ * v1 intentionally does NOT render Markdown β we don't want a Markdown parser
+ * dep just to unblock the "no preview available" fallback, and monospace text
+ * with visible fences reads fine for the note-length content this feature
+ * captures. Bump to a real renderer if/when Markdown-authored entries grow.
+ */
+export default function TextPreview({ src, mime, title }) {
+ const [text, setText] = useState(null)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ let cancelled = false
+ setText(null)
+ setError(null)
+ fetch(src, { credentials: 'same-origin' })
+ .then(r => {
+ if (!r.ok) throw new Error(`HTTP ${r.status}`)
+ return r.text()
+ })
+ .then(t => { if (!cancelled) setText(t) })
+ .catch(e => { if (!cancelled) setError(e.message || String(e)) })
+ return () => { cancelled = true }
+ }, [src])
+
+ if (error) {
+ return (
+
+ Failed to load text: {error}
+
+ )
+ }
+ if (text === null) {
+ return
Loadingβ¦
+ }
+ return (
+
+ {title &&
{title}
}
+
{text}
+ {mime &&
{mime}
}
+
+ )
+}
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 150c50f..1503e8e 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -3285,3 +3285,51 @@ body.has-audio-bar { padding-bottom: 56px; }
border: 1px solid var(--line);
border-radius: 4px;
}
+
+/* ββ Text / Markdown preview ββββββββββββββββββββββββββββββββββββββββββββββ */
+.text-preview {
+ padding: 32px 40px 48px;
+ max-width: 780px;
+ margin: 0 auto;
+ font-family: var(--sans);
+ color: var(--ink);
+ overflow: auto;
+ height: 100%;
+ box-sizing: border-box;
+}
+.text-preview--loading,
+.text-preview--error {
+ padding: 24px;
+ color: var(--muted);
+ font-size: 13px;
+}
+.text-preview--error { color: var(--accent); }
+.text-preview__title {
+ font-family: var(--serif, var(--sans));
+ font-size: 22px;
+ font-weight: 600;
+ margin: 0 0 20px;
+ line-height: 1.25;
+ color: var(--ink);
+}
+.text-preview__body {
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+ font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
+ font-size: 13.5px;
+ line-height: 1.65;
+ color: var(--ink);
+ background: var(--paper);
+ border: 1px solid var(--line);
+ border-radius: var(--r2);
+ padding: 16px 18px;
+ margin: 0;
+}
+.text-preview__mime {
+ margin-top: 12px;
+ font-size: 11px;
+ color: var(--muted);
+ letter-spacing: .04em;
+ text-align: right;
+}