// Center pane — doc kicker, title, Write/Preview toggle, markdown body,
// footer with word-count and autosave note.
function Editor({ trip, date, day, media, title, setTitle, body, setBody, mode, setMode, saveState, onMarkDirty }) {
const titleRef = React.useRef();
React.useEffect(() => {
const ta = titleRef.current;
if (!ta) return;
ta.style.height = "auto";
ta.style.height = ta.scrollHeight + "px";
}, [title]);
const wordCount = React.useMemo(
() => (body || "").trim().split(/\s+/).filter(Boolean).length,
[body]
);
return (
insertMediaRef(setBody, onMarkDirty, m)} />
{mode === "write" ? (
{ setBody(v); onMarkDirty(); }} />
) : (
)}
);
}
// BodyEditor — plain markdown source with drag-drop support for rail items.
function BodyEditor({ value, onChange, media }) {
const taRef = React.useRef();
const [dragOver, setDragOver] = React.useState(false);
const insertAt = (pos, text) => {
const next = value.slice(0, pos) + text + value.slice(pos);
onChange(next);
requestAnimationFrame(() => {
const ta = taRef.current;
if (!ta) return;
ta.focus();
const caret = pos + text.length;
ta.setSelectionRange(caret, caret);
});
};
const onDrop = (e) => {
e.preventDefault();
setDragOver(false);
const id = e.dataTransfer.getData("application/x-postcard-item") ||
e.dataTransfer.getData("text/x-postcard-item");
if (!id) return;
const m = (media || []).find((x) => x.id === id);
if (!m) return;
const snippet = mediaSnippet(m);
if (!snippet) return;
const ta = taRef.current;
let pos = value.length;
if (document.caretPositionFromPoint) {
const cp = document.caretPositionFromPoint(e.clientX, e.clientY);
if (cp && ta && typeof ta.selectionStart === "number") {
pos = cp.offset != null ? cp.offset : ta.selectionStart;
}
} else if (ta && typeof ta.selectionStart === "number") {
pos = ta.selectionStart;
}
const before = pos > 0 && value[pos - 1] !== "\n" ? "\n\n" : "";
const after = pos < value.length && value[pos] !== "\n" ? "\n\n" : "\n";
insertAt(pos, before + snippet + after);
};
return (
{ if (e.dataTransfer.types.includes("application/x-postcard-item")) { e.preventDefault(); setDragOver(true); } }}
onDragOver={(e) => { if (e.dataTransfer.types.includes("application/x-postcard-item")) { e.preventDefault(); e.dataTransfer.dropEffect = "copy"; } }}
onDragLeave={() => setDragOver(false)}
onDrop={onDrop}>
);
}
function InsertItemMenu({ media, onPick }) {
const [open, setOpen] = React.useState(false);
const ref = React.useRef();
React.useEffect(() => {
const close = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener("click", close);
return () => document.removeEventListener("click", close);
}, []);
return (
{open && (
{media.filter((m) => m.status !== "hidden").length === 0 && (
nothing to insert
)}
{media.filter((m) => m.status !== "hidden").map((m) => (
))}
)}
);
}
// ---------- editor helpers ----------
function getBodyTA() {
return document.querySelector(".body-source");
}
function insertAtCursor(setBody, onMarkDirty, text) {
const ta = getBodyTA();
if (!ta) return;
const { selectionStart: s, selectionEnd: e, value } = ta;
const next = value.slice(0, s) + text + value.slice(e);
setBody(next); onMarkDirty();
requestAnimationFrame(() => {
ta.focus();
const pos = s + text.length;
ta.setSelectionRange(pos, pos);
});
}
function wrapSelection(setBody, onMarkDirty, pre, post) {
const ta = getBodyTA();
if (!ta) return;
const { selectionStart: s, selectionEnd: e, value } = ta;
const next = value.slice(0, s) + pre + value.slice(s, e) + post + value.slice(e);
setBody(next); onMarkDirty();
requestAnimationFrame(() => {
ta.focus();
ta.setSelectionRange(s + pre.length, e + pre.length);
});
}
function mediaSnippet(m) {
if (!m) return "";
if (m.kind === "text") return `> ${m.text_body || ""}`;
const ref = `item:${m.id}`;
if (m.kind === "photo") return ``;
if (m.kind === "video") return `{{< video src="${ref}" >}}`;
if (m.kind === "voice" || m.kind === "audio") return `{{< audio src="${ref}" >}}`;
return "";
}
function insertMediaRef(setBody, onMarkDirty, m) {
const snippet = mediaSnippet(m);
if (!snippet) return;
insertAtCursor(setBody, onMarkDirty, "\n\n" + snippet + "\n\n");
}
function renderBodyPreview(body, media) {
if (!body || !body.trim()) return "";
const byId = {};
const byName = {};
(media || []).forEach((m) => {
if (m.id) byId[m.id] = m;
if (m.filename) byName[m.filename] = m;
});
const resolve = (ref) => {
const t = (ref || "").trim();
if (/^(https?:|data:|blob:)/i.test(t)) return t;
if (t.startsWith("item:")) {
const m = byId[t.slice(5)];
return m ? api.rawMediaURL(m.id) : t;
}
const name = t.split("/").pop();
const m = byName[name];
return m ? api.rawMediaURL(m.id) : t;
};
let src = body
.replace(/\{\{<\s*audio\s+src="([^"]+)"[^>]*>\}\}/g, (_, p) => ``)
.replace(/\{\{<\s*video\s+src="([^"]+)"[^>]*>\}\}/g, (_, p) => ``)
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, p) => `})`);
return window.marked ? marked.parse(src, { breaks: false, gfm: true }) : src;
}
function relTime(unix) {
const diff = Math.floor(Date.now() / 1000 - unix);
if (diff < 5) return "just now";
if (diff < 60) return diff + "s ago";
if (diff < 3600) return Math.floor(diff / 60) + "m ago";
if (diff < 86400) return Math.floor(diff / 3600) + "h ago";
return Math.floor(diff / 86400) + "d ago";
}
Object.assign(window, { Editor });