Spaces:
Sleeping
Sleeping
feat: add publish button with confirmation dialog
Browse files- Purple publish icon in the top bar
- Confirmation dialog explaining what publish does
- Loading state with spinner during publish
- Success/error states with appropriate messaging
- Calls POST /api/publish with current docName
Made-with: Cursor
- frontend/src/App.tsx +96 -0
frontend/src/App.tsx
CHANGED
|
@@ -14,6 +14,16 @@ import UndoIcon from "@mui/icons-material/Undo";
|
|
| 14 |
import RedoIcon from "@mui/icons-material/Redo";
|
| 15 |
import FileDownloadOutlinedIcon from "@mui/icons-material/FileDownloadOutlined";
|
| 16 |
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
import { Editor } from "./editor/Editor";
|
| 18 |
import { CommentsSidebar } from "./components/CommentsSidebar";
|
| 19 |
import { CommentDialog } from "./components/CommentDialog";
|
|
@@ -61,6 +71,9 @@ export default function App() {
|
|
| 61 |
const [showExport, setShowExport] = useState(false);
|
| 62 |
const [commentDialogOpen, setCommentDialogOpen] = useState(false);
|
| 63 |
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
|
|
|
|
|
|
|
|
| 64 |
const selectionRange = useRef<{ from: number; to: number } | null>(null);
|
| 65 |
const [hasSelection, setHasSelection] = useState(false);
|
| 66 |
const [undoManager, setUndoManager] = useState<UndoManager | null>(null);
|
|
@@ -107,6 +120,26 @@ export default function App() {
|
|
| 107 |
setSettingsMap(map);
|
| 108 |
}, []);
|
| 109 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
const handleAddComment = useCallback(() => {
|
| 111 |
const editor = editorRef.current;
|
| 112 |
if (!editor) return;
|
|
@@ -195,6 +228,16 @@ export default function App() {
|
|
| 195 |
<FileDownloadOutlinedIcon sx={{ fontSize: 18 }} />
|
| 196 |
</IconButton>
|
| 197 |
</Tooltip>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
<Chip
|
| 199 |
label={user.name}
|
| 200 |
size="small"
|
|
@@ -316,6 +359,59 @@ export default function App() {
|
|
| 316 |
store={frontmatterStore}
|
| 317 |
settingsMap={settingsMap}
|
| 318 |
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
</Box>
|
| 320 |
);
|
| 321 |
}
|
|
|
|
| 14 |
import RedoIcon from "@mui/icons-material/Redo";
|
| 15 |
import FileDownloadOutlinedIcon from "@mui/icons-material/FileDownloadOutlined";
|
| 16 |
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
|
| 17 |
+
import PublishOutlinedIcon from "@mui/icons-material/PublishOutlined";
|
| 18 |
+
import {
|
| 19 |
+
Button,
|
| 20 |
+
Dialog,
|
| 21 |
+
DialogTitle,
|
| 22 |
+
DialogContent,
|
| 23 |
+
DialogContentText,
|
| 24 |
+
DialogActions,
|
| 25 |
+
CircularProgress,
|
| 26 |
+
} from "@mui/material";
|
| 27 |
import { Editor } from "./editor/Editor";
|
| 28 |
import { CommentsSidebar } from "./components/CommentsSidebar";
|
| 29 |
import { CommentDialog } from "./components/CommentDialog";
|
|
|
|
| 71 |
const [showExport, setShowExport] = useState(false);
|
| 72 |
const [commentDialogOpen, setCommentDialogOpen] = useState(false);
|
| 73 |
const [settingsOpen, setSettingsOpen] = useState(false);
|
| 74 |
+
const [publishDialogOpen, setPublishDialogOpen] = useState(false);
|
| 75 |
+
const [publishState, setPublishState] = useState<"idle" | "loading" | "success" | "error">("idle");
|
| 76 |
+
const [publishError, setPublishError] = useState("");
|
| 77 |
const selectionRange = useRef<{ from: number; to: number } | null>(null);
|
| 78 |
const [hasSelection, setHasSelection] = useState(false);
|
| 79 |
const [undoManager, setUndoManager] = useState<UndoManager | null>(null);
|
|
|
|
| 120 |
setSettingsMap(map);
|
| 121 |
}, []);
|
| 122 |
|
| 123 |
+
const handlePublish = useCallback(async () => {
|
| 124 |
+
setPublishState("loading");
|
| 125 |
+
setPublishError("");
|
| 126 |
+
try {
|
| 127 |
+
const res = await fetch("/api/publish", {
|
| 128 |
+
method: "POST",
|
| 129 |
+
headers: { "Content-Type": "application/json" },
|
| 130 |
+
body: JSON.stringify({ docName }),
|
| 131 |
+
});
|
| 132 |
+
if (!res.ok) {
|
| 133 |
+
const data = await res.json().catch(() => ({ error: "Publish failed" }));
|
| 134 |
+
throw new Error(data.error || "Publish failed");
|
| 135 |
+
}
|
| 136 |
+
setPublishState("success");
|
| 137 |
+
} catch (err: any) {
|
| 138 |
+
setPublishState("error");
|
| 139 |
+
setPublishError(err.message || "Unknown error");
|
| 140 |
+
}
|
| 141 |
+
}, [docName]);
|
| 142 |
+
|
| 143 |
const handleAddComment = useCallback(() => {
|
| 144 |
const editor = editorRef.current;
|
| 145 |
if (!editor) return;
|
|
|
|
| 228 |
<FileDownloadOutlinedIcon sx={{ fontSize: 18 }} />
|
| 229 |
</IconButton>
|
| 230 |
</Tooltip>
|
| 231 |
+
<Box sx={{ width: "1px", height: 16, bgcolor: "divider", mx: 0.5 }} />
|
| 232 |
+
<Tooltip title="Publish article" arrow>
|
| 233 |
+
<IconButton
|
| 234 |
+
size="small"
|
| 235 |
+
onClick={() => { setPublishState("idle"); setPublishDialogOpen(true); }}
|
| 236 |
+
sx={{ color: "primary.main" }}
|
| 237 |
+
>
|
| 238 |
+
<PublishOutlinedIcon sx={{ fontSize: 18 }} />
|
| 239 |
+
</IconButton>
|
| 240 |
+
</Tooltip>
|
| 241 |
<Chip
|
| 242 |
label={user.name}
|
| 243 |
size="small"
|
|
|
|
| 359 |
store={frontmatterStore}
|
| 360 |
settingsMap={settingsMap}
|
| 361 |
/>
|
| 362 |
+
|
| 363 |
+
<Dialog
|
| 364 |
+
open={publishDialogOpen}
|
| 365 |
+
onClose={() => setPublishDialogOpen(false)}
|
| 366 |
+
maxWidth="xs"
|
| 367 |
+
fullWidth
|
| 368 |
+
>
|
| 369 |
+
<DialogTitle>
|
| 370 |
+
{publishState === "success" ? "Published!" : "Publish article"}
|
| 371 |
+
</DialogTitle>
|
| 372 |
+
<DialogContent>
|
| 373 |
+
{publishState === "idle" && (
|
| 374 |
+
<DialogContentText>
|
| 375 |
+
This will generate a static HTML page, PDF, and thumbnail
|
| 376 |
+
from the current document. Visitors without edit access
|
| 377 |
+
will see the published version.
|
| 378 |
+
</DialogContentText>
|
| 379 |
+
)}
|
| 380 |
+
{publishState === "loading" && (
|
| 381 |
+
<Box sx={{ display: "flex", alignItems: "center", gap: 2, py: 2 }}>
|
| 382 |
+
<CircularProgress size={24} />
|
| 383 |
+
<DialogContentText sx={{ m: 0 }}>
|
| 384 |
+
Publishing...
|
| 385 |
+
</DialogContentText>
|
| 386 |
+
</Box>
|
| 387 |
+
)}
|
| 388 |
+
{publishState === "success" && (
|
| 389 |
+
<DialogContentText>
|
| 390 |
+
Article published successfully. Visitors will now see
|
| 391 |
+
the updated version.
|
| 392 |
+
</DialogContentText>
|
| 393 |
+
)}
|
| 394 |
+
{publishState === "error" && (
|
| 395 |
+
<DialogContentText color="error">
|
| 396 |
+
{publishError || "An error occurred while publishing."}
|
| 397 |
+
</DialogContentText>
|
| 398 |
+
)}
|
| 399 |
+
</DialogContent>
|
| 400 |
+
<DialogActions>
|
| 401 |
+
{publishState === "idle" && (
|
| 402 |
+
<>
|
| 403 |
+
<Button onClick={() => setPublishDialogOpen(false)}>Cancel</Button>
|
| 404 |
+
<Button variant="contained" onClick={handlePublish}>
|
| 405 |
+
Publish
|
| 406 |
+
</Button>
|
| 407 |
+
</>
|
| 408 |
+
)}
|
| 409 |
+
{publishState === "loading" && null}
|
| 410 |
+
{(publishState === "success" || publishState === "error") && (
|
| 411 |
+
<Button onClick={() => setPublishDialogOpen(false)}>Close</Button>
|
| 412 |
+
)}
|
| 413 |
+
</DialogActions>
|
| 414 |
+
</Dialog>
|
| 415 |
</Box>
|
| 416 |
);
|
| 417 |
}
|