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

111
.gitignore vendored Normal file
View File

@ -0,0 +1,111 @@
# Dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Build outputs
dist/
build/
out/
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
*.lcov
# nyc test coverage
.nyc_output
# ESLint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
public
# Storybook build outputs
.out
.storybook-out
storybook-static
# Temporary folders
tmp/
temp/
# Logs
logs
*.log
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# IDE files
.vscode/
.idea/
*.swp
*.swo
*~
# Backup files
*.bak
# Testing
test-results/
playwright-report/
playwright/.cache/
# TypeScript
*.tsbuildinfo

2
CLAUDE.md Normal file
View File

@ -0,0 +1,2 @@
## Guidelines
- Ne rajoute aucune dépendance sans m'avoir consulté au préalable

69
README.md Normal file
View File

@ -0,0 +1,69 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
eslint.config.js Normal file
View File

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { globalIgnores } from 'eslint/config'
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

46
index.html Normal file
View File

@ -0,0 +1,46 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Title and basic meta -->
<title>Palace - Espace de notes connectées</title>
<meta name="description" content="Palace est un espace de prise de notes minimaliste où vous pouvez créer, organiser et naviguer entre des espaces de notes interconnectés." />
<meta name="keywords" content="notes, espace de travail, minimaliste, navigation, organisation, productivité" />
<meta name="author" content="Palace" />
<!-- Theme and app meta -->
<meta name="theme-color" content="#000000" />
<meta name="color-scheme" content="dark" />
<!-- Open Graph meta tags -->
<meta property="og:title" content="Palace - Espace de notes connectées" />
<meta property="og:description" content="Un espace de prise de notes minimaliste avec navigation fluide entre espaces interconnectés." />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Palace" />
<!-- Twitter Card meta tags -->
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="Palace - Espace de notes connectées" />
<meta name="twitter:description" content="Un espace de prise de notes minimaliste avec navigation fluide entre espaces interconnectés." />
<!-- Prevent indexing if needed -->
<meta name="robots" content="noindex, nofollow" />
<!-- PWA meta -->
<meta name="application-name" content="Palace" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<meta name="apple-mobile-web-app-title" content="Palace" />
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="alternate icon" href="/favicon.svg" />
<link rel="mask-icon" href="/favicon.svg" color="#000000" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

31
package.json Normal file
View File

@ -0,0 +1,31 @@
{
"name": "palace",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@nanostores/react": "^1.0.0",
"nanostores": "^1.0.1",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@eslint/js": "^9.30.1",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"eslint": "^9.30.1",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"typescript": "~5.8.3",
"typescript-eslint": "^8.35.1",
"vite": "^7.0.4"
}
}

2103
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

0
src/App.css Normal file
View File

29
src/App.tsx Normal file
View 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

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

124
src/db.ts Normal file
View 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
View 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
View 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
View 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
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

27
tsconfig.app.json Normal file
View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

25
tsconfig.node.json Normal file
View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

7
vite.config.ts Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})