Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion packages/learningmap/src/SettingsDrawer.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import React, { useState, useEffect } from "react";
import { X, Save } from "lucide-react";
import { X, Save, RefreshCw } from "lucide-react";
import { Settings } from "./types";
import { ColorSelector } from "./ColorSelector";
import { getTranslations } from "./translations";
import { useReactFlow } from "@xyflow/react";
import { useEditorStore } from "./editorStore";
import { generateRandomId } from "./helper";

interface SettingsDrawerProps {
defaultLanguage?: string;
Expand Down Expand Up @@ -87,6 +88,32 @@ export const SettingsDrawer: React.FC<SettingsDrawerProps> = ({
<option value="de">{t.languageGerman}</option>
</select>
</div>

<div className="form-group">
<label>{t.storageId}</label>
<div style={{ display: 'flex', gap: '8px', marginBottom: '8px' }}>
<input
type="text"
value={localSettings?.id || ""}
onChange={(e) => setLocalSettings(settings => ({ ...settings, id: e.target.value }))}
placeholder="Optional"
style={{ flex: 1 }}
/>
<button
onClick={() => setLocalSettings(settings => ({ ...settings, id: generateRandomId() }))}
className="secondary-button"
type="button"
style={{ padding: '8px 12px', display: 'flex', alignItems: 'center', gap: '4px' }}
title={t.generateRandomId}
>
<RefreshCw size={16} />
</button>
</div>
<p style={{ fontSize: '0.875rem', color: '#666', margin: 0, fontStyle: 'italic' }}>
ℹ️ {t.storageIdHint}
</p>
</div>

<div className="form-group">
<ColorSelector
label={t.backgroundColor}
Expand Down
18 changes: 18 additions & 0 deletions packages/learningmap/src/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,21 @@ export const parseRoadmapData = (
edges: (userRoadmapData as any).edges || defaultRoadmapData.edges,
};
};

/**
* Generates a random ID similar to the format used by json.openpatch.org
* Example format: iIhK7sHqL-EMWp9OM5_-q
*/
export const generateRandomId = (): string => {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
const segments = [9, 11, 1]; // Generate segments of 9, 11, and 1 characters
return segments
.map(length => {
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
})
.join('-');
};
15 changes: 15 additions & 0 deletions packages/learningmap/src/translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,11 @@ export interface Translations {
viewportZoom: string;
useCurrentViewport: string;

// ID settings
storageId: string;
generateRandomId: string;
storageIdHint: string;

// Welcome message
welcomeTitle: string;
welcomeSubtitle: string;
Expand Down Expand Up @@ -398,6 +403,11 @@ const en: Translations = {
viewportZoom: "Zoom",
useCurrentViewport: "Use Current Viewport",

// ID settings
storageId: "ID",
generateRandomId: "Generate Random ID",
storageIdHint: "Learning maps with the same ID will share the same state when a student is working on it.",

// Welcome message
welcomeTitle: "Learningmap",
welcomeSubtitle: "All data is stored locally in your browser",
Expand Down Expand Up @@ -607,6 +617,11 @@ const de: Translations = {
viewportZoom: "Zoom",
useCurrentViewport: "Aktuellen Ansichtsbereich verwenden",

// ID settings
storageId: "ID",
generateRandomId: "Zufällige ID generieren",
storageIdHint: "Lernkarten mit der gleichen ID teilen sich den gleichen Zustand, wenn ein Schüler daran arbeitet.",

// Welcome message
welcomeTitle: "Learningmap",
welcomeSubtitle: "Alle Daten werden lokal in Ihrem Browser gespeichert",
Expand Down
1 change: 1 addition & 0 deletions packages/learningmap/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export interface BackgroundConfig {

export interface Settings {
title?: string;
id?: string;
background?: BackgroundConfig;
language?: string;
viewport?: {
Expand Down
99 changes: 88 additions & 11 deletions platforms/web/src/Learn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,44 @@ function Learn() {
useEffect(() => {
if (!jsonId) return;

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

if (existingMap) {
// Already have the data, just update last accessed
addLearningMap(jsonId, existingMap.roadmapData);
// Check if the roadmap has a storage ID and handle potential conflicts
const storageId = existingMap.roadmapData.settings?.id;
if (storageId && storageId !== jsonId) {
// There's a custom storage ID - check if a different map exists with that ID
const mapWithStorageId = getLearningMap(storageId);
if (mapWithStorageId && mapWithStorageId.roadmapData !== existingMap.roadmapData) {
// Ask user if they want to replace
const shouldReplace = window.confirm(
`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.`
);
if (shouldReplace) {
// Keep the existing state but update the roadmap data
const existingState = mapWithStorageId.state;
addLearningMap(storageId, existingMap.roadmapData);
if (existingState) {
updateState(storageId, existingState);
}
// Remove the old jsonId entry to avoid duplicates
if (jsonId !== storageId) {
removeLearningMap(jsonId);
}
}
} else {
// No conflict, just update
addLearningMap(storageId, existingMap.roadmapData);
// Remove the old jsonId entry if different
if (jsonId !== storageId) {
removeLearningMap(jsonId);
}
}
} else {
// No custom storage ID, just use jsonId
addLearningMap(jsonId, existingMap.roadmapData);
}
return;
}

Expand All @@ -57,26 +90,54 @@ function Learn() {
.then((r) => r.text())
.then((text) => {
const json = JSON.parse(text);
addLearningMap(jsonId, json);
const storageId = json.settings?.id;

if (storageId && storageId !== jsonId) {
// Check if a map with this storage ID already exists
const existingMapWithStorageId = getLearningMap(storageId);
if (existingMapWithStorageId) {
// Ask user if they want to replace
const shouldReplace = window.confirm(
`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.`
);
if (shouldReplace) {
// Keep the existing state but update the roadmap data
const existingState = existingMapWithStorageId.state;
addLearningMap(storageId, json);
if (existingState) {
updateState(storageId, existingState);
}
} else {
// User chose not to replace, just use jsonId as key
addLearningMap(jsonId, json);
}
} else {
// No conflict, use storage ID
addLearningMap(storageId, json);
}
} else {
// No custom storage ID, use jsonId
addLearningMap(jsonId, json);
}
setLoading(false);
})
.catch(() => {
setError('Failed to load learning map. Please check the URL and try again.');
setLoading(false);
});
}, [jsonId, getLearningMap, addLearningMap]);
}, [jsonId, getLearningMap, addLearningMap, updateState, removeLearningMap]);

const handleStateChange = useCallback((state: RoadmapState) => {
if (jsonId) {
const handleStateChange = useCallback((state: RoadmapState, key: string) => {
if (key) {
// Debounce state updates to prevent infinite loops
if (updateTimeoutRef.current) {
clearTimeout(updateTimeoutRef.current);
}
updateTimeoutRef.current = setTimeout(() => {
updateState(jsonId, state);
updateState(key, state);
}, 500);
}
}, [jsonId, updateState]);
}, [updateState]);

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

// If there's a json ID, show the learning map
if (jsonId) {
const learningMap = getLearningMap(jsonId);
// First try to get by storage ID if present, otherwise use jsonId
let learningMap = getLearningMap(jsonId);

// If not found by jsonId, check if there's a storage ID in any map
if (!learningMap) {
const allMaps = getAllLearningMaps();
const mapWithJsonId = allMaps.find(m => m.id === jsonId);
if (mapWithJsonId) {
learningMap = mapWithJsonId;
}
}

// Try to determine the storage key (either custom id or jsonId)
const storageKey = learningMap?.roadmapData?.settings?.id || jsonId;
if (storageKey !== jsonId) {
learningMap = getLearningMap(storageKey);
}

if (loading) {
return (
Expand Down Expand Up @@ -143,10 +220,10 @@ function Learn() {
</div>
</div>
<LearningMap
key={jsonId}
key={storageKey}
roadmapData={learningMap.roadmapData}
initialState={learningMap.state}
onChange={handleStateChange}
onChange={(state) => handleStateChange(state, storageKey)}
/>
</div>
);
Expand Down
Loading