first commit
This commit is contained in:
111
.gitignore
vendored
Normal file
111
.gitignore
vendored
Normal 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
2
CLAUDE.md
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
## Guidelines
|
||||||
|
- Ne rajoute aucune dépendance sans m'avoir consulté au préalable
|
||||||
69
README.md
Normal file
69
README.md
Normal 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
23
eslint.config.js
Normal 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
46
index.html
Normal 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
31
package.json
Normal 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
2103
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
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" />
|
||||||
27
tsconfig.app.json
Normal file
27
tsconfig.app.json
Normal 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
7
tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
25
tsconfig.node.json
Normal file
25
tsconfig.node.json
Normal 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
7
vite.config.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user