add README, sidebar adjustability
@@ -0,0 +1,138 @@
|
||||
<img src="logoExcaliDash.png" alt="[Image Description]" width="80" height="88">
|
||||
|
||||
# ExcaliDash
|
||||
|
||||
A beautiful, self hosted dashboard and organizer for [Excalidraw](https://github.com/excalidraw/excalidraw) with live collaboration.
|
||||

|
||||
|
||||
[Features](#Features)
|
||||
|
||||
[Installation](#Installation)
|
||||
|
||||
[Development](#Development)
|
||||
|
||||
[Credits](#Credits)
|
||||
|
||||
# Features
|
||||
|
||||
## Persistent storage for all your drawings
|
||||
|
||||

|
||||
|
||||
## Real time collaboration
|
||||
|
||||

|
||||
|
||||
## Search your drawings
|
||||
|
||||

|
||||
|
||||
## Drag and drop drawings into collections
|
||||
|
||||

|
||||
|
||||
## Export/import your drawings and databases for backup
|
||||
|
||||

|
||||
|
||||
# Installation
|
||||
|
||||
## Dockerhub (recommended)
|
||||
|
||||
[Install Docker](https://docs.docker.com/desktop/)
|
||||
|
||||
```bash
|
||||
# Download docker-compose.prod.yml
|
||||
curl -OL https://raw.githubusercontent.com/ZimengXiong/ExcaliDash/refs/heads/main/docker-compose.prod.yml
|
||||
|
||||
# Pull images
|
||||
docker compose -f docker-compose.prod.yml pull
|
||||
|
||||
# Run container
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
## Docker build
|
||||
|
||||
[Install Docker](https://docs.docker.com/desktop/)
|
||||
|
||||
```bash
|
||||
# Clone the repository (recommended)
|
||||
git clone git@github.com:ZimengXiong/ExcaliDash.git
|
||||
|
||||
# or, clone with HTTPS
|
||||
# git clone https://github.com/ZimengXiong/ExcaliDash.git
|
||||
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
|
||||
# Access the frontend at localhost:6767
|
||||
```
|
||||
|
||||
# Development
|
||||
|
||||
## Clone the repository
|
||||
|
||||
```bash
|
||||
# Clone the repository (recommended)
|
||||
git clone git@github.com:ZimengXiong/ExcaliDash.git
|
||||
|
||||
# or, clone with HTTPS
|
||||
# git clone https://github.com/ZimengXiong/ExcaliDash.git
|
||||
```
|
||||
|
||||
## Frontend
|
||||
|
||||
```bash
|
||||
cd ExcaliDash/frontend
|
||||
npm install
|
||||
|
||||
# Copy environment file and customize if needed
|
||||
cp .env.example .env
|
||||
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Backend
|
||||
|
||||
```bash
|
||||
cd ExcaliDash/backend
|
||||
npm install
|
||||
|
||||
# Copy environment file and customize if needed
|
||||
cp .env.example .env
|
||||
|
||||
# Generate Prisma client and setup database
|
||||
npx prisma generate
|
||||
npx prisma db push
|
||||
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
ExcaliDash/
|
||||
├── backend/ # Node.js + Express + Prisma
|
||||
│ ├── src/
|
||||
│ │ └── index.ts # Main server file
|
||||
│ ├── prisma/
|
||||
│ │ ├── schema.prisma # Database schema
|
||||
│ │ └── dev.db # SQLite database
|
||||
│ └── package.json
|
||||
├── frontend/ # React + TypeScript + Vite
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # React components
|
||||
│ │ ├── pages/ # Page components
|
||||
│ │ ├── hooks/ # Custom hooks
|
||||
│ │ └── api/ # API client
|
||||
│ └── package.json
|
||||
└── README.md
|
||||
```
|
||||
|
||||
# Credits
|
||||
|
||||
- Example designs from:
|
||||
- https://github.com/Prakash-sa/system-design-ultimatum/tree/main
|
||||
- https://github.com/kitsteam/excalidraw-examples/tree/main
|
||||
- [Excalidraw](https://github.com/ZimengXiong/ExcaliDash)
|
||||
|
||||
|
After Width: | Height: | Size: 9.8 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import type { Collection } from '../types';
|
||||
|
||||
@@ -23,10 +23,51 @@ export const Layout: React.FC<LayoutProps> = ({
|
||||
onDeleteCollection,
|
||||
onDrop
|
||||
}) => {
|
||||
const [sidebarWidth, setSidebarWidth] = useState(260);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||
const startXRef = useRef(0);
|
||||
const startWidthRef = useRef(0);
|
||||
|
||||
// Handle mouse down on resize handle
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsResizing(true);
|
||||
startXRef.current = e.clientX;
|
||||
startWidthRef.current = sidebarWidth;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const diff = e.clientX - startXRef.current;
|
||||
const newWidth = Math.max(200, Math.min(600, startWidthRef.current + diff));
|
||||
setSidebarWidth(newWidth);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsResizing(false);
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
// Cleanup event listeners on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', () => {});
|
||||
document.removeEventListener('mouseup', () => {});
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-screen w-full bg-[#F3F4F6] dark:bg-neutral-950 p-4 transition-colors duration-200 overflow-hidden">
|
||||
<div className="flex gap-4 items-start h-full">
|
||||
<aside className="flex-shrink-0 w-[260px] h-full bg-white dark:bg-neutral-900 rounded-2xl border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] overflow-hidden z-20 transition-colors duration-200">
|
||||
<aside
|
||||
ref={sidebarRef}
|
||||
className="flex-shrink-0 h-full bg-white dark:bg-neutral-900 rounded-2xl border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] overflow-hidden z-20 transition-colors duration-200 relative"
|
||||
style={{ width: `${sidebarWidth}px` }}
|
||||
>
|
||||
<Sidebar
|
||||
collections={collections}
|
||||
selectedCollectionId={selectedCollectionId}
|
||||
@@ -36,6 +77,15 @@ export const Layout: React.FC<LayoutProps> = ({
|
||||
onDeleteCollection={onDeleteCollection}
|
||||
onDrop={onDrop}
|
||||
/>
|
||||
|
||||
{/* Resize Handle */}
|
||||
<div
|
||||
className={`absolute top-0 right-0 w-1.5 h-full cursor-col-resize bg-transparent hover:bg-indigo-400 dark:hover:bg-indigo-500 transition-all duration-150 ${isResizing ? 'bg-indigo-500 dark:bg-indigo-400 w-2' : ''} group`}
|
||||
onMouseDown={handleMouseDown}
|
||||
title="Drag to resize sidebar"
|
||||
>
|
||||
<div className="absolute inset-y-0 -left-0.5 -right-0.5 bg-transparent hover:bg-indigo-500/10 dark:hover:bg-indigo-400/10 transition-colors duration-150" />
|
||||
</div>
|
||||
</aside>
|
||||
<main className="flex-1 bg-white/40 dark:bg-neutral-900/40 backdrop-blur-sm rounded-2xl border border-white/50 dark:border-neutral-800/50 shadow-sm h-full transition-colors duration-200 overflow-y-auto">
|
||||
<div className="max-w-[1600px] mx-auto p-6 lg:p-8 min-h-full">
|
||||
|
||||
@@ -52,7 +52,7 @@ const SidebarItem: React.FC<SidebarItemProps> = ({
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative group/item px-3">
|
||||
<div className="relative group/item pl-3 pr-2">
|
||||
{isEditing ? (
|
||||
<form onSubmit={onEditSubmit} className="py-1">
|
||||
<input
|
||||
@@ -87,7 +87,7 @@ const SidebarItem: React.FC<SidebarItemProps> = ({
|
||||
setIsDragOver(false);
|
||||
onDrop?.(e, id);
|
||||
}}
|
||||
className={clsx(
|
||||
className={clsx(
|
||||
"w-full flex items-center gap-3 px-3 py-2.5 text-sm font-bold rounded-lg transition-all duration-200 border-2 group cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:focus-visible:ring-2 dark:focus-visible:ring-neutral-500",
|
||||
isActive || isDragOver
|
||||
? "bg-indigo-50 dark:bg-neutral-800 text-indigo-900 dark:text-neutral-200 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] -translate-y-0.5"
|
||||
@@ -97,9 +97,9 @@ const SidebarItem: React.FC<SidebarItemProps> = ({
|
||||
<span className={clsx("transition-colors duration-200", isActive || isDragOver ? "text-indigo-900 dark:text-neutral-200" : "text-slate-400 dark:text-neutral-500 group-hover:text-slate-900 dark:group-hover:text-neutral-200")}>
|
||||
{icon}
|
||||
</span>
|
||||
<span className="truncate flex-1 text-left font-bold">{label}</span>
|
||||
<span className="min-w-0 flex-1 text-left font-bold">{label}</span>
|
||||
{extraAction && (
|
||||
<div className="opacity-0 group-hover/item:opacity-100 transition-all duration-200 flex items-center gap-1 translate-x-2 group-hover/item:translate-x-0">
|
||||
<div className="opacity-0 group-hover/item:opacity-100 transition-all duration-200 flex items-center gap-1 flex-shrink-0">
|
||||
{extraAction}
|
||||
</div>
|
||||
)}
|
||||
@@ -166,7 +166,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-[260px] flex flex-col h-full bg-transparent">
|
||||
<div className="w-full flex flex-col h-full bg-transparent">
|
||||
<div className="p-5 pb-2">
|
||||
<h1 className="text-2xl text-slate-900 dark:text-white flex items-center gap-3 tracking-tight" style={{ fontFamily: 'Excalifont' }}>
|
||||
<Logo className="w-10 h-10" />
|
||||
@@ -182,7 +182,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
<div className="px-6 pb-2 text-[11px] font-bold text-slate-400 dark:text-neutral-500 uppercase tracking-wider">
|
||||
Library
|
||||
</div>
|
||||
<div className="px-3">
|
||||
<div className="pl-3 pr-2">
|
||||
<button
|
||||
onClick={() => onSelectCollection(undefined)}
|
||||
className={clsx(
|
||||
@@ -193,7 +193,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
)}
|
||||
>
|
||||
<LayoutGrid size={18} className={clsx(selectedCollectionId === undefined ? "text-indigo-900 dark:text-neutral-200" : "text-slate-400 dark:text-neutral-500")} />
|
||||
All Drawings
|
||||
<span className="min-w-0 flex-1 text-left">All Drawings</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -252,35 +252,12 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
onEditSubmit={handleEditSubmit}
|
||||
onEditBlur={() => setEditingId(null)}
|
||||
onDrop={onDrop}
|
||||
extraAction={
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingId(collection.id);
|
||||
setEditName(collection.name);
|
||||
}}
|
||||
className="p-1.5 text-slate-400 dark:text-neutral-500 hover:text-indigo-600 dark:hover:text-neutral-200 hover:bg-indigo-50 dark:hover:bg-neutral-800 rounded-md transition-colors"
|
||||
>
|
||||
<Edit2 size={12} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCollectionToDelete(collection.id);
|
||||
}}
|
||||
className="p-1.5 text-slate-400 dark:text-neutral-500 hover:text-rose-600 dark:hover:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-900/30 rounded-md transition-colors"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t border-slate-200/50 dark:border-slate-700/50 space-y-2">
|
||||
<div className="px-3 pt-4 pb-4 border-t border-slate-200/50 dark:border-slate-700/50 space-y-2">
|
||||
<button
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -296,14 +273,14 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
navigate('/collections?id=trash');
|
||||
}}
|
||||
className={clsx(
|
||||
"w-full flex items-center gap-3 px-3 py-2 text-sm font-bold rounded-xl transition-all duration-200 border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] mb-4",
|
||||
"w-full flex items-center gap-3 px-3 py-2 text-sm font-bold rounded-xl transition-all duration-200 border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)]",
|
||||
selectedCollectionId === 'trash' || isTrashDragOver
|
||||
? "bg-rose-50 dark:bg-rose-900/30 text-rose-900 dark:text-rose-300 -translate-y-0.5"
|
||||
: "bg-white dark:bg-neutral-900 text-slate-900 dark:text-neutral-200 hover:bg-rose-50 dark:hover:bg-rose-900/30 hover:text-rose-900 dark:hover:text-rose-300 hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5"
|
||||
)}
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
Trash
|
||||
<span className="min-w-0 flex-1 text-left">Trash</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -316,7 +293,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
)}
|
||||
>
|
||||
<SettingsIcon size={18} />
|
||||
Settings
|
||||
<span className="min-w-0 flex-1 text-left">Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 903 KiB |