first commit
This commit is contained in:
0
src/App.css
Normal file
0
src/App.css
Normal file
29
src/App.tsx
Normal file
29
src/App.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { useEffect } from 'react'
|
||||
import { loadSpace, getSpaceFromUrl } from './store'
|
||||
import { Space } from './components/Space'
|
||||
import { Navigation } from './components/Navigation'
|
||||
import './App.css'
|
||||
|
||||
function App() {
|
||||
useEffect(() => {
|
||||
const initialSpace = getSpaceFromUrl()
|
||||
loadSpace(initialSpace)
|
||||
|
||||
const handleHashChange = () => {
|
||||
const spaceId = getSpaceFromUrl()
|
||||
loadSpace(spaceId)
|
||||
}
|
||||
|
||||
window.addEventListener('hashchange', handleHashChange)
|
||||
return () => window.removeEventListener('hashchange', handleHashChange)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Space />
|
||||
<Navigation />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
6
src/components/Navigation.tsx
Normal file
6
src/components/Navigation.tsx
Normal file
@ -0,0 +1,6 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { navigationHistory, goBack } from '../store'
|
||||
|
||||
export function Navigation() {
|
||||
return null
|
||||
}
|
||||
169
src/components/NavigationConsole.tsx
Normal file
169
src/components/NavigationConsole.tsx
Normal file
@ -0,0 +1,169 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { navigateToSpace } from '../store'
|
||||
import { db } from '../db'
|
||||
|
||||
interface NavigationConsoleProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function NavigationConsole({ isOpen, onClose }: NavigationConsoleProps) {
|
||||
const [input, setInput] = useState('')
|
||||
const [existingSpaces, setExistingSpaces] = useState<string[]>([])
|
||||
const [filteredSpaces, setFilteredSpaces] = useState<string[]>([])
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const loadSpaces = async () => {
|
||||
const spaces = await db.getAllSpaceIds()
|
||||
setExistingSpaces(spaces)
|
||||
}
|
||||
if (isOpen) {
|
||||
loadSpaces()
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (input.trim() === '') {
|
||||
setFilteredSpaces(existingSpaces.slice(0, 8))
|
||||
} else {
|
||||
const filtered = existingSpaces.filter(space =>
|
||||
space.toLowerCase().includes(input.toLowerCase())
|
||||
).slice(0, 8)
|
||||
|
||||
if (filtered.length === 0 || !filtered.includes(input)) {
|
||||
filtered.unshift(input)
|
||||
}
|
||||
|
||||
setFilteredSpaces(filtered)
|
||||
}
|
||||
setSelectedIndex(0)
|
||||
}, [input, existingSpaces])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
const targetSpace = filteredSpaces[selectedIndex] || input
|
||||
if (targetSpace.trim()) {
|
||||
navigateToSpace(sanitizeSpaceId(targetSpace.trim()))
|
||||
onClose()
|
||||
}
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex(prev => (prev + 1) % filteredSpaces.length)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex(prev => (prev - 1 + filteredSpaces.length) % filteredSpaces.length)
|
||||
}
|
||||
}
|
||||
|
||||
const sanitizeSpaceId = (input: string): string => {
|
||||
if (input.startsWith('http://') || input.startsWith('https://')) {
|
||||
try {
|
||||
const url = new URL(input)
|
||||
const path = url.pathname.slice(1) || url.hostname
|
||||
return path.toLowerCase().replace(/[^a-z0-9]/g, '-')
|
||||
} catch {
|
||||
return input.toLowerCase().replace(/[^a-z0-9]/g, '-')
|
||||
}
|
||||
}
|
||||
return input.toLowerCase().replace(/[^a-z0-9]/g, '-')
|
||||
}
|
||||
|
||||
const handleItemClick = (space: string) => {
|
||||
navigateToSpace(sanitizeSpaceId(space))
|
||||
onClose()
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.5)',
|
||||
zIndex: 2000
|
||||
}}
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: '20%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: '4px',
|
||||
width: '400px',
|
||||
zIndex: 2001,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '8px' }}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Nom de l'espace ou URL..."
|
||||
style={{
|
||||
width: '100%',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
color: 'white',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'monospace'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{filteredSpaces.length > 0 && (
|
||||
<div style={{ borderTop: '1px solid #333', maxHeight: '200px', overflowY: 'auto' }}>
|
||||
{filteredSpaces.map((space, index) => {
|
||||
const isExisting = existingSpaces.includes(space)
|
||||
const isSelected = index === selectedIndex
|
||||
const isUrl = space.startsWith('http://') || space.startsWith('https://')
|
||||
|
||||
return (
|
||||
<div
|
||||
key={space}
|
||||
onClick={() => handleItemClick(space)}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
background: isSelected ? '#333' : 'transparent',
|
||||
color: isExisting ? '#4CAF50' : (isUrl ? '#2196F3' : '#FFA726'),
|
||||
borderLeft: isSelected ? '3px solid white' : '3px solid transparent'
|
||||
}}
|
||||
>
|
||||
{space}
|
||||
{!isExisting && (
|
||||
<span style={{ color: '#666', marginLeft: '8px', fontSize: '12px' }}>
|
||||
(nouveau)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
228
src/components/Note.tsx
Normal file
228
src/components/Note.tsx
Normal file
@ -0,0 +1,228 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { updateNote, deleteNote, editingNoteId, navigateToSpace, selectedNoteIds, selectNote, moveSelectedNotesLocal, saveSelectedNotesToDB, existingSpaceIds } from '../store'
|
||||
import { useStore } from '@nanostores/react'
|
||||
|
||||
type NoteType = {
|
||||
id: string
|
||||
x: number
|
||||
y: number
|
||||
content: string
|
||||
}
|
||||
|
||||
interface NoteProps {
|
||||
note: NoteType
|
||||
}
|
||||
|
||||
export function Note({ note }: NoteProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [content, setContent] = useState(note.content)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 })
|
||||
const [hasDragged, setHasDragged] = useState(false)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const $editingNoteId = useStore(editingNoteId)
|
||||
const $selectedNoteIds = useStore(selectedNoteIds)
|
||||
const $existingSpaceIds = useStore(existingSpaceIds)
|
||||
|
||||
const isSelected = $selectedNoteIds.includes(note.id)
|
||||
|
||||
// Check if note content is a single word that matches an existing space
|
||||
const isSpaceLink = () => {
|
||||
if (!note.content) return false
|
||||
const words = note.content.trim().split(/\s+/)
|
||||
if (words.length !== 1) return false
|
||||
const spaceId = words[0].toLowerCase().replace(/[^a-z0-9]/g, '-')
|
||||
return $existingSpaceIds.includes(spaceId)
|
||||
}
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if ($editingNoteId === note.id) {
|
||||
setIsEditing(true)
|
||||
editingNoteId.set(null)
|
||||
}
|
||||
}, [$editingNoteId, note.id])
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && textareaRef.current) {
|
||||
textareaRef.current.focus()
|
||||
textareaRef.current.setSelectionRange(content.length, content.length)
|
||||
}
|
||||
}, [isEditing, content.length])
|
||||
|
||||
const handleSave = async () => {
|
||||
if (content.trim() === '') {
|
||||
await deleteNote(note.id)
|
||||
} else {
|
||||
await updateNote(note.id, { content: content.trim() })
|
||||
}
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSave()
|
||||
} else if (e.key === 'Escape') {
|
||||
setContent(note.content)
|
||||
setIsEditing(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Selection
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (isEditing) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
// If we just finished dragging, don't change selection
|
||||
if (hasDragged) {
|
||||
setHasDragged(false)
|
||||
return
|
||||
}
|
||||
|
||||
// If note is already selected and no shift key, don't change selection
|
||||
// (this preserves multi-selection during drag start)
|
||||
if (isSelected && !e.shiftKey) {
|
||||
return
|
||||
}
|
||||
|
||||
selectNote(note.id, e.shiftKey)
|
||||
}
|
||||
|
||||
// Navigation
|
||||
const handleDoubleClick = (e: React.MouseEvent) => {
|
||||
if (isEditing) return
|
||||
e.stopPropagation()
|
||||
|
||||
if (note.content.trim()) {
|
||||
const words = note.content.trim().split(/\s+/)
|
||||
if (words.length > 0) {
|
||||
const spaceId = words[0].toLowerCase().replace(/[^a-z0-9]/g, '-')
|
||||
navigateToSpace(spaceId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drag and drop
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (isEditing) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
// If the note isn't selected, add it to selection before starting drag
|
||||
if (!isSelected) {
|
||||
selectNote(note.id, e.shiftKey)
|
||||
}
|
||||
|
||||
setIsDragging(true)
|
||||
setDragStart({ x: e.clientX, y: e.clientY })
|
||||
setHasDragged(false)
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isDragging) return
|
||||
|
||||
const deltaX = e.clientX - dragStart.x
|
||||
const deltaY = e.clientY - dragStart.y
|
||||
|
||||
// Mark that we've dragged (prevents click event from changing selection)
|
||||
if (Math.abs(deltaX) > 2 || Math.abs(deltaY) > 2) {
|
||||
setHasDragged(true)
|
||||
}
|
||||
|
||||
// Move all selected notes (or just this note if none are selected)
|
||||
if ($selectedNoteIds.length > 0) {
|
||||
moveSelectedNotesLocal(deltaX, deltaY)
|
||||
} else {
|
||||
// If no notes are selected, just move this one
|
||||
updateNote(note.id, { x: note.x + deltaX, y: note.y + deltaY })
|
||||
}
|
||||
|
||||
setDragStart({ x: e.clientX, y: e.clientY })
|
||||
}
|
||||
|
||||
const handleMouseUp = async () => {
|
||||
if (isDragging && $selectedNoteIds.length > 0) {
|
||||
// Save multi-note moves to DB
|
||||
await saveSelectedNotesToDB()
|
||||
}
|
||||
setIsDragging(false)
|
||||
// Don't reset hasDragged here - let click handler reset it
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
}
|
||||
}, [isDragging, dragStart])
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onBlur={handleSave}
|
||||
onKeyDown={handleKeyDown}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: note.x,
|
||||
top: note.y,
|
||||
background: 'transparent',
|
||||
border: '1px solid #333',
|
||||
color: 'white',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '14px',
|
||||
padding: '4px 8px',
|
||||
resize: 'none',
|
||||
outline: 'none',
|
||||
minWidth: '100px',
|
||||
minHeight: '20px'
|
||||
}}
|
||||
rows={1}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: note.x,
|
||||
top: note.y,
|
||||
color: isSpaceLink() ? '#ff4444' : 'white',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '14px',
|
||||
padding: '4px 8px',
|
||||
cursor: isDragging ? 'grabbing' : (isSelected ? 'grab' : 'pointer'),
|
||||
userSelect: 'none',
|
||||
border: isSelected ? '2px solid white' : '1px solid transparent',
|
||||
zIndex: isDragging ? 1000 : (isSelected ? 10 : 1),
|
||||
background: isSelected ? 'rgba(255, 255, 255, 0.1)' : 'transparent'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isDragging && !isSelected) {
|
||||
e.currentTarget.style.border = '1px solid #333'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isDragging && !isSelected) {
|
||||
e.currentTarget.style.border = '1px solid transparent'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{note.content || '...'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
227
src/components/Space.tsx
Normal file
227
src/components/Space.tsx
Normal file
@ -0,0 +1,227 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { currentSpace, currentSpaceId, createNote, editingNoteId, clearSelection, deleteSelectedNotes, selectNotesInRect, refreshExistingSpaces } from '../store'
|
||||
import { Note } from './Note'
|
||||
import { NavigationConsole } from './NavigationConsole'
|
||||
import { WelcomeModal, shouldShowWelcome } from './WelcomeModal'
|
||||
|
||||
export function Space() {
|
||||
const $currentSpace = useStore(currentSpace)
|
||||
const $currentSpaceId = useStore(currentSpaceId)
|
||||
const $editingNoteId = useStore(editingNoteId)
|
||||
|
||||
const [isSelecting, setIsSelecting] = useState(false)
|
||||
const [selectionStart, setSelectionStart] = useState({ x: 0, y: 0 })
|
||||
const [selectionEnd, setSelectionEnd] = useState({ x: 0, y: 0 })
|
||||
const [justFinishedSelection, setJustFinishedSelection] = useState(false)
|
||||
const [showNavigationConsole, setShowNavigationConsole] = useState(false)
|
||||
const [showWelcomeModal, setShowWelcomeModal] = useState(false)
|
||||
|
||||
// Show welcome modal on first load and refresh existing spaces
|
||||
useEffect(() => {
|
||||
if (shouldShowWelcome()) {
|
||||
setShowWelcomeModal(true)
|
||||
}
|
||||
// Initialize existing spaces list
|
||||
refreshExistingSpaces()
|
||||
}, [])
|
||||
|
||||
// Click in empty space - clear selection (but not if we just finished a rectangle selection)
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (justFinishedSelection) {
|
||||
setJustFinishedSelection(false)
|
||||
return
|
||||
}
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
// Double-click in empty space - create note
|
||||
const handleDoubleClick = async (e: React.MouseEvent) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
const y = e.clientY - rect.top
|
||||
|
||||
const noteId = await createNote(x, y)
|
||||
if (noteId) {
|
||||
editingNoteId.set(noteId)
|
||||
}
|
||||
}
|
||||
|
||||
// Drag in empty space - rectangle selection
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (e.detail === 2) return // Ignore double-click
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
const y = e.clientY - rect.top
|
||||
|
||||
setIsSelecting(true)
|
||||
setSelectionStart({ x, y })
|
||||
setSelectionEnd({ x, y })
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isSelecting) return
|
||||
|
||||
const spaceElement = document.querySelector('[data-space-canvas]') as HTMLElement
|
||||
if (!spaceElement) return
|
||||
|
||||
const rect = spaceElement.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
const y = e.clientY - rect.top
|
||||
|
||||
setSelectionEnd({ x, y })
|
||||
}
|
||||
|
||||
const handleMouseUp = (e: MouseEvent) => {
|
||||
if (!isSelecting) return
|
||||
|
||||
setIsSelecting(false)
|
||||
|
||||
// Get final mouse position directly from event
|
||||
const spaceElement = document.querySelector('[data-space-canvas]') as HTMLElement
|
||||
if (!spaceElement) return
|
||||
|
||||
const rect = spaceElement.getBoundingClientRect()
|
||||
const finalX = e.clientX - rect.left
|
||||
const finalY = e.clientY - rect.top
|
||||
|
||||
// Calculate selection rectangle using actual coordinates
|
||||
const minX = Math.min(selectionStart.x, finalX)
|
||||
const maxX = Math.max(selectionStart.x, finalX)
|
||||
const minY = Math.min(selectionStart.y, finalY)
|
||||
const maxY = Math.max(selectionStart.y, finalY)
|
||||
|
||||
const width = Math.abs(maxX - minX)
|
||||
const height = Math.abs(maxY - minY)
|
||||
|
||||
// Only select if rectangle is big enough
|
||||
if (width > 5 || height > 5) {
|
||||
selectNotesInRect(minX, minY, maxX, maxY)
|
||||
setJustFinishedSelection(true)
|
||||
} else {
|
||||
clearSelection()
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isSelecting) {
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
}
|
||||
}, [isSelecting, selectionStart])
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Don't handle shortcuts if editing a note, navigation console or welcome modal is open
|
||||
if ($editingNoteId || showNavigationConsole || showWelcomeModal) return
|
||||
|
||||
if (e.key === ':' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
setShowNavigationConsole(true)
|
||||
} else if (e.key === '?' || (e.key === ':' && e.shiftKey)) {
|
||||
e.preventDefault()
|
||||
setShowWelcomeModal(true)
|
||||
} else if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
deleteSelectedNotes()
|
||||
} else if (e.key === 'Escape') {
|
||||
clearSelection()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [$editingNoteId, showNavigationConsole, showWelcomeModal])
|
||||
|
||||
if (!$currentSpace) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
background: '#000',
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
Loading...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const selectionRect = {
|
||||
left: Math.min(selectionStart.x, selectionEnd.x),
|
||||
top: Math.min(selectionStart.y, selectionEnd.y),
|
||||
width: Math.abs(selectionEnd.x - selectionStart.x),
|
||||
height: Math.abs(selectionEnd.y - selectionStart.y)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-space-canvas
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
background: '#000',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
cursor: isSelecting ? 'crosshair' : 'default'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '10px',
|
||||
left: '10px',
|
||||
color: '#666',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '12px',
|
||||
userSelect: 'none',
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
>
|
||||
Space: {$currentSpaceId}
|
||||
</div>
|
||||
|
||||
{$currentSpace.notes.map((note) => (
|
||||
<Note key={note.id} note={note} />
|
||||
))}
|
||||
|
||||
{isSelecting && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: selectionRect.left,
|
||||
top: selectionRect.top,
|
||||
width: selectionRect.width,
|
||||
height: selectionRect.height,
|
||||
border: '1px dashed #555',
|
||||
background: 'rgba(255, 255, 255, 0.1)',
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<NavigationConsole
|
||||
isOpen={showNavigationConsole}
|
||||
onClose={() => setShowNavigationConsole(false)}
|
||||
/>
|
||||
|
||||
<WelcomeModal
|
||||
isOpen={showWelcomeModal}
|
||||
onClose={() => setShowWelcomeModal(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
117
src/components/WelcomeModal.tsx
Normal file
117
src/components/WelcomeModal.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
interface WelcomeModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function WelcomeModal({ isOpen, onClose }: WelcomeModalProps) {
|
||||
const [neverShowAgain, setNeverShowAgain] = useState(false)
|
||||
|
||||
const handleClose = () => {
|
||||
if (neverShowAgain) {
|
||||
localStorage.setItem('palace-welcome-dismissed', 'true')
|
||||
}
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleOverlayClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
handleClose()
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.8)',
|
||||
zIndex: 3000,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
onClick={handleOverlayClick}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #333',
|
||||
width: '500px',
|
||||
maxWidth: '90vw',
|
||||
fontFamily: 'monospace',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '24px' }}>
|
||||
<h2 style={{ margin: '0 0 20px 0', fontSize: '18px', color: '#fff' }}>
|
||||
Bienvenue dans Palace
|
||||
</h2>
|
||||
|
||||
<div style={{ marginBottom: '20px', lineHeight: '1.5', color: '#ccc' }}>
|
||||
<p style={{ margin: '0 0 12px 0' }}>
|
||||
<strong>Navigation :</strong> Tapez <code style={{ background: '#333', padding: '2px 4px', borderRadius: '2px' }}>:</code> pour ouvrir la console de navigation
|
||||
</p>
|
||||
<p style={{ margin: '0 0 12px 0' }}>
|
||||
<strong>Création de notes :</strong> Double-cliquez sur l'espace vide pour créer une note
|
||||
</p>
|
||||
<p style={{ margin: '0 0 12px 0' }}>
|
||||
<strong>Sélection multiple :</strong> Shift+clic ou glisser pour sélectionner plusieurs notes
|
||||
</p>
|
||||
<p style={{ margin: '0 0 12px 0' }}>
|
||||
<strong>Navigation entre espaces :</strong> Double-cliquez sur une note pour naviguer vers l'espace correspondant
|
||||
</p>
|
||||
<p style={{ margin: '0' }}>
|
||||
<strong>Raccourcis :</strong> Suppr/Backspace pour effacer, Échap pour désélectionner
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', color: '#ccc' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={neverShowAgain}
|
||||
onChange={(e) => setNeverShowAgain(e.target.checked)}
|
||||
style={{ marginRight: '8px' }}
|
||||
/>
|
||||
Ne plus afficher ce message
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
style={{
|
||||
background: '#333',
|
||||
color: 'white',
|
||||
border: '1px solid #555',
|
||||
padding: '8px 16px',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#444'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#333'
|
||||
}}
|
||||
>
|
||||
Compris !
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function shouldShowWelcome(): boolean {
|
||||
return localStorage.getItem('palace-welcome-dismissed') !== 'true'
|
||||
}
|
||||
124
src/db.ts
Normal file
124
src/db.ts
Normal file
@ -0,0 +1,124 @@
|
||||
type Note = {
|
||||
id: string
|
||||
x: number
|
||||
y: number
|
||||
content: string
|
||||
}
|
||||
|
||||
type Space = {
|
||||
id: string
|
||||
notes: Note[]
|
||||
}
|
||||
|
||||
class Database {
|
||||
private db: IDBDatabase | null = null
|
||||
|
||||
async init() {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const request = indexedDB.open('palace-db', 1)
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => {
|
||||
this.db = request.result
|
||||
resolve()
|
||||
}
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result
|
||||
if (!db.objectStoreNames.contains('spaces')) {
|
||||
db.createObjectStore('spaces', { keyPath: 'id' })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async getSpace(id: string): Promise<Space | undefined> {
|
||||
if (!this.db) await this.init()
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db!.transaction(['spaces'], 'readonly')
|
||||
const store = transaction.objectStore('spaces')
|
||||
const request = store.get(id)
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
})
|
||||
}
|
||||
|
||||
async saveSpace(space: Space): Promise<void> {
|
||||
if (!this.db) await this.init()
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db!.transaction(['spaces'], 'readwrite')
|
||||
const store = transaction.objectStore('spaces')
|
||||
const request = store.put(space)
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => resolve()
|
||||
})
|
||||
}
|
||||
|
||||
async createSpace(id: string): Promise<Space> {
|
||||
const space: Space = {
|
||||
id,
|
||||
notes: []
|
||||
}
|
||||
await this.saveSpace(space)
|
||||
return space
|
||||
}
|
||||
|
||||
async addNoteToSpace(spaceId: string, note: Note): Promise<void> {
|
||||
const space = await this.getSpace(spaceId)
|
||||
if (!space) {
|
||||
throw new Error(`Space ${spaceId} not found`)
|
||||
}
|
||||
space.notes.push(note)
|
||||
await this.saveSpace(space)
|
||||
}
|
||||
|
||||
async updateNoteInSpace(spaceId: string, noteId: string, updates: Partial<Note>): Promise<void> {
|
||||
const space = await this.getSpace(spaceId)
|
||||
if (!space) {
|
||||
throw new Error(`Space ${spaceId} not found`)
|
||||
}
|
||||
const noteIndex = space.notes.findIndex(n => n.id === noteId)
|
||||
if (noteIndex === -1) {
|
||||
throw new Error(`Note ${noteId} not found in space ${spaceId}`)
|
||||
}
|
||||
space.notes[noteIndex] = { ...space.notes[noteIndex], ...updates }
|
||||
await this.saveSpace(space)
|
||||
}
|
||||
|
||||
async deleteNoteFromSpace(spaceId: string, noteId: string): Promise<void> {
|
||||
const space = await this.getSpace(spaceId)
|
||||
if (!space) {
|
||||
throw new Error(`Space ${spaceId} not found`)
|
||||
}
|
||||
space.notes = space.notes.filter(n => n.id !== noteId)
|
||||
await this.saveSpace(space)
|
||||
}
|
||||
|
||||
async getAllSpaceIds(): Promise<string[]> {
|
||||
if (!this.db) await this.init()
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db!.transaction(['spaces'], 'readonly')
|
||||
const store = transaction.objectStore('spaces')
|
||||
const request = store.getAllKeys()
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => resolve(request.result as string[])
|
||||
})
|
||||
}
|
||||
|
||||
async deleteSpace(id: string): Promise<void> {
|
||||
if (!this.db) await this.init()
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db!.transaction(['spaces'], 'readwrite')
|
||||
const store = transaction.objectStore('spaces')
|
||||
const request = store.delete(id)
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => resolve()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const db = new Database()
|
||||
21
src/index.css
Normal file
21
src/index.css
Normal file
@ -0,0 +1,21 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
color: white;
|
||||
font-family: 'Courier New', 'Monaco', monospace;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
191
src/store.ts
Normal file
191
src/store.ts
Normal file
@ -0,0 +1,191 @@
|
||||
import { atom } from 'nanostores'
|
||||
import { db } from './db'
|
||||
|
||||
type Note = {
|
||||
id: string
|
||||
x: number
|
||||
y: number
|
||||
content: string
|
||||
}
|
||||
|
||||
type Space = {
|
||||
id: string
|
||||
notes: Note[]
|
||||
}
|
||||
|
||||
export const currentSpaceId = atom<string>('home')
|
||||
export const currentSpace = atom<Space | null>(null)
|
||||
export const navigationHistory = atom<string[]>(['home'])
|
||||
export const editingNoteId = atom<string | null>(null)
|
||||
export const selectedNoteIds = atom<string[]>([])
|
||||
export const existingSpaceIds = atom<string[]>([])
|
||||
|
||||
export const refreshExistingSpaces = async () => {
|
||||
const spaces = await db.getAllSpaceIds()
|
||||
existingSpaceIds.set(spaces)
|
||||
}
|
||||
|
||||
export const loadSpace = async (spaceId: string) => {
|
||||
// Check if current space is empty and delete it (except 'home')
|
||||
const currentSpace_value = currentSpace.get()
|
||||
const currentSpaceId_value = currentSpaceId.get()
|
||||
if (currentSpace_value && currentSpaceId_value !== 'home' && currentSpace_value.notes.length === 0) {
|
||||
await db.deleteSpace(currentSpaceId_value)
|
||||
}
|
||||
|
||||
currentSpace.set(null)
|
||||
let space = await db.getSpace(spaceId)
|
||||
if (!space) {
|
||||
space = await db.createSpace(spaceId)
|
||||
}
|
||||
currentSpace.set(space)
|
||||
currentSpaceId.set(spaceId)
|
||||
window.history.pushState({}, '', `/#${spaceId}`)
|
||||
|
||||
// Refresh list of existing spaces
|
||||
await refreshExistingSpaces()
|
||||
|
||||
return space
|
||||
}
|
||||
|
||||
export const navigateToSpace = async (spaceId: string) => {
|
||||
const history = navigationHistory.get()
|
||||
if (history[history.length - 1] !== spaceId) {
|
||||
navigationHistory.set([...history, spaceId])
|
||||
}
|
||||
await loadSpace(spaceId)
|
||||
}
|
||||
|
||||
export const goBack = async () => {
|
||||
const history = navigationHistory.get()
|
||||
if (history.length > 1) {
|
||||
const newHistory = history.slice(0, -1)
|
||||
navigationHistory.set(newHistory)
|
||||
const previousSpaceId = newHistory[newHistory.length - 1]
|
||||
await loadSpace(previousSpaceId)
|
||||
}
|
||||
}
|
||||
|
||||
export const createNote = async (x: number, y: number) => {
|
||||
const space = currentSpace.get()
|
||||
if (!space) return null
|
||||
|
||||
const note: Note = {
|
||||
id: `note-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
x,
|
||||
y,
|
||||
content: ''
|
||||
}
|
||||
|
||||
await db.addNoteToSpace(space.id, note)
|
||||
const updatedSpace = await db.getSpace(space.id)
|
||||
currentSpace.set(updatedSpace!)
|
||||
|
||||
return note.id
|
||||
}
|
||||
|
||||
export const updateNote = async (noteId: string, updates: Partial<Note>) => {
|
||||
const space = currentSpace.get()
|
||||
if (!space) return
|
||||
|
||||
await db.updateNoteInSpace(space.id, noteId, updates)
|
||||
const updatedSpace = await db.getSpace(space.id)
|
||||
currentSpace.set(updatedSpace!)
|
||||
}
|
||||
|
||||
export const deleteNote = async (noteId: string) => {
|
||||
const space = currentSpace.get()
|
||||
if (!space) return
|
||||
|
||||
await db.deleteNoteFromSpace(space.id, noteId)
|
||||
const updatedSpace = await db.getSpace(space.id)
|
||||
currentSpace.set(updatedSpace!)
|
||||
}
|
||||
|
||||
export const getSpaceFromUrl = () => {
|
||||
const hash = window.location.hash.slice(1)
|
||||
return hash || 'home'
|
||||
}
|
||||
|
||||
// Selection management
|
||||
export const selectNote = (noteId: string, multiSelect: boolean = false) => {
|
||||
const currentSelection = selectedNoteIds.get()
|
||||
|
||||
if (multiSelect) {
|
||||
if (!currentSelection.includes(noteId)) {
|
||||
// Add to selection only
|
||||
const newSelection = [...currentSelection, noteId]
|
||||
selectedNoteIds.set(newSelection)
|
||||
}
|
||||
// If already selected and multiSelect, do nothing (don't remove)
|
||||
} else {
|
||||
// Single selection
|
||||
selectedNoteIds.set([noteId])
|
||||
}
|
||||
}
|
||||
|
||||
export const selectNotesInRect = (minX: number, minY: number, maxX: number, maxY: number) => {
|
||||
const space = currentSpace.get()
|
||||
if (!space) return
|
||||
|
||||
const selectedIds = space.notes
|
||||
.filter(note => {
|
||||
// Check if note overlaps with rectangle (considering note has some width/height)
|
||||
const noteRight = note.x + 50 // Approximate note width
|
||||
const noteBottom = note.y + 20 // Approximate note height
|
||||
|
||||
const inRect = !(note.x > maxX || noteRight < minX || note.y > maxY || noteBottom < minY)
|
||||
return inRect
|
||||
})
|
||||
.map(note => note.id)
|
||||
|
||||
selectedNoteIds.set(selectedIds)
|
||||
}
|
||||
|
||||
export const clearSelection = () => {
|
||||
selectedNoteIds.set([])
|
||||
}
|
||||
|
||||
export const deleteSelectedNotes = async () => {
|
||||
const selectedIds = selectedNoteIds.get()
|
||||
const space = currentSpace.get()
|
||||
if (!space || selectedIds.length === 0) return
|
||||
|
||||
for (const noteId of selectedIds) {
|
||||
await db.deleteNoteFromSpace(space.id, noteId)
|
||||
}
|
||||
|
||||
const updatedSpace = await db.getSpace(space.id)
|
||||
currentSpace.set(updatedSpace!)
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
export const moveSelectedNotesLocal = (deltaX: number, deltaY: number) => {
|
||||
const selectedIds = selectedNoteIds.get()
|
||||
const space = currentSpace.get()
|
||||
if (!space || selectedIds.length === 0) return
|
||||
|
||||
// Update positions locally without DB calls
|
||||
const updatedNotes = space.notes.map(note => {
|
||||
if (selectedIds.includes(note.id)) {
|
||||
return { ...note, x: note.x + deltaX, y: note.y + deltaY }
|
||||
}
|
||||
return note
|
||||
})
|
||||
|
||||
currentSpace.set({ ...space, notes: updatedNotes })
|
||||
}
|
||||
|
||||
export const saveSelectedNotesToDB = async () => {
|
||||
const selectedIds = selectedNoteIds.get()
|
||||
const space = currentSpace.get()
|
||||
if (!space || selectedIds.length === 0) return
|
||||
|
||||
// Save to DB
|
||||
for (const noteId of selectedIds) {
|
||||
const note = space.notes.find(n => n.id === noteId)
|
||||
if (note) {
|
||||
await db.updateNoteInSpace(space.id, noteId, { x: note.x, y: note.y })
|
||||
}
|
||||
}
|
||||
}
|
||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user