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