Compare commits

..

4 Commits

Author SHA1 Message Date
Zimeng Xiong 602350d2e6 Merge pull request #9 from ZimengXiong/pre-release
v0.1.6 Add export button, store library in database
2025-11-24 15:01:02 -08:00
Zimeng Xiong f20d48fea2 fix migration issues 2025-11-24 14:53:17 -08:00
Zimeng Xiong c53dc010de Merge branch '8-export-drawing' into pre-release 2025-11-24 14:43:58 -08:00
Zimeng Xiong fa73708d97 allow importing of libraries via URL, update db schema 2025-11-24 14:32:48 -08:00
18 changed files with 1310 additions and 19 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.5 0.1.6
+6
View File
@@ -5,6 +5,12 @@ set -e
if [ ! -f "/app/prisma/schema.prisma" ]; then if [ ! -f "/app/prisma/schema.prisma" ]; then
echo "Mount is empty. Hydrating /app/prisma..." echo "Mount is empty. Hydrating /app/prisma..."
cp -R /app/prisma_template/. /app/prisma/ cp -R /app/prisma_template/. /app/prisma/
else
# Volume exists but may be missing new migrations from an upgrade
# Always sync schema and migrations from template to ensure upgrades work
echo "Syncing schema and migrations from template..."
cp /app/prisma_template/schema.prisma /app/prisma/schema.prisma
cp -R /app/prisma_template/migrations/. /app/prisma/migrations/
fi fi
# 2. Fix permissions unconditionally (Running as root) # 2. Fix permissions unconditionally (Running as root)
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "backend", "name": "backend",
"version": "0.1.5", "version": "0.1.6",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
@@ -0,0 +1,7 @@
-- CreateTable
CREATE TABLE "Library" (
"id" TEXT NOT NULL PRIMARY KEY DEFAULT 'default',
"items" TEXT NOT NULL DEFAULT '[]',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
Binary file not shown.
Binary file not shown.
+7
View File
@@ -33,3 +33,10 @@ model Drawing {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model Library {
id String @id @default("default") // Singleton pattern - use "default" ID
items String @default("[]") // Stored as JSON string array of library items
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
File diff suppressed because one or more lines are too long
@@ -139,6 +139,13 @@ exports.Prisma.DrawingScalarFieldEnum = {
updatedAt: 'updatedAt' updatedAt: 'updatedAt'
}; };
exports.Prisma.LibraryScalarFieldEnum = {
id: 'id',
items: 'items',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.SortOrder = { exports.Prisma.SortOrder = {
asc: 'asc', asc: 'asc',
desc: 'desc' desc: 'desc'
@@ -152,7 +159,8 @@ exports.Prisma.NullsOrder = {
exports.Prisma.ModelName = { exports.Prisma.ModelName = {
Collection: 'Collection', Collection: 'Collection',
Drawing: 'Drawing' Drawing: 'Drawing',
Library: 'Library'
}; };
/** /**
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "prisma-client-6afe3d9baa793154c8d01c79f8418d91423e5ccaec794547bf848a451459cf53", "name": "prisma-client-8eed3ee5004eaec649fc60571177778f25acb4a3cdc2c238bbb8e70dd820d0ff",
"main": "index.js", "main": "index.js",
"types": "index.d.ts", "types": "index.d.ts",
"browser": "index-browser.js", "browser": "index-browser.js",
@@ -33,3 +33,10 @@ model Drawing {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model Library {
id String @id @default("default") // Singleton pattern - use "default" ID
items String @default("[]") // Stored as JSON string array of library items
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
+9 -1
View File
@@ -139,6 +139,13 @@ exports.Prisma.DrawingScalarFieldEnum = {
updatedAt: 'updatedAt' updatedAt: 'updatedAt'
}; };
exports.Prisma.LibraryScalarFieldEnum = {
id: 'id',
items: 'items',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.SortOrder = { exports.Prisma.SortOrder = {
asc: 'asc', asc: 'asc',
desc: 'desc' desc: 'desc'
@@ -152,7 +159,8 @@ exports.Prisma.NullsOrder = {
exports.Prisma.ModelName = { exports.Prisma.ModelName = {
Collection: 'Collection', Collection: 'Collection',
Drawing: 'Drawing' Drawing: 'Drawing',
Library: 'Library'
}; };
/** /**
+52
View File
@@ -801,6 +801,58 @@ app.delete("/collections/:id", async (req, res) => {
} }
}); });
// --- Library ---
// GET /library - Fetch stored library items
app.get("/library", async (req, res) => {
try {
const library = await prisma.library.findUnique({
where: { id: "default" },
});
if (!library) {
// Return empty array if no library exists yet
return res.json({ items: [] });
}
res.json({
items: JSON.parse(library.items),
});
} catch (error) {
console.error("Failed to fetch library:", error);
res.status(500).json({ error: "Failed to fetch library" });
}
});
// PUT /library - Update/create library items
app.put("/library", async (req, res) => {
try {
const { items } = req.body;
if (!Array.isArray(items)) {
return res.status(400).json({ error: "Items must be an array" });
}
const library = await prisma.library.upsert({
where: { id: "default" },
update: {
items: JSON.stringify(items),
},
create: {
id: "default",
items: JSON.stringify(items),
},
});
res.json({
items: JSON.parse(library.items),
});
} catch (error) {
console.error("Failed to update library:", error);
res.status(500).json({ error: "Failed to update library" });
}
});
// --- Export/Import Endpoints --- // --- Export/Import Endpoints ---
// GET /export - Export SQLite database (supports .sqlite and .db extensions) // GET /export - Export SQLite database (supports .sqlite and .db extensions)
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "0.1.5", "version": "0.1.6",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+12
View File
@@ -84,3 +84,15 @@ export const deleteCollection = async (id: string) => {
const response = await api.delete<{ success: true }>(`/collections/${id}`); const response = await api.delete<{ success: true }>(`/collections/${id}`);
return response.data; return response.data;
}; };
// --- Library ---
export const getLibrary = async () => {
const response = await api.get<{ items: any[] }>("/library");
return response.data.items;
};
export const updateLibrary = async (items: any[]) => {
const response = await api.put<{ items: any[] }>("/library", { items });
return response.data.items;
};
+90 -1
View File
@@ -242,6 +242,59 @@ export const Editor: React.FC = () => {
setIsReady(true); setIsReady(true);
}, []); }, []);
// Handle #addLibrary URL hash parameter for importing libraries from links
useEffect(() => {
if (!isReady || !excalidrawAPI.current) return;
const hash = window.location.hash;
if (!hash.includes('addLibrary=')) return;
const params = new URLSearchParams(hash.slice(1)); // Remove the leading #
const libraryUrl = params.get('addLibrary');
if (!libraryUrl) return;
const importLibraryFromUrl = async () => {
try {
console.log('[Editor] Importing library from URL:', libraryUrl);
toast.loading('Importing library...', { id: 'library-import' });
const response = await fetch(libraryUrl);
if (!response.ok) {
throw new Error(`Failed to fetch library: ${response.statusText}`);
}
const blob = await response.blob();
// Use Excalidraw's updateLibrary API with proper settings:
// - defaultStatus: "published" puts items in "Excalidraw library" section
// - merge: true preserves existing library items
// - openLibraryMenu: true shows the library sidebar after import
await excalidrawAPI.current.updateLibrary({
libraryItems: blob,
merge: true,
defaultStatus: "published",
openLibraryMenu: true,
});
// Get the updated library items and persist to server
const updatedItems = excalidrawAPI.current.getAppState().libraryItems || [];
await api.updateLibrary([...updatedItems]);
toast.success('Library imported successfully', { id: 'library-import' });
console.log('[Editor] Library import complete');
// Clear the hash to prevent re-importing on refresh
window.history.replaceState(null, '', window.location.pathname + window.location.search);
} catch (err) {
console.error('[Editor] Failed to import library:', err);
toast.error('Failed to import library', { id: 'library-import' });
}
};
importLibraryFromUrl();
}, [isReady]);
const buildEmptyScene = useCallback(() => ({ const buildEmptyScene = useCallback(() => ({
elements: [], elements: [],
appState: { appState: {
@@ -260,6 +313,7 @@ export const Editor: React.FC = () => {
// ------------------------------------------------------------------ // ------------------------------------------------------------------
const saveDataRef = useRef<((elements: readonly any[], appState: any) => Promise<void>) | null>(null); const saveDataRef = useRef<((elements: readonly any[], appState: any) => Promise<void>) | null>(null);
const savePreviewRef = useRef<((elements: readonly any[], appState: any, files: any) => Promise<void>) | null>(null); const savePreviewRef = useRef<((elements: readonly any[], appState: any, files: any) => Promise<void>) | null>(null);
const saveLibraryRef = useRef<((items: any[]) => Promise<void>) | null>(null);
// Update the ref on every render to ensure it has access to the latest props/state // Update the ref on every render to ensure it has access to the latest props/state
saveDataRef.current = async (elements: readonly any[], appState: any) => { saveDataRef.current = async (elements: readonly any[], appState: any) => {
@@ -327,6 +381,17 @@ export const Editor: React.FC = () => {
} }
}; };
saveLibraryRef.current = async (items: any[]) => {
try {
console.log("[Editor] Saving library", { itemCount: items.length });
await api.updateLibrary(items);
console.log("[Editor] Library save complete");
} catch (err) {
console.error('Failed to save library', err);
toast.error("Failed to save library");
}
};
// Create the debounced function ONLY ONCE. // Create the debounced function ONLY ONCE.
// It simply calls whatever is currently in saveDataRef.current // It simply calls whatever is currently in saveDataRef.current
const debouncedSave = useCallback( const debouncedSave = useCallback(
@@ -347,6 +412,15 @@ export const Editor: React.FC = () => {
[] []
); );
const debouncedSaveLibrary = useCallback(
debounce((items: any[]) => {
if (saveLibraryRef.current) {
saveLibraryRef.current(items);
}
}, 1000),
[]
);
const broadcastChanges = useCallback( const broadcastChanges = useCallback(
throttle((elements: readonly any[]) => { throttle((elements: readonly any[]) => {
if (!socketRef.current || !id) return; if (!socketRef.current || !id) return;
@@ -392,7 +466,14 @@ export const Editor: React.FC = () => {
return; return;
} }
try { try {
const data = await api.getDrawing(id); // Fetch drawing and library in parallel
const [data, libraryItems] = await Promise.all([
api.getDrawing(id),
api.getLibrary().catch((err) => {
console.warn('Failed to load library, using empty:', err);
return [];
})
]);
setDrawingName(data.name); setDrawingName(data.name);
// Use elements directly without converting - they're already normalized during import // Use elements directly without converting - they're already normalized during import
@@ -418,6 +499,7 @@ export const Editor: React.FC = () => {
appState: hydratedAppState, appState: hydratedAppState,
files, files,
scrollToContent: true, scrollToContent: true,
libraryItems,
}); });
} catch (err) { } catch (err) {
console.error('Failed to load drawing', err); console.error('Failed to load drawing', err);
@@ -538,6 +620,12 @@ export const Editor: React.FC = () => {
} }
}; };
// Handle library changes and persist to server
const handleLibraryChange = useCallback((items: readonly any[]) => {
console.log("[Editor] Library changed", { itemCount: items.length });
debouncedSaveLibrary([...items]);
}, [debouncedSaveLibrary]);
// Disable native Excalidraw save dialogs // Disable native Excalidraw save dialogs
// UIOptions is now defined outside the component // UIOptions is now defined outside the component
@@ -659,6 +747,7 @@ export const Editor: React.FC = () => {
initialData={initialData} initialData={initialData}
onChange={handleCanvasChange} onChange={handleCanvasChange}
onPointerUpdate={onPointerUpdate} onPointerUpdate={onPointerUpdate}
onLibraryChange={handleLibraryChange}
excalidrawAPI={setExcalidrawAPI} excalidrawAPI={setExcalidrawAPI}
UIOptions={UIOptions} UIOptions={UIOptions}
/> />