first commit

This commit is contained in:
2025-07-19 00:36:16 +02:00
commit 051c291b8c
23 changed files with 3574 additions and 0 deletions

View File

@ -0,0 +1,6 @@
import { useStore } from '@nanostores/react'
import { navigationHistory, goBack } from '../store'
export function Navigation() {
return null
}

View 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
View 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
View 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>
)
}

View 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'
}