PostEditor.jsx 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653
  1. import React, { useState, useEffect, useMemo } from "react";
  2. import { useNavigate, useParams, Link } from "react-router-dom";
  3. import MDEditor, { commands } from "@uiw/react-md-editor";
  4. import "@uiw/react-md-editor/markdown-editor.css";
  5. import { API_BASE } from "../config";
  6. import { createMarkdownParser } from "../utils/markdownParser";
  7. import DOMPurify from "dompurify";
  8. import MediaGalleryModal from "./MediaGalleryModal";
  9. // Initialize the shared markdown parser
  10. const md = createMarkdownParser();
  11. function PostEditor() {
  12. const navigate = useNavigate();
  13. const { slug } = useParams();
  14. const isEditing = !!slug;
  15. const [activeTab, setActiveTab] = useState("write"); // "write" | "preview"
  16. const [galleryOpen, setGalleryOpen] = useState(false);
  17. const [formData, setFormData] = useState({
  18. title: "",
  19. description: "",
  20. content: "",
  21. tags: "",
  22. hidden: false,
  23. });
  24. const [cursorLine, setCursorLine] = useState(0);
  25. const [loading, setLoading] = useState(isEditing);
  26. const [saving, setSaving] = useState(false);
  27. const [error, setError] = useState(null);
  28. // Calculate temporary slug for new posts
  29. const getSlugForUpload = () => {
  30. if (isEditing) return slug;
  31. if (formData.title) {
  32. const date = new Date();
  33. const dateStr = date.toISOString().slice(0, 10).replace(/-/g, "");
  34. const slugText = formData.title
  35. .toLowerCase()
  36. .replace(/[^a-z0-9]+/g, "-")
  37. .replace(/^-|-$/g, "")
  38. .slice(0, 30);
  39. return `${dateStr}-${slugText}`;
  40. }
  41. return "";
  42. };
  43. useEffect(() => {
  44. if (isEditing) {
  45. fetchPost();
  46. }
  47. }, [slug, isEditing]);
  48. const fetchPost = async () => {
  49. try {
  50. setLoading(true);
  51. const response = await fetch(`${API_BASE}/posts/${slug}`, {
  52. credentials: "include",
  53. });
  54. if (!response.ok) throw new Error("Failed to fetch post");
  55. const post = await response.json();
  56. let content = post.content;
  57. content = content.replace(/^title:.*$/m, "");
  58. content = content.replace(/^desc:.*$/m, "");
  59. content = content.replace(/^tags:.*$/m, "");
  60. content = content.replace(/^hidden:.*$/m, ""); // Remove hidden if present in content body (should be only in header but just in case)
  61. content = content.replace(/^\n+/, "");
  62. setFormData({
  63. title: post.title,
  64. description: post.description,
  65. content: content.trim(),
  66. tags: post.tags ? post.tags.join(", ") : "",
  67. hidden: !!post.hidden,
  68. });
  69. } catch (err) {
  70. setError(err.message);
  71. } finally {
  72. setLoading(false);
  73. }
  74. };
  75. const handleInputChange = (field, value, event) => {
  76. setFormData((prev) => ({ ...prev, [field]: value }));
  77. // Track cursor line for sync
  78. if (field === "content" && event) {
  79. // MDEditor's onChange passes the value, but we need the event or access to the textarea
  80. // However, uiw/react-md-editor's onChange only gives value.
  81. // We need to use onSelect or capture it from the underlying textarea.
  82. }
  83. };
  84. // Improved cursor tracker
  85. const insertTextAtCursor = (text) => {
  86. setFormData(prev => ({
  87. ...prev,
  88. content: prev.content + "\n" + text
  89. }));
  90. };
  91. // Improved cursor tracker
  92. const handleCursorSelect = (e) => {
  93. // e.target is the textarea
  94. if (!e || !e.target) return;
  95. const { selectionStart, value } = e.target;
  96. const lines = value.substr(0, selectionStart).split("\n");
  97. // Markdown-it lines are 0-based. lines.length is 1-based count.
  98. // So line index is lines.length - 1.
  99. setCursorLine(lines.length - 1);
  100. };
  101. const handleEditorChange = (value, event, state) => {
  102. handleInputChange("content", value || "");
  103. // We rely on handleCursorSelect via onSelect/onClick/onKeyUp for cursor tracking now.
  104. };
  105. const handlePaste = async (event) => {
  106. const items = event.clipboardData.items;
  107. for (const item of items) {
  108. if (item.type.indexOf("image") === 0) {
  109. event.preventDefault();
  110. const file = item.getAsFile();
  111. if (!file) continue;
  112. const tempSlug = getSlugForUpload() || "uploads";
  113. const placeholder = `![Uploading ${file.name}...]()...`;
  114. // Insert placeholder
  115. // We use api.replaceSelection if we had access to 'api' from MDEditor,
  116. // but here we are in a raw paste handler on the textarea.
  117. // We'll append for now or try to use a more sophisticated insertion if possible,
  118. // but sticking to insertTextAtCursor is safer for state consistency.
  119. // BETTER: MDEditor's `paste` command? No, native onPaste is best.
  120. // Native paste on textarea doesn't give us cursor position easily in React state flow
  121. // without ref manipulation.
  122. // Let's use the standard "append" or try to insert at end.
  123. // Actually, let's use the replaceSelection approach if we can get a ref to the editor instance,
  124. // but we don't have it easily.
  125. // We will append to content with a newline for simplicity and reliability.
  126. setFormData(prev => ({ ...prev, content: prev.content + "\n" + placeholder }));
  127. try {
  128. const formData = new FormData();
  129. formData.append("file", file);
  130. const response = await fetch(`${API_BASE}/upload?slug=${tempSlug}`, {
  131. method: "POST",
  132. body: formData,
  133. credentials: "include",
  134. });
  135. if (!response.ok) throw new Error("Upload failed");
  136. const data = await response.json();
  137. // Replace placeholder with actual image markdown
  138. setFormData(prev => ({
  139. ...prev,
  140. content: prev.content.replace(placeholder, `![${data.originalName}](${API_BASE}${data.url})`)
  141. }));
  142. } catch (error) {
  143. console.error("Paste upload error:", error);
  144. setFormData(prev => ({
  145. ...prev,
  146. content: prev.content.replace(placeholder, `[Upload Failed: ${error.message}]`)
  147. }));
  148. }
  149. }
  150. }
  151. };
  152. // Gallery selection handler
  153. const handleImageSelect = (url, name) => {
  154. // We append to end since we lose cursor position when modal opens/closes
  155. // Ideally we would insert at cursor, but standard 'insertTextAtCursor' used state append.
  156. // Users can cut/paste.
  157. const markdown = `![${name}](${url})`;
  158. insertTextAtCursor(markdown);
  159. setGalleryOpen(false);
  160. };
  161. // Define Custom Toolbar Commands
  162. const customCommands = useMemo(() => {
  163. const mediaCommand = {
  164. name: "media",
  165. keyCommand: "media",
  166. buttonProps: { "aria-label": "Media Gallery", title: "Media Gallery" },
  167. icon: (
  168. <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
  169. ),
  170. execute: (state, api) => {
  171. setGalleryOpen(true);
  172. },
  173. };
  174. const compareCommand = {
  175. name: "compare",
  176. keyCommand: "compare",
  177. buttonProps: { "aria-label": "Insert Comparison", title: "Insert Comparison" },
  178. icon: (
  179. <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /></svg>
  180. ),
  181. execute: (state, api) => {
  182. api.replaceSelection(`\n\`\`\`compare\n![Before](url1)\n![After](url2)\n\`\`\`\n`);
  183. },
  184. };
  185. const reelCommand = {
  186. name: "reel",
  187. keyCommand: "reel",
  188. buttonProps: { "aria-label": "Insert Zoom Reel", title: "Insert Zoom Reel" },
  189. icon: (
  190. <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7" /></svg>
  191. ),
  192. execute: (state, api) => {
  193. api.replaceSelection(`\n\`\`\`zoom-reel\n![Image 1](url1)\n![Image 2](url2)\n\`\`\`\n`);
  194. },
  195. };
  196. const resizeCommand = {
  197. name: "resize",
  198. keyCommand: "resize",
  199. buttonProps: { "aria-label": "Insert Resized Image", title: "Insert Resized Image" },
  200. icon: (
  201. <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg>
  202. ),
  203. execute: (state, api) => {
  204. api.replaceSelection(`![Resized](url =300x200)`);
  205. },
  206. };
  207. return [mediaCommand, compareCommand, reelCommand, resizeCommand];
  208. }, []);
  209. const handleSubmit = async (e) => {
  210. e.preventDefault();
  211. if (!formData.title.trim() || !formData.content.trim()) {
  212. setError("Title and content are required");
  213. return;
  214. }
  215. try {
  216. setSaving(true);
  217. setError(null);
  218. const payload = {
  219. title: formData.title.trim(),
  220. description: formData.description.trim(),
  221. content: formData.content.trim(),
  222. tags: formData.tags
  223. .split(",")
  224. .map((tag) => tag.trim())
  225. .filter((tag) => tag),
  226. hidden: formData.hidden,
  227. };
  228. const url = isEditing
  229. ? `${API_BASE}/posts/${slug}`
  230. : `${API_BASE}/posts`;
  231. const method = isEditing ? "PUT" : "POST";
  232. const response = await fetch(url, {
  233. method,
  234. headers: {
  235. "Content-Type": "application/json",
  236. },
  237. credentials: "include",
  238. body: JSON.stringify(payload),
  239. });
  240. if (!response.ok) {
  241. const errorData = await response.json();
  242. throw new Error(errorData.error || "Failed to save post");
  243. }
  244. const savedPost = await response.json();
  245. navigate(`/admin`);
  246. } catch (err) {
  247. setError(err.message);
  248. } finally {
  249. setSaving(false);
  250. }
  251. };
  252. const [viewMode, setViewMode] = useState("tabs"); // "tabs" | "split"
  253. // Process markdown for preview
  254. const renderContent = () => {
  255. return md.render(formData.content || "");
  256. };
  257. // Calculate preview for both modes
  258. // Helper to sanitize
  259. const getSanitizedHtml = (html) => DOMPurify.sanitize(html, {
  260. ADD_TAGS: ["input"],
  261. ADD_ATTR: ["type", "min", "max", "value", "step", "style", "width", "height", "class", "data-line"], // Added data-line
  262. });
  263. const previewHtml = renderContent();
  264. const sanitizedPreview = getSanitizedHtml(previewHtml);
  265. const [settingsOpen, setSettingsOpen] = useState(false);
  266. // Auto-resize title textarea
  267. const titleRef = React.useRef(null);
  268. useEffect(() => {
  269. if (titleRef.current) {
  270. titleRef.current.style.height = "auto";
  271. titleRef.current.style.height = titleRef.current.scrollHeight + "px";
  272. }
  273. }, [formData.title]);
  274. // Scroll Sync & Flicker Effect
  275. useEffect(() => {
  276. if (activeTab === "preview" || viewMode === "split") {
  277. const elements = document.querySelectorAll('[data-line]');
  278. let target = null;
  279. let maxLine = -1;
  280. elements.forEach(el => {
  281. const line = parseInt(el.getAttribute('data-line'), 10);
  282. if (line <= cursorLine && line > maxLine) {
  283. maxLine = line;
  284. target = el;
  285. }
  286. });
  287. if (target) {
  288. target.scrollIntoView({ behavior: 'smooth', block: 'center' });
  289. // Add flicker effect
  290. target.classList.add('highlight-pulse');
  291. setTimeout(() => {
  292. target.classList.remove('highlight-pulse');
  293. }, 1500);
  294. }
  295. }
  296. }, [activeTab, cursorLine, viewMode]);
  297. return (
  298. <div className="min-h-screen theme-bg font-sans theme-text selection:bg-blue-100 selection:text-blue-900 flex flex-col transition-colors duration-300">
  299. <MediaGalleryModal
  300. isOpen={galleryOpen}
  301. onClose={() => setGalleryOpen(false)}
  302. onSelect={handleImageSelect}
  303. slug={getSlugForUpload()}
  304. />
  305. {/* Glassmorphic Top Bar */}
  306. <nav className="sticky top-0 z-40 bg-white/80 dark:bg-[#0d1117]/80 backdrop-blur-md border-b theme-border px-4 h-16 flex items-center justify-between transition-all duration-300">
  307. <div className="flex items-center gap-4">
  308. <Link
  309. to="/admin"
  310. className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors theme-text-secondary hover:theme-text"
  311. title="Back to Admin"
  312. >
  313. <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" /></svg>
  314. </Link>
  315. <span className="text-sm font-medium theme-text-secondary hidden sm:block">
  316. {saving ? "Saving..." : isEditing ? "Editing" : "Drafting"}
  317. </span>
  318. </div>
  319. <div className="flex items-center gap-3">
  320. {/* View Mode Toggle */}
  321. <div className="flex bg-gray-100 dark:bg-gray-800 p-1 rounded-lg mr-2">
  322. <button
  323. type="button"
  324. onClick={() => setViewMode(viewMode === "tabs" ? "split" : "tabs")}
  325. className={`px-2 py-1.5 rounded-md text-xs font-semibold transition-all flex items-center gap-1 ${viewMode === "split"
  326. ? "theme-surface theme-text shadow-sm"
  327. : "theme-text-secondary hover:theme-text"
  328. }`}
  329. title="Toggle Split View"
  330. >
  331. <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m-4 10a2 2 0 002 2h2a2 2 0 002-2m0 0V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" /></svg>
  332. <span className="hidden sm:inline">Split</span>
  333. </button>
  334. </div>
  335. {/* Tab Toggles (Only visible in Tabs mode) */}
  336. {viewMode === "tabs" && (
  337. <div className="flex bg-gray-100 dark:bg-gray-800 p-1 rounded-lg">
  338. <button
  339. type="button"
  340. onClick={() => setActiveTab("write")}
  341. className={`px-3 py-1.5 rounded-md text-xs font-semibold transition-all ${activeTab === "write"
  342. ? "theme-surface theme-text shadow-sm"
  343. : "theme-text-secondary hover:theme-text"
  344. }`}
  345. >
  346. Write
  347. </button>
  348. <button
  349. type="button"
  350. onClick={() => setActiveTab("preview")}
  351. className={`px-3 py-1.5 rounded-md text-xs font-semibold transition-all ${activeTab === "preview"
  352. ? "theme-surface theme-text shadow-sm"
  353. : "theme-text-secondary hover:theme-text"
  354. }`}
  355. >
  356. Preview
  357. </button>
  358. </div>
  359. )}
  360. <div className="h-6 w-px bg-gray-200 dark:bg-gray-700 mx-1"></div>
  361. <button
  362. type="button"
  363. onClick={() => setSettingsOpen(true)}
  364. className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 theme-text-secondary hover:theme-text transition-colors"
  365. title="Post Settings"
  366. >
  367. <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" /></svg>
  368. </button>
  369. <button
  370. onClick={handleSubmit}
  371. disabled={saving}
  372. className="bg-blue-600 hover:bg-blue-700 text-white px-5 py-2 rounded-lg font-medium text-sm transition-all shadow-sm hover:shadow active:scale-95 disabled:opacity-70 disabled:cursor-not-allowed"
  373. >
  374. {saving ? "Publishing..." : "Publish"}
  375. </button>
  376. </div>
  377. </nav>
  378. {/* Main Content Area */}
  379. <div className="flex-grow overflow-y-auto">
  380. <div className={`max-w-7xl mx-auto px-6 py-8 md:py-12 lg:py-16 ${viewMode === "split" ? "max-w-[95vw]" : ""}`}>
  381. {error && (
  382. <div className="mb-8 p-4 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-lg flex items-center gap-3 animate-in fade-in slide-in-from-top-2">
  383. <svg className="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
  384. {error}
  385. </div>
  386. )}
  387. <div className="space-y-6">
  388. {/* Title Input - Scaled down and boxed */}
  389. <a>Title:</a>
  390. <div className="border theme-border rounded-xl p-4 bg-gray-50/50 dark:bg-gray-800/50">
  391. <textarea
  392. ref={titleRef}
  393. value={formData.title}
  394. onChange={(e) => handleInputChange("title", e.target.value)}
  395. placeholder="Post Title"
  396. rows={1}
  397. className="w-full text-lg font-bold bg-transparent border-none placeholder-gray-400 dark:placeholder-gray-500 theme-text focus:ring-0 px-0 leading-tight resize-none overflow-hidden"
  398. style={{ minHeight: '1.5em' }}
  399. />
  400. </div>
  401. {/* Description Input - Subtitle style */}
  402. <a>Description:</a>
  403. <div className="border theme-border rounded-xl p-4 bg-gray-50/50 dark:bg-gray-800/50">
  404. <textarea
  405. value={formData.description}
  406. onChange={(e) => handleInputChange("description", e.target.value)}
  407. placeholder="Post Description (Subtitle)..."
  408. rows={2}
  409. className="w-full text-base bg-transparent border-none placeholder-gray-400 dark:placeholder-gray-500 theme-text-secondary focus:ring-0 px-0 leading-relaxed resize-none overflow-hidden"
  410. style={{ minHeight: '3em' }}
  411. />
  412. </div>
  413. {/* Split Editor/Preview Layout */}
  414. <div className={`grid gap-6 ${viewMode === "split" ? "grid-cols-1 lg:grid-cols-2" : "grid-cols-1"}`}>
  415. {/* Editor Column - Visible if write tab active OR split mode */}
  416. {activeTab === "write" || viewMode === "split" ? (
  417. <div className="prose-editor-wrapper -mx-4 md:mx-0">
  418. <MDEditor
  419. value={formData.content}
  420. onChange={handleEditorChange}
  421. commands={[...commands.getCommands(), ...customCommands]}
  422. height={viewMode === "split" ? window.innerHeight * 0.8 : window.innerHeight * 0.7}
  423. minHeight={500}
  424. preview="edit"
  425. visibleDragBar={false}
  426. textareaProps={{
  427. placeholder: "Tell your story...",
  428. onPaste: handlePaste,
  429. onSelect: handleCursorSelect,
  430. onClick: handleCursorSelect,
  431. onKeyUp: handleCursorSelect
  432. }}
  433. className="shadow-sm"
  434. />
  435. </div>
  436. ) : null}
  437. {/* Preview Column - Visible if preview tab active OR split mode */}
  438. {(activeTab === "preview" || viewMode === "split") && (
  439. <article
  440. className={`markdown-content prose dark:prose-invert max-w-none prose-lg ${viewMode === "split" ? "border-l pl-6 border-gray-200 dark:border-gray-800" : ""}`}
  441. style={{
  442. height: viewMode === "split" ? window.innerHeight * 0.8 : 'auto',
  443. overflowY: viewMode === "split" ? 'auto' : 'visible'
  444. }}
  445. >
  446. <div
  447. dangerouslySetInnerHTML={{ __html: sanitizedPreview }}
  448. ref={(node) => {
  449. if (node) {
  450. // Initialize comparison sliders if present in preview
  451. const sliders = node.querySelectorAll(".comparison-slider");
  452. sliders.forEach(slider => {
  453. slider.oninput = (e) => {
  454. const container = e.target.closest(".comparison-wrapper");
  455. const topImage = container.querySelector(".comparison-top");
  456. const handle = container.querySelector(".slider-handle");
  457. const val = e.target.value;
  458. if (topImage) topImage.style.clipPath = `inset(0 ${100 - val}% 0 0)`;
  459. if (handle) handle.style.left = `${val}%`;
  460. }
  461. });
  462. // Initialize Zoom Reels in Preview (Re-using logic from PostView slightly simplified)
  463. const zoomReels = node.querySelectorAll(".interactive-zoom-reel");
  464. zoomReels.forEach(reel => {
  465. const images = reel.querySelectorAll(".zoom-reel-img");
  466. const viewports = reel.querySelectorAll(".zoom-reel-viewport");
  467. const slider = reel.querySelector(".zoom-slider");
  468. const resetBtn = reel.querySelector(".reset-zoom");
  469. let state = { zoom: 1, panX: 0, panY: 0, isDragging: false, startX: 0, startY: 0, initialPanX: 0, initialPanY: 0 };
  470. const updateTransform = () => {
  471. images.forEach(img => {
  472. img.style.transform = `translate(${state.panX}px, ${state.panY}px) scale(${state.zoom})`;
  473. });
  474. };
  475. slider.oninput = (e) => {
  476. state.zoom = parseFloat(e.target.value);
  477. if (state.zoom === 1) { state.panX = 0; state.panY = 0; }
  478. updateTransform();
  479. };
  480. resetBtn.onclick = () => {
  481. state.zoom = 1; state.panX = 0; state.panY = 0; slider.value = 1;
  482. updateTransform();
  483. };
  484. // Simplified drag for preview
  485. reel.onmousedown = (e) => {
  486. if (state.zoom <= 1) return;
  487. e.preventDefault();
  488. state.isDragging = true;
  489. state.startX = e.clientX;
  490. state.startY = e.clientY;
  491. state.initialPanX = state.panX;
  492. state.initialPanY = state.panY;
  493. viewports.forEach(v => v.style.cursor = "grabbing");
  494. };
  495. reel.onmousemove = (e) => {
  496. if (!state.isDragging) return;
  497. e.preventDefault();
  498. const dx = e.clientX - state.startX;
  499. const dy = e.clientY - state.startY;
  500. state.panX = state.initialPanX + dx;
  501. state.panY = state.initialPanY + dy;
  502. updateTransform();
  503. };
  504. reel.onmouseup = () => {
  505. state.isDragging = false;
  506. viewports.forEach(v => v.style.cursor = "grab");
  507. };
  508. reel.onmouseleave = () => {
  509. state.isDragging = false;
  510. viewports.forEach(v => v.style.cursor = "grab");
  511. };
  512. });
  513. }
  514. }}
  515. />
  516. </article>
  517. )}
  518. </div>
  519. </div>
  520. </div>
  521. </div>
  522. {/* Settings Drawer - Slide-out */}
  523. {settingsOpen && (
  524. <div className="fixed inset-0 z-50 overflow-hidden">
  525. <div className="absolute inset-0 bg-black/20 backdrop-blur-sm transition-opacity" onClick={() => setSettingsOpen(false)}></div>
  526. <div className="absolute inset-y-0 right-0 max-w-xs w-full theme-surface shadow-2xl transform transition-transform duration-300 ease-in-out px-6 py-6 border-l theme-border flex flex-col h-full animate-in slide-in-from-right">
  527. <div className="flex items-center justify-between mb-8">
  528. <h2 className="text-lg font-bold theme-text">Post Settings</h2>
  529. <button
  530. onClick={() => setSettingsOpen(false)}
  531. className="p-2 -mr-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 theme-text-secondary hover:theme-text"
  532. >
  533. <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" /></svg>
  534. </button>
  535. </div>
  536. <div className="space-y-6 flex-grow overflow-y-auto">
  537. <div>
  538. <label htmlFor="tags" className="block text-xs font-bold uppercase tracking-wider theme-text-secondary mb-2">Tags</label>
  539. <input
  540. type="text"
  541. id="tags"
  542. value={formData.tags}
  543. onChange={(e) => handleInputChange("tags", e.target.value)}
  544. className="w-full bg-transparent border theme-border rounded-lg px-4 py-3 text-sm focus:ring-2 focus:ring-blue-500/20 outline-none transition-all theme-text"
  545. placeholder="technology, life..."
  546. />
  547. <p className="mt-2 text-xs theme-text-secondary">Comma separated.</p>
  548. </div>
  549. <div className="pt-4 border-t theme-border">
  550. <label className="flex items-center gap-3 cursor-pointer group">
  551. <input
  552. type="checkbox"
  553. checked={formData.hidden}
  554. onChange={(e) => handleInputChange("hidden", e.target.checked)}
  555. className="w-5 h-5 theme-text rounded focus:ring-gray-500 border-gray-300 transition-colors"
  556. />
  557. <div className="flex flex-col">
  558. <span className="text-sm font-medium theme-text group-hover:theme-text transition-colors">Hidden Post</span>
  559. <span className="text-xs theme-text-secondary">Only visible to admins</span>
  560. </div>
  561. </label>
  562. </div>
  563. </div>
  564. <div className="mt-auto pt-6 border-t theme-border">
  565. <div className="text-xs theme-text-secondary text-center">
  566. Settings allow you to fine-tune metadata.
  567. </div>
  568. </div>
  569. </div>
  570. </div>
  571. )}
  572. </div>
  573. );
  574. }
  575. export default PostEditor;