Skip to content

Commit 241b4b3

Browse files
authored
Merge pull request #12 from openpatch/copilot/add-json-store-learningmaps
Add JSON Store integration for sharing learningmaps via shareable links
2 parents 62ad545 + b03ae84 commit 241b4b3

8 files changed

Lines changed: 2637 additions & 3772 deletions

File tree

packages/learningmap/src/EditorToolbar.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import React from "react";
22
import { Menu, MenuButton, MenuDivider, MenuItem, SubMenu } from "@szhsin/react-menu";
33
import "@szhsin/react-menu/dist/index.css";
44
import '@szhsin/react-menu/dist/transitions/zoom.css';
5-
import { Plus, Bug, Settings, Eye, Menu as MenuI, FolderOpen, Download, ImageDown, ExternalLink } from "lucide-react";
5+
import { Plus, Bug, Settings, Eye, Menu as MenuI, FolderOpen, Download, ImageDown, ExternalLink, Share2 } from "lucide-react";
66
import { getTranslations } from "./translations";
77

88
interface EditorToolbarProps {
@@ -20,6 +20,7 @@ interface EditorToolbarProps {
2020
onOpenSettingsDrawer: () => void;
2121
onDownlad: () => void;
2222
onOpen: () => void;
23+
onShare: () => void;
2324
language?: string;
2425
}
2526

@@ -38,6 +39,7 @@ export const EditorToolbar: React.FC<EditorToolbarProps> = ({
3839
onOpenSettingsDrawer,
3940
onDownlad,
4041
onOpen,
42+
onShare,
4143
language = "en",
4244
}) => {
4345
const t = getTranslations(language);
@@ -63,6 +65,9 @@ export const EditorToolbar: React.FC<EditorToolbarProps> = ({
6365
<MenuItem onClick={onDownlad}>
6466
<Download size={16} /> <span>{t.download}</span>
6567
</MenuItem>
68+
<MenuItem onClick={onShare}>
69+
<Share2 size={16} /> <span>{t.share}</span>
70+
</MenuItem>
6671
<MenuDivider />
6772
<SubMenu className={`${debugMode ? "active" : ""}`} label={<><Bug size={16} /> <span>{t.debug}</span></>}>
6873
<MenuItem type="checkbox" checked={debugMode} onClick={onToggleDebugMode}>

packages/learningmap/src/LearningMapEditor.tsx

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ import useUndoable from "./useUndoable";
3535
import { MultiNodePanel } from "./MultiNodePanel";
3636
import { getTranslations } from "./translations";
3737
import { WelcomeMessage } from "./WelcomeMessage";
38+
import { ShareDialog } from "./ShareDialog";
39+
import { LoadExternalDialog } from "./LoadExternalDialog";
3840

3941
const nodeTypes = {
4042
topic: TopicNode,
@@ -51,6 +53,8 @@ export interface LearningMapEditorProps {
5153
roadmapData?: string | RoadmapData;
5254
language?: string;
5355
onChange?: (data: RoadmapData) => void;
56+
jsonStore?: string;
57+
onLoadExternal?: (id: string) => void;
5458
}
5559

5660
const getDefaultFilename = () => {
@@ -63,6 +67,8 @@ export function LearningMapEditor({
6367
roadmapData,
6468
language = "en",
6569
onChange,
70+
jsonStore = "https://json.openpatch.org",
71+
onLoadExternal,
6672
}: LearningMapEditorProps) {
6773
const { screenToFlowPosition, zoomIn, zoomOut, setCenter, fitView, getNodes, getEdges } = useReactFlow();
6874
const [roadmapState, setRoadmapState, { undo, redo, canUndo, canRedo, reset, resetInitialState }] = useUndoable<RoadmapData>({
@@ -122,6 +128,12 @@ export function LearningMapEditor({
122128
const [showCompletionOptional, setShowCompletionOptional] = useState(true);
123129
const [showUnlockAfter, setShowUnlockAfter] = useState(true);
124130

131+
// Share dialog state
132+
const [shareDialogOpen, setShareDialogOpen] = useState(false);
133+
const [shareLink, setShareLink] = useState("");
134+
const [loadExternalDialogOpen, setLoadExternalDialogOpen] = useState(false);
135+
const [pendingExternalId, setPendingExternalId] = useState<string | null>(null);
136+
125137
// Edge drawer state
126138
const [selectedEdge, setSelectedEdge] = useState<Edge | null>(null);
127139
const [edgeDrawerOpen, setEdgeDrawerOpen] = useState(false);
@@ -432,6 +444,66 @@ export function LearningMapEditor({
432444
downloadAnchorNode.remove();
433445
}, [roadmapState]);
434446

447+
const handleShare = useCallback(() => {
448+
// Check if map is empty (no nodes)
449+
if (!roadmapState.nodes || roadmapState.nodes.length === 0) {
450+
alert(t.emptyMapCannotBeShared);
451+
return;
452+
}
453+
454+
// Upload to JSON store
455+
fetch(`${jsonStore}/api/v2/post`, {
456+
method: "POST",
457+
mode: "cors",
458+
headers: {
459+
"Content-Type": "application/json",
460+
},
461+
body: JSON.stringify(roadmapState),
462+
})
463+
.then((r) => r.json())
464+
.then((json) => {
465+
const link = window.location.origin + window.location.pathname + "#json=" + json.id;
466+
setShareLink(link);
467+
setShareDialogOpen(true);
468+
})
469+
.catch(() => {
470+
alert(t.uploadFailed);
471+
});
472+
}, [roadmapState, jsonStore, t]);
473+
474+
const loadFromJsonStore = useCallback((id: string) => {
475+
fetch(`${jsonStore}/api/v2/${id}`, {
476+
method: "GET",
477+
mode: "cors",
478+
})
479+
.then((r) => r.text())
480+
.then((text) => {
481+
const json = JSON.parse(text);
482+
setRoadmapState(json);
483+
loadRoadmapStateIntoReactFlowState(json);
484+
setLoadExternalDialogOpen(false);
485+
setPendingExternalId(null);
486+
})
487+
.catch(() => {
488+
alert(t.loadFailed);
489+
});
490+
}, [jsonStore, t, setRoadmapState]);
491+
492+
const handleLoadExternal = useCallback((id: string) => {
493+
setPendingExternalId(id);
494+
setLoadExternalDialogOpen(true);
495+
}, []);
496+
497+
// Check for external JSON in URL hash on mount
498+
useEffect(() => {
499+
const hash = window.location.hash;
500+
if (hash.startsWith("#json=")) {
501+
const id = hash.substring(6);
502+
handleLoadExternal(id);
503+
}
504+
}, [handleLoadExternal]);
505+
506+
435507
const defaultEdgeOptions = {
436508
animated: false,
437509
style: {
@@ -757,6 +829,7 @@ export function LearningMapEditor({
757829
onOpenSettingsDrawer={handleOpenSettingsDrawer}
758830
onDownlad={handleDownload}
759831
onOpen={handleOpen}
832+
onShare={handleShare}
760833
language={effectiveLanguage}
761834
/>
762835
{previewMode && <LearningMap roadmapData={roadmapState} language={effectiveLanguage} />}
@@ -863,6 +936,26 @@ export function LearningMapEditor({
863936
</table>
864937
<button className="primary-button" onClick={() => setHelpOpen(false)}>{t.close}</button>
865938
</dialog>
939+
<ShareDialog
940+
open={shareDialogOpen}
941+
onClose={() => setShareDialogOpen(false)}
942+
shareLink={shareLink}
943+
language={effectiveLanguage}
944+
/>
945+
<LoadExternalDialog
946+
open={loadExternalDialogOpen}
947+
onClose={() => {
948+
setLoadExternalDialogOpen(false);
949+
setPendingExternalId(null);
950+
}}
951+
onDownloadCurrent={handleDownload}
952+
onReplace={() => {
953+
if (pendingExternalId) {
954+
loadFromJsonStore(pendingExternalId);
955+
}
956+
}}
957+
language={effectiveLanguage}
958+
/>
866959
</>
867960
}
868961
</>
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import React from "react";
2+
import { X, Download, AlertTriangle } from "lucide-react";
3+
import { getTranslations } from "./translations";
4+
5+
interface LoadExternalDialogProps {
6+
open: boolean;
7+
onClose: () => void;
8+
onDownloadCurrent: () => void;
9+
onReplace: () => void;
10+
language?: string;
11+
}
12+
13+
export function LoadExternalDialog({
14+
open,
15+
onClose,
16+
onDownloadCurrent,
17+
onReplace,
18+
language = "en",
19+
}: LoadExternalDialogProps) {
20+
const t = getTranslations(language);
21+
22+
if (!open) return null;
23+
24+
const handleDownloadAndReplace = () => {
25+
onDownloadCurrent();
26+
setTimeout(() => onReplace(), 100);
27+
};
28+
29+
return (
30+
<>
31+
<div className="drawer-overlay" onClick={onClose} />
32+
<div className="share-dialog">
33+
<header className="drawer-header">
34+
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
35+
<AlertTriangle size={20} color="#f59e0b" />
36+
<h2 className="drawer-title" style={{ margin: 0 }}>Warning</h2>
37+
</div>
38+
<button className="close-button" onClick={onClose} aria-label={t.close}>
39+
<X size={20} />
40+
</button>
41+
</header>
42+
<div className="drawer-content">
43+
<p style={{ marginBottom: 16 }}>{t.loadExternalWarning}</p>
44+
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
45+
<button
46+
onClick={handleDownloadAndReplace}
47+
className="drawer-button"
48+
style={{
49+
display: "flex",
50+
alignItems: "center",
51+
justifyContent: "center",
52+
gap: 8,
53+
}}
54+
>
55+
<Download size={16} />
56+
{t.downloadCurrentMap}
57+
</button>
58+
<button
59+
onClick={onReplace}
60+
className="drawer-button"
61+
style={{
62+
display: "flex",
63+
alignItems: "center",
64+
justifyContent: "center",
65+
gap: 8,
66+
backgroundColor: "#dc2626",
67+
}}
68+
>
69+
{t.replaceWithExternal}
70+
</button>
71+
</div>
72+
</div>
73+
</div>
74+
</>
75+
);
76+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import React, { useState } from "react";
2+
import { X, Link2, Check } from "lucide-react";
3+
import { getTranslations } from "./translations";
4+
5+
interface ShareDialogProps {
6+
open: boolean;
7+
onClose: () => void;
8+
shareLink: string;
9+
language?: string;
10+
}
11+
12+
export function ShareDialog({ open, onClose, shareLink, language = "en" }: ShareDialogProps) {
13+
const t = getTranslations(language);
14+
const [copied, setCopied] = useState(false);
15+
16+
if (!open) return null;
17+
18+
const handleCopyLink = async () => {
19+
try {
20+
await navigator.clipboard.writeText(shareLink);
21+
setCopied(true);
22+
setTimeout(() => setCopied(false), 2000);
23+
} catch (err) {
24+
console.error("Failed to copy link:", err);
25+
}
26+
};
27+
28+
return (
29+
<>
30+
<div className="drawer-overlay" onClick={onClose} />
31+
<div className="share-dialog">
32+
<header className="drawer-header">
33+
<h2 className="drawer-title">{t.share}</h2>
34+
<button className="close-button" onClick={onClose} aria-label={t.close}>
35+
<X size={20} />
36+
</button>
37+
</header>
38+
<div className="drawer-content">
39+
<p style={{ marginBottom: 16 }}>{t.shareLink}</p>
40+
<div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
41+
<input
42+
type="text"
43+
value={shareLink}
44+
readOnly
45+
style={{
46+
flex: 1,
47+
padding: "8px 12px",
48+
border: "1px solid #d1d5db",
49+
borderRadius: 4,
50+
fontSize: 14,
51+
fontFamily: "monospace",
52+
}}
53+
onClick={(e) => e.currentTarget.select()}
54+
/>
55+
</div>
56+
<button
57+
onClick={handleCopyLink}
58+
className="drawer-button"
59+
style={{
60+
width: "100%",
61+
display: "flex",
62+
alignItems: "center",
63+
justifyContent: "center",
64+
gap: 8,
65+
}}
66+
>
67+
{copied ? (
68+
<>
69+
<Check size={16} />
70+
{t.linkCopied}
71+
</>
72+
) : (
73+
<>
74+
<Link2 size={16} />
75+
{t.copyLink}
76+
</>
77+
)}
78+
</button>
79+
</div>
80+
</div>
81+
</>
82+
);
83+
}

packages/learningmap/src/index.css

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,34 @@ header.drawer-header {
240240
justify-content: flex-end;
241241
}
242242

243+
/* Share Dialog */
244+
.share-dialog {
245+
position: fixed;
246+
top: 50%;
247+
left: 50%;
248+
transform: translate(-50%, -50%);
249+
width: 90%;
250+
max-width: 500px;
251+
background: var(--color-nav, white);
252+
border-radius: 8px;
253+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
254+
z-index: 1003;
255+
display: flex;
256+
flex-direction: column;
257+
animation: scaleIn 0.2s ease;
258+
}
259+
260+
@keyframes scaleIn {
261+
from {
262+
opacity: 0;
263+
transform: translate(-50%, -50%) scale(0.95);
264+
}
265+
to {
266+
opacity: 1;
267+
transform: translate(-50%, -50%) scale(1);
268+
}
269+
}
270+
243271
/* Form Styles */
244272
.form-group {
245273
margin-bottom: 20px;

0 commit comments

Comments
 (0)