Skip to content

Commit d06754d

Browse files
authored
Merge pull request #29 from openpatch/copilot/add-storage-id-field
Add ID Field to Learning Maps for Shared State Management
2 parents e881de0 + cd90858 commit d06754d

File tree

6 files changed

+2372
-2702
lines changed

6 files changed

+2372
-2702
lines changed

packages/learningmap/src/SettingsDrawer.tsx

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import React, { useState, useEffect } from "react";
2-
import { X, Save } from "lucide-react";
2+
import { X, Save, RefreshCw } from "lucide-react";
33
import { Settings } from "./types";
44
import { ColorSelector } from "./ColorSelector";
55
import { getTranslations } from "./translations";
66
import { useReactFlow } from "@xyflow/react";
77
import { useEditorStore } from "./editorStore";
8+
import { generateRandomId } from "./helper";
89

910
interface SettingsDrawerProps {
1011
defaultLanguage?: string;
@@ -87,6 +88,32 @@ export const SettingsDrawer: React.FC<SettingsDrawerProps> = ({
8788
<option value="de">{t.languageGerman}</option>
8889
</select>
8990
</div>
91+
92+
<div className="form-group">
93+
<label>{t.storageId}</label>
94+
<div style={{ display: 'flex', gap: '8px', marginBottom: '8px' }}>
95+
<input
96+
type="text"
97+
value={localSettings?.id || ""}
98+
onChange={(e) => setLocalSettings(settings => ({ ...settings, id: e.target.value }))}
99+
placeholder="Optional"
100+
style={{ flex: 1 }}
101+
/>
102+
<button
103+
onClick={() => setLocalSettings(settings => ({ ...settings, id: generateRandomId() }))}
104+
className="secondary-button"
105+
type="button"
106+
style={{ padding: '8px 12px', display: 'flex', alignItems: 'center', gap: '4px' }}
107+
title={t.generateRandomId}
108+
>
109+
<RefreshCw size={16} />
110+
</button>
111+
</div>
112+
<p style={{ fontSize: '0.875rem', color: '#666', margin: 0, fontStyle: 'italic' }}>
113+
ℹ️ {t.storageIdHint}
114+
</p>
115+
</div>
116+
90117
<div className="form-group">
91118
<ColorSelector
92119
label={t.backgroundColor}

packages/learningmap/src/helper.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,21 @@ export const parseRoadmapData = (
106106
edges: (userRoadmapData as any).edges || defaultRoadmapData.edges,
107107
};
108108
};
109+
110+
/**
111+
* Generates a random ID similar to the format used by json.openpatch.org
112+
* Example format: iIhK7sHqL-EMWp9OM5_-q
113+
*/
114+
export const generateRandomId = (): string => {
115+
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
116+
const segments = [9, 11, 1]; // Generate segments of 9, 11, and 1 characters
117+
return segments
118+
.map(length => {
119+
let result = '';
120+
for (let i = 0; i < length; i++) {
121+
result += chars.charAt(Math.floor(Math.random() * chars.length));
122+
}
123+
return result;
124+
})
125+
.join('-');
126+
};

packages/learningmap/src/translations.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,11 @@ export interface Translations {
192192
viewportZoom: string;
193193
useCurrentViewport: string;
194194

195+
// ID settings
196+
storageId: string;
197+
generateRandomId: string;
198+
storageIdHint: string;
199+
195200
// Welcome message
196201
welcomeTitle: string;
197202
welcomeSubtitle: string;
@@ -398,6 +403,11 @@ const en: Translations = {
398403
viewportZoom: "Zoom",
399404
useCurrentViewport: "Use Current Viewport",
400405

406+
// ID settings
407+
storageId: "ID",
408+
generateRandomId: "Generate Random ID",
409+
storageIdHint: "Learning maps with the same ID will share the same state when a student is working on it.",
410+
401411
// Welcome message
402412
welcomeTitle: "Learningmap",
403413
welcomeSubtitle: "All data is stored locally in your browser",
@@ -607,6 +617,11 @@ const de: Translations = {
607617
viewportZoom: "Zoom",
608618
useCurrentViewport: "Aktuellen Ansichtsbereich verwenden",
609619

620+
// ID settings
621+
storageId: "ID",
622+
generateRandomId: "Zufällige ID generieren",
623+
storageIdHint: "Lernkarten mit der gleichen ID teilen sich den gleichen Zustand, wenn ein Schüler daran arbeitet.",
624+
610625
// Welcome message
611626
welcomeTitle: "Learningmap",
612627
welcomeSubtitle: "Alle Daten werden lokal in Ihrem Browser gespeichert",

packages/learningmap/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export interface BackgroundConfig {
4848

4949
export interface Settings {
5050
title?: string;
51+
id?: string;
5152
background?: BackgroundConfig;
5253
language?: string;
5354
viewport?: {

platforms/web/src/Learn.tsx

Lines changed: 88 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,44 @@ function Learn() {
3838
useEffect(() => {
3939
if (!jsonId) return;
4040

41+
// First, fetch the roadmap data to check if it has an id
4142
const existingMap = getLearningMap(jsonId);
4243

4344
if (existingMap) {
44-
// Already have the data, just update last accessed
45-
addLearningMap(jsonId, existingMap.roadmapData);
45+
// Check if the roadmap has a storage ID and handle potential conflicts
46+
const storageId = existingMap.roadmapData.settings?.id;
47+
if (storageId && storageId !== jsonId) {
48+
// There's a custom storage ID - check if a different map exists with that ID
49+
const mapWithStorageId = getLearningMap(storageId);
50+
if (mapWithStorageId && mapWithStorageId.roadmapData !== existingMap.roadmapData) {
51+
// Ask user if they want to replace
52+
const shouldReplace = window.confirm(
53+
`A learning map with the storage ID "${storageId}" already exists. Would you like to replace it with this map? Your progress will not be removed.`
54+
);
55+
if (shouldReplace) {
56+
// Keep the existing state but update the roadmap data
57+
const existingState = mapWithStorageId.state;
58+
addLearningMap(storageId, existingMap.roadmapData);
59+
if (existingState) {
60+
updateState(storageId, existingState);
61+
}
62+
// Remove the old jsonId entry to avoid duplicates
63+
if (jsonId !== storageId) {
64+
removeLearningMap(jsonId);
65+
}
66+
}
67+
} else {
68+
// No conflict, just update
69+
addLearningMap(storageId, existingMap.roadmapData);
70+
// Remove the old jsonId entry if different
71+
if (jsonId !== storageId) {
72+
removeLearningMap(jsonId);
73+
}
74+
}
75+
} else {
76+
// No custom storage ID, just use jsonId
77+
addLearningMap(jsonId, existingMap.roadmapData);
78+
}
4679
return;
4780
}
4881

@@ -57,26 +90,54 @@ function Learn() {
5790
.then((r) => r.text())
5891
.then((text) => {
5992
const json = JSON.parse(text);
60-
addLearningMap(jsonId, json);
93+
const storageId = json.settings?.id;
94+
95+
if (storageId && storageId !== jsonId) {
96+
// Check if a map with this storage ID already exists
97+
const existingMapWithStorageId = getLearningMap(storageId);
98+
if (existingMapWithStorageId) {
99+
// Ask user if they want to replace
100+
const shouldReplace = window.confirm(
101+
`A learning map with the storage ID "${storageId}" already exists. Would you like to replace it with this map? Your progress will not be removed.`
102+
);
103+
if (shouldReplace) {
104+
// Keep the existing state but update the roadmap data
105+
const existingState = existingMapWithStorageId.state;
106+
addLearningMap(storageId, json);
107+
if (existingState) {
108+
updateState(storageId, existingState);
109+
}
110+
} else {
111+
// User chose not to replace, just use jsonId as key
112+
addLearningMap(jsonId, json);
113+
}
114+
} else {
115+
// No conflict, use storage ID
116+
addLearningMap(storageId, json);
117+
}
118+
} else {
119+
// No custom storage ID, use jsonId
120+
addLearningMap(jsonId, json);
121+
}
61122
setLoading(false);
62123
})
63124
.catch(() => {
64125
setError('Failed to load learning map. Please check the URL and try again.');
65126
setLoading(false);
66127
});
67-
}, [jsonId, getLearningMap, addLearningMap]);
128+
}, [jsonId, getLearningMap, addLearningMap, updateState, removeLearningMap]);
68129

69-
const handleStateChange = useCallback((state: RoadmapState) => {
70-
if (jsonId) {
130+
const handleStateChange = useCallback((state: RoadmapState, key: string) => {
131+
if (key) {
71132
// Debounce state updates to prevent infinite loops
72133
if (updateTimeoutRef.current) {
73134
clearTimeout(updateTimeoutRef.current);
74135
}
75136
updateTimeoutRef.current = setTimeout(() => {
76-
updateState(jsonId, state);
137+
updateState(key, state);
77138
}, 500);
78139
}
79-
}, [jsonId, updateState]);
140+
}, [updateState]);
80141

81142
const handleAddMap = () => {
82143
// Parse URL to extract json ID
@@ -98,7 +159,23 @@ function Learn() {
98159

99160
// If there's a json ID, show the learning map
100161
if (jsonId) {
101-
const learningMap = getLearningMap(jsonId);
162+
// First try to get by storage ID if present, otherwise use jsonId
163+
let learningMap = getLearningMap(jsonId);
164+
165+
// If not found by jsonId, check if there's a storage ID in any map
166+
if (!learningMap) {
167+
const allMaps = getAllLearningMaps();
168+
const mapWithJsonId = allMaps.find(m => m.id === jsonId);
169+
if (mapWithJsonId) {
170+
learningMap = mapWithJsonId;
171+
}
172+
}
173+
174+
// Try to determine the storage key (either custom id or jsonId)
175+
const storageKey = learningMap?.roadmapData?.settings?.id || jsonId;
176+
if (storageKey !== jsonId) {
177+
learningMap = getLearningMap(storageKey);
178+
}
102179

103180
if (loading) {
104181
return (
@@ -143,10 +220,10 @@ function Learn() {
143220
</div>
144221
</div>
145222
<LearningMap
146-
key={jsonId}
223+
key={storageKey}
147224
roadmapData={learningMap.roadmapData}
148225
initialState={learningMap.state}
149-
onChange={handleStateChange}
226+
onChange={(state) => handleStateChange(state, storageKey)}
150227
/>
151228
</div>
152229
);

0 commit comments

Comments
 (0)