reinit repo with sources from zipped dir

This commit is contained in:
Louis
2025-11-21 09:35:47 +01:00
parent 53580dd312
commit a2725828f5
82 changed files with 28056 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
import nx from '@nx/eslint-plugin'
import baseConfig from '../../eslint.config.mjs'
export default [
...baseConfig,
...nx.configs['flat/react'],
{
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
// Override or add rules here
rules: {},
},
]

5
apps/admin/package.json Normal file
View File

@@ -0,0 +1,5 @@
{
"name": "@klx/admin",
"version": "0.0.1",
"private": true
}

25
apps/admin/project.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "@klx/admin",
"targets": {
"serve": {
"continuous": true,
"dependsOn": [
"^build"
],
"options": {
"cwd": "apps/admin",
"args": [
"--node-env=development"
],
"env": {},
"command": "rspack serve"
},
"syncGenerators": [
"@nx/js:typescript-sync"
],
"executor": "nx:run-commands",
"configurations": {},
"parallelism": true
}
}
}

View File

@@ -0,0 +1,35 @@
const { NxAppRspackPlugin } = require('@nx/rspack/app-plugin')
const { NxReactRspackPlugin } = require('@nx/rspack/react-plugin')
const { join } = require('path')
module.exports = {
output: {
path: join(__dirname, 'dist'),
},
devServer: {
port: 4201,
open: true,
historyApiFallback: {
index: '/index.html',
disableDotRule: true,
htmlAcceptHeaders: ['text/html', 'application/xhtml+xml'],
},
},
plugins: [
new NxAppRspackPlugin({
tsConfig: './tsconfig.app.json',
main: './src/main.tsx',
index: './src/index.html',
baseHref: '/',
assets: ['./src/assets'],
styles: ['./src/styles.css'],
outputHashing: process.env['NODE_ENV'] === 'production' ? 'all' : 'none',
optimization: process.env['NODE_ENV'] === 'production',
}),
new NxReactRspackPlugin({
// Uncomment this line if you don't want to use SVGR
// See: https://react-svgr.com/
// svgr: false
}),
],
}

View File

@@ -0,0 +1,15 @@
import {faker} from '@faker-js/faker'
import { User } from '../types'
const waitMs = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
export const fetchUsers = async (): Promise<User[]> => {
await waitMs(30)
return Array.from({length: 2000}).map(() => ({
uuid: faker.string.uuid(),
name: faker.person.fullName(),
email: faker.internet.email(),
job: faker.person.jobTitle(),
}))
}

View File

View File

@@ -0,0 +1,18 @@
import {UsersTable} from '../components/UsersTable'
import {Header, Footer, Main} from '@klx/ui'
import {ClockWrapper} from './ClockWrapper'
const APP_NAME = '@klx/admin'
export function App() {
return (
<div>
<Header title={APP_NAME} />
<Main>
<ClockWrapper />
<UsersTable />
</Main>
<Footer />
</div>
)
}

View File

@@ -0,0 +1,20 @@
import { useState, useEffect } from 'react'
export const Clock = () => {
const [time, setTime] = useState(new Date())
useEffect(() => {
const interval = setInterval(() => {
setTime(new Date())
}, 1000)
return () => clearInterval(interval)
}, [])
const pad = (n: number) => n.toString().padStart(2, '0')
return (
<div className="text-sm font-mono text-gray-700 font-semibold">
{pad(time.getHours())}:{pad(time.getMinutes())}:{pad(time.getSeconds())}
</div>
)
}

View File

@@ -0,0 +1,9 @@
import { Clock } from './Clock'
export const ClockWrapper = () => {
return (
<div className="flex items-center px-4 py-2 m-2">
<Clock />
</div>
)
}

View File

@@ -0,0 +1,35 @@
import { SortDirection } from '../hooks/useSort'
interface SortHeaderProps {
label: string
isActive: boolean
direction: SortDirection
onClick: () => void
}
export const SortHeader = ({ label, isActive, direction, onClick }: SortHeaderProps) => {
const getSortIndicator = () => {
if (!isActive) return '↑↓'
if (direction === 'asc') return '↑'
if (direction === 'desc') return '↓'
return '↑↓'
}
return (
<th
onClick={onClick}
className={`
px-4 py-3 text-left cursor-pointer
transition-all duration-200 ease-out select-none text-sm font-semibold whitespace-nowrap
${isActive
? 'bg-blue-50 text-blue-700 border-b-2 border-blue-600'
: 'bg-gradient-to-r from-gray-50 to-gray-100 text-gray-700 hover:bg-gray-200 border-b-2 border-gray-200'
}
`}
title="Click to sort"
>
{label}
<span className="ml-2 text-xs font-normal opacity-75">{getSortIndicator()}</span>
</th>
)
}

View File

@@ -0,0 +1,53 @@
import {User} from '../types'
import {useEffect, useState} from 'react'
import {fetchUsers} from '../api/fetchUsers'
import {useSort} from '../hooks/useSort'
import {useFilters} from '../hooks/useFilters'
import {VirtualizedTable} from './VirtualizedTable'
import {filterUsers} from '../utils/filterUsers'
export const UsersTable = () => {
const [users, setUsers] = useState<User[]>([])
const [selectedUser, setSelectedUser] = useState<User | null>(null)
const {filters, updateFilters, clearFilters} = useFilters()
// Filtrer puis trier
const filteredUsers = filterUsers(users, filters)
const {sortedItems, sortKey, direction, handleSort} = useSort(filteredUsers)
useEffect(() => {
fetchUsers()
.then(data => setUsers(data))
.catch(console.error)
}, [])
const onUserClick = (user: User) => {
setSelectedUser(user)
}
return (
<div className="p-6 bg-white rounded-xl shadow-md hover:shadow-lg transition-shadow duration-300 border border-gray-100">
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold text-gray-900">
Users Management
</h2>
<span className="text-xs font-medium text-gray-500 bg-gray-100 px-3 py-1 rounded-full">
{sortedItems.length} / {users.length} users
</span>
</div>
<VirtualizedTable
items={sortedItems}
sortKey={sortKey}
direction={direction}
onSort={handleSort}
selectedUser={selectedUser}
onSelectUser={onUserClick}
filters={filters}
onFilterChange={updateFilters}
onClearFilters={clearFilters}
totalUsers={users.length}
/>
</div>
)
}

View File

@@ -0,0 +1,130 @@
import { User } from '../types'
import { SortDirection } from '../hooks/useSort'
import { Filters } from '../hooks/useFilters'
import { SortHeader } from './SortHeader'
interface VirtualizedTableProps {
items: User[]
sortKey: keyof User | null
direction: SortDirection
onSort: (key: keyof User) => void
selectedUser: User | null
onSelectUser: (user: User) => void
filters: Filters
onFilterChange: (filters: Partial<Filters>) => void
onClearFilters: () => void
totalUsers: number
}
export const VirtualizedTable = ({
items,
sortKey,
direction,
onSort,
selectedUser,
onSelectUser,
filters,
onFilterChange,
onClearFilters,
totalUsers,
}: VirtualizedTableProps) => {
const cellStyles = 'border-b border-gray-200 px-4 py-3.5 text-sm text-gray-700'
const inputClass = 'px-2 py-1 border border-gray-300 rounded text-xs focus:outline-none focus:ring-1 focus:ring-blue-500'
const hasActiveFilters = Object.values(filters).some(v => v !== '')
return (
<div className="rounded-lg border border-gray-200 overflow-hidden shadow-sm">
{/* Filter Bar */}
<div className="bg-white border-b border-gray-200 flex sticky top-0 z-9 w-full px-4 py-2">
<input
type="text"
placeholder="Name..."
value={filters.name}
onChange={(e) => onFilterChange({ name: e.target.value })}
className={`flex-1 ${inputClass}`}
/>
<input
type="text"
placeholder="Email..."
value={filters.email}
onChange={(e) => onFilterChange({ email: e.target.value })}
className={`flex-1 ${inputClass} mx-2`}
/>
<input
type="text"
placeholder="Job..."
value={filters.job}
onChange={(e) => onFilterChange({ job: e.target.value })}
className={`flex-1 ${inputClass}`}
/>
{hasActiveFilters && (
<button
onClick={onClearFilters}
className="ml-2 px-2 py-1 bg-red-100 text-red-700 rounded text-xs hover:bg-red-200 transition-colors font-medium whitespace-nowrap"
>
Clear
</button>
)}
</div>
{/* Table with scrollable container */}
<div className="overflow-y-auto" style={{ maxHeight: '500px' }}>
<table className="w-full border-collapse">
<thead className="bg-gradient-to-r from-gray-50 to-gray-100 border-b-2 border-gray-200 sticky top-0 z-10">
<tr>
<SortHeader
label="Name"
isActive={sortKey === 'name'}
direction={direction}
onClick={() => onSort('name')}
/>
<SortHeader
label="Email"
isActive={sortKey === 'email'}
direction={direction}
onClick={() => onSort('email')}
/>
<SortHeader
label="Job Title"
isActive={sortKey === 'job'}
direction={direction}
onClick={() => onSort('job')}
/>
</tr>
</thead>
<tbody>
{items.length > 0 ? (
items.map((user, index) => (
<tr
key={user.uuid}
onClick={() => onSelectUser(user)}
className={`
cursor-pointer transition-all duration-150 ease-out
border-b border-gray-100 last:border-b-0
${selectedUser?.uuid === user.uuid
? 'bg-gradient-to-r from-blue-50 to-blue-50 shadow-sm'
: index % 2 === 0
? 'bg-white hover:bg-blue-50'
: 'bg-gray-50 hover:bg-blue-50'
}
`}
>
<td className={`${cellStyles}`}>{user.name}</td>
<td className={`${cellStyles}`}>{user.email}</td>
<td className={`${cellStyles}`}>{user.job}</td>
</tr>
))
) : (
<tr>
<td colSpan={3} className="text-center py-12 text-gray-400 text-sm font-medium">
No users found
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,48 @@
import { useState, useEffect } from 'react'
export interface Filters {
name: string
email: string
job: string
}
export const useFilters = () => {
const [filters, setFilters] = useState<Filters>({ name: '', email: '', job: '' })
// Lire les filtres depuis l'URL au montage
useEffect(() => {
const params = new URLSearchParams(window.location.search)
const urlFilters: Filters = {
name: params.get('name') || '',
email: params.get('email') || '',
job: params.get('job') || '',
}
setFilters(urlFilters)
}, [])
// Updater les filtres et l'URL
const updateFilters = (newFilters: Partial<Filters>) => {
const updated = { ...filters, ...newFilters }
setFilters(updated)
// Construire les query params
const params = new URLSearchParams()
if (updated.name) params.set('name', updated.name)
if (updated.email) params.set('email', updated.email)
if (updated.job) params.set('job', updated.job)
// Updater l'URL sans rechargement
const queryString: string = params.toString()
const newUrl: string = queryString
? `${window.location.pathname}?${queryString}`
: window.location.pathname
window.history.replaceState({}, '', newUrl)
}
// Réinitialiser les filtres
const clearFilters = () => {
updateFilters({ name: '', email: '', job: '' })
}
return { filters, updateFilters, clearFilters }
}

View File

@@ -0,0 +1,77 @@
import { useState, useCallback } from 'react'
export type SortDirection = 'asc' | 'desc' | null
export type SortableKey<T> = keyof T
interface UseSortOptions<T> {
initialSortKey?: SortableKey<T>
initialDirection?: SortDirection
}
export const useSort = <T extends Record<string, string | number>>(
items: T[],
options?: UseSortOptions<T>
) => {
const [sortKey, setSortKey] = useState<SortableKey<T> | null>(
options?.initialSortKey ?? null
)
const [direction, setDirection] = useState<SortDirection>(
options?.initialDirection ?? null
)
const handleSort = useCallback(
(key: SortableKey<T>) => {
// Si on clique sur la même colonne, on change de direction
if (sortKey === key) {
if (direction === 'asc') {
setDirection('desc')
} else if (direction === 'desc') {
// Cycle: asc -> desc -> null
setSortKey(null)
setDirection(null)
} else {
setDirection('asc')
}
} else {
// Si on clique sur une nouvelle colonne, on sort en asc
setSortKey(key)
setDirection('asc')
}
},
[sortKey, direction]
)
const sortedItems = [...items].sort((a, b) => {
if (sortKey === null) return 0
const valueA = a[sortKey]
const valueB = b[sortKey]
// Handle null/undefined
if (valueA === null || valueA === undefined) return direction === 'asc' ? 1 : -1
if (valueB === null || valueB === undefined) return direction === 'asc' ? -1 : 1
// Handle strings
if (typeof valueA === 'string' && typeof valueB === 'string') {
const comparison = valueA.localeCompare(valueB)
return direction === 'asc' ? comparison : -comparison
}
// Handle numbers
if (typeof valueA === 'number' && typeof valueB === 'number') {
return direction === 'asc' ? valueA - valueB : valueB - valueA
}
// Default comparison
if (valueA < valueB) return direction === 'asc' ? -1 : 1
if (valueA > valueB) return direction === 'asc' ? 1 : -1
return 0
})
return {
sortedItems,
sortKey,
direction,
handleSort,
}
}

14
apps/admin/src/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Admin</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>

11
apps/admin/src/main.tsx Normal file
View File

@@ -0,0 +1,11 @@
import {StrictMode} from 'react'
import * as ReactDOM from 'react-dom/client'
import {App} from './components/App'
import './styles.css'
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)
root.render(
<StrictMode>
<App />
</StrictMode>
)

89
apps/admin/src/styles.css Normal file
View File

@@ -0,0 +1,89 @@
html {
-webkit-text-size-adjust: 100%;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
line-height: 1.5;
tab-size: 4;
scroll-behavior: smooth;
}
body {
font-family: inherit;
line-height: inherit;
margin: 0;
}
h1,
h2,
p,
pre {
margin: 0;
}
*,
::before,
::after {
box-sizing: border-box;
border-width: 0;
border-style: solid;
border-color: currentColor;
}
h1,
h2 {
font-size: inherit;
font-weight: inherit;
}
a {
color: inherit;
text-decoration: inherit;
}
pre {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
'Liberation Mono', 'Courier New', monospace;
}
svg {
display: block;
vertical-align: middle;
shape-rendering: auto;
text-rendering: optimizeLegibility;
}
pre {
background-color: rgba(55, 65, 81, 1);
border-radius: 0.25rem;
color: rgba(229, 231, 235, 1);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
'Liberation Mono', 'Courier New', monospace;
overflow: auto;
padding: 0.5rem 0.75rem;
}
.shadow {
box-shadow: 0 0 #0000, 0 0 #0000, 0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
.rounded {
border-radius: 1.5rem;
}
.wrapper {
width: 100%;
}
.container {
margin-left: auto;
margin-right: auto;
max-width: 768px;
padding-bottom: 3rem;
padding-left: 1rem;
padding-right: 1rem;
color: rgba(55, 65, 81, 1);
width: 100%;
}

2
apps/admin/src/types.ts Normal file
View File

@@ -0,0 +1,2 @@
// Re-export User from models package for backward compatibility
export type {User} from '@klx/models'

View File

@@ -0,0 +1,11 @@
import { User } from '../types'
import { Filters } from '../hooks/useFilters'
export const filterUsers = (users: User[], filters: Filters): User[] => {
return users.filter(user => {
const nameMatch = user.name.toLowerCase().includes(filters.name.toLowerCase())
const emailMatch = user.email.toLowerCase().includes(filters.email.toLowerCase())
const jobMatch = user.job.toLowerCase().includes(filters.job.toLowerCase())
return nameMatch && emailMatch && jobMatch
})
}

View File

@@ -0,0 +1,35 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"tsBuildInfoFile": "dist/tsconfig.app.tsbuildinfo",
"jsx": "react-jsx",
"lib": ["dom"],
"types": ["node", "@nx/react/typings/cssmodule.d.ts", "@nx/react/typings/image.d.ts"],
"rootDir": "src"
},
"exclude": [
"out-tsc",
"dist",
"src/**/*.spec.ts",
"src/**/*.test.ts",
"src/**/*.spec.tsx",
"src/**/*.test.tsx",
"src/**/*.spec.js",
"src/**/*.test.js",
"src/**/*.spec.jsx",
"src/**/*.test.jsx",
"eslint.config.js",
"eslint.config.cjs",
"eslint.config.mjs"
],
"include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"],
"references": [
{
"path": "../../packages/models/tsconfig.lib.json"
},
{
"path": "../../packages/ui/tsconfig.lib.json"
}
]
}

16
apps/admin/tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"files": [],
"include": [],
"references": [
{
"path": "../../packages/models"
},
{
"path": "../../packages/ui"
},
{
"path": "./tsconfig.app.json"
}
],
"extends": "../../tsconfig.base.json"
}

View File

@@ -0,0 +1,12 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../eslint.config.mjs';
export default [
...baseConfig,
...nx.configs['flat/react'],
{
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
// Override or add rules here
rules: {},
},
];

17
apps/board/jest.config.js Normal file
View File

@@ -0,0 +1,17 @@
module.exports = {
displayName: 'board',
preset: '../../jest.preset.js',
testEnvironment: 'jsdom',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
coverageDirectory: '../../coverage/apps/board',
testMatch: ['**/*.spec.ts', '**/*.spec.tsx'],
transformIgnorePatterns: [
'node_modules/(?!(@faker-js|@klx)/)',
],
globals: {
'ts-jest': {
tsconfig: '<rootDir>/tsconfig.spec.json',
babelConfig: true,
},
},
};

5
apps/board/package.json Normal file
View File

@@ -0,0 +1,5 @@
{
"name": "@klx/board",
"version": "0.0.1",
"private": true
}

39
apps/board/project.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "@klx/board",
"targets": {
"serve": {
"continuous": true,
"dependsOn": [
"^build"
],
"options": {
"cwd": "apps/board",
"args": [
"--node-env=development"
],
"env": {},
"command": "rspack serve"
},
"syncGenerators": [
"@nx/js:typescript-sync"
],
"executor": "nx:run-commands",
"configurations": {},
"parallelism": true
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "apps/board/jest.config.js",
"passWithNoTests": true
},
"configurations": {
"ci": {
"ci": true,
"codeCoverage": true
}
}
}
}
}

View File

@@ -0,0 +1,35 @@
const { NxAppRspackPlugin } = require('@nx/rspack/app-plugin')
const { NxReactRspackPlugin } = require('@nx/rspack/react-plugin')
const { join } = require('path')
module.exports = {
output: {
path: join(__dirname, 'dist'),
},
devServer: {
port: 4200,
open: true,
historyApiFallback: {
index: '/index.html',
disableDotRule: true,
htmlAcceptHeaders: ['text/html', 'application/xhtml+xml'],
},
},
plugins: [
new NxAppRspackPlugin({
tsConfig: './tsconfig.app.json',
main: './src/main.tsx',
index: './src/index.html',
baseHref: '/',
assets: ['./src/assets'],
styles: ['./src/styles.css'],
outputHashing: process.env['NODE_ENV'] === 'production' ? 'all' : 'none',
optimization: process.env['NODE_ENV'] === 'production',
}),
new NxReactRspackPlugin({
// Uncomment this line if you don't want to use SVGR
// See: https://react-svgr.com/
// svgr: false
}),
],
}

View File

View File

@@ -0,0 +1,59 @@
import {useState, useRef} from 'react'
import {observer} from 'mobx-react-lite'
import {useBoardStore} from '../hooks/useBoardStore'
import {useClickOutside} from '../hooks/useClickOutside'
import {PlusIcon, TextIcon, ImageIcon, EmbedIcon} from './Icons'
export const AddIdeaButton = observer(() => {
const [isOpen, setIsOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
const store = useBoardStore()
useClickOutside(menuRef, () => setIsOpen(false))
const handleAddIdea = (type: 'text' | 'image' | 'iframe') => {
store.addIdeaAtPosition({x: 100 + store.ideas.length * 20, y: 120}, type, '')
setIsOpen(false)
}
return (
<div className="absolute left-2 top-2 z-10" ref={menuRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center justify-center rounded cursor-pointer text-white hover:opacity-90 transition-opacity bg-sky-500"
style={{
width: '44px',
height: '44px',
}}
>
<PlusIcon size={24} />
</button>
{isOpen && (
<div className="absolute top-full left-0 mt-2 bg-gray-200 border rounded-sm shadow-lg flex flex-col gap-2 p-1 w-max shadow-sm">
<button
onClick={() => handleAddIdea('text')}
className="flex items-center gap-2 px-3 py-2 text-left text-sm hover:bg-gray-100 rounded-sm transition-colors"
>
<TextIcon size={18} />
Text
</button>
<button
onClick={() => handleAddIdea('image')}
className="flex items-center gap-2 px-3 py-2 text-left text-sm hover:bg-gray-100 rounded-sm transition-colors"
>
<ImageIcon size={18} />
Image
</button>
<button
onClick={() => handleAddIdea('iframe')}
className="flex items-center gap-2 px-3 py-2 text-left text-sm hover:bg-gray-100 rounded-sm transition-colors"
>
<EmbedIcon size={18} />
Embed
</button>
</div>
)}
</div>
)
})

View File

@@ -0,0 +1,24 @@
import {useMemo} from 'react'
import {Header, Footer} from '@klx/ui'
import {Board} from './Board'
import {BoardStore} from '../store/BoardStore'
import {BoardStoreContext} from '../context/BoardContext'
const APP_NAME = '@klx/board'
export function App() {
// We get the store instance once here
const store = useMemo(() => new BoardStore(), [])
return (
<BoardStoreContext.Provider value={store}>
<div className="flex flex-col h-screen">
<Header title={APP_NAME} />
<main className="flex-1 overflow-hidden">
<Board />
</main>
<Footer />
</div>
</BoardStoreContext.Provider>
)
}

View File

@@ -0,0 +1,67 @@
import {observer} from 'mobx-react-lite'
import {DraggableIdea} from './DraggableIdea'
import {AddIdeaButton} from './AddIdeaButton'
import {detectUrlType} from '../features/autoDetect/autoDetect'
import {useContextMenu} from '../hooks/useContextMenu'
import {ContextMenu} from './ContextMenu'
import {useClipboard} from '../hooks/useClipboard'
import {useBoardStore} from '../hooks/useBoardStore'
export const Board = observer(() => {
const store = useBoardStore()
const {contextMenu, handleContextMenu, closeContextMenu} = useContextMenu()
const {readFromClipboard} = useClipboard()
const handlePaste = async () => {
const text = await readFromClipboard()
if (text) {
const position = {x: 0, y: 0}
if (contextMenu) {
position.x = contextMenu.x
position.y = contextMenu.y
}
const type = detectUrlType(text)
store.addIdeaAtPosition(position, type, text)
}
}
return (
<div className="h-full flex flex-col relative">
<div
className="flex-1 relative"
style={{
position: 'relative',
width: '100%',
}}
onContextMenu={handleContextMenu}
onDrop={e => {
e.preventDefault()
}}
>
<AddIdeaButton />
{store.ideas.map(idea => (
<DraggableIdea key={idea.id} idea={idea} />
))}
</div>
{contextMenu && (
<ContextMenu
onClose={closeContextMenu}
position={{x: contextMenu.x, y: contextMenu.y}}
actions={[
{
label: 'Paste',
onClick: async () => {
closeContextMenu()
handlePaste()
},
},
{
label: 'Close',
onClick: closeContextMenu,
},
]}
/>
)}
</div>
)
})

View File

@@ -0,0 +1,31 @@
import { useRef } from "react";
import { useClickOutside } from "../hooks/useClickOutside";
export const ContextMenu = ({ actions, position, onClose }: { actions: { label: string, onClick: () => void }[], position: { x: number, y: number }, onClose: () => void }) => {
const menuRef = useRef<HTMLDivElement>(null);
useClickOutside(menuRef, () => {
onClose();
});
return (
<div
className="fixed bg-gray-200 border rounded-sm shadow-lg flex flex-col gap-2 p-1 shadow-sm"
style={{
top: position.y,
left: position.x,
}}
ref={menuRef}
>
{actions.map(action => (
<button
key={action.label}
onClick={action.onClick}
className="flex items-center gap-2 px-3 py-2 text-left text-sm hover:bg-gray-100 rounded-sm transition-colors"
>
{action.label}
</button>
))}
</div>
)
}

View File

@@ -0,0 +1,341 @@
import {useState} from 'react'
import {Rnd} from 'react-rnd'
import type {ImageIdea, IframeIdea, TextIdea} from '@klx/models'
import {observer} from 'mobx-react-lite'
import {detectUrlType, parseIframe} from '../features/autoDetect/autoDetect'
import {TextIcon, ImageIcon, EmbedIcon} from './Icons'
import {useContextMenu} from '../hooks/useContextMenu'
import {ContextMenu} from './ContextMenu'
import {useBoardStore} from '../hooks/useBoardStore'
export const DraggableIdea = observer(({idea}: {idea: TextIdea | ImageIdea | IframeIdea}) => {
const [isEditing, setIsEditing] = useState(false)
const {contextMenu, handleContextMenu, handleTouchStart, handleTouchEnd, closeContextMenu} = useContextMenu()
const store = useBoardStore()
return (
<>
<Rnd
size={{width: idea.size.width, height: idea.size.height}}
position={{x: idea.position.x, y: idea.position.y}}
onDragStop={(_, d) => {
idea.updatePosition(d.x, d.y)
}}
onResizeStop={(e, dir, elementRef, delta, newPosition) => {
idea.updateSize(idea.size.width + delta.width, idea.size.height + delta.height)
idea.updatePosition(newPosition.x, newPosition.y)
}}
bounds="parent"
className="border border-gray-300 p-2 shadow-md cursor-move hover:shadow-lg transition-shadow"
style={{background: idea.color}}
>
<div
className="flex flex-col justify-center items-center h-full w-full pt-6 hover:opacity-80 transition-opacity relative"
onContextMenu={(e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
handleContextMenu(e)
}}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{renderIdeaContent(idea, isEditing, setIsEditing)}
{!isEditing && (
<div className="absolute top-1 right-1 opacity-0 hover:opacity-100 sm:opacity-0 md:opacity-0 lg:opacity-0 touch:opacity-100 transition-opacity">
<button
onClick={(e) => {
e.stopPropagation()
setIsEditing(true)
}}
className="p-1 bg-blue-500 text-white rounded text-xs hover:bg-blue-600"
title="Edit"
aria-label="Edit idea"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M11.586 2.414a2 2 0 00-2.828 0l-7.071 7.071a2 2 0 00-.511 1.03l-1.048 3.536a.5.5 0 00.638.638l3.536-1.048a2 2 0 001.03-.511l7.071-7.071a2 2 0 000-2.828l-1.417-1.417z" />
</svg>
</button>
</div>
)}
</div>
</Rnd>
{contextMenu && (
<ContextMenu
onClose={closeContextMenu}
position={{x: contextMenu.x, y: contextMenu.y}}
actions={[
{
label: 'Edit',
onClick: () => {
closeContextMenu()
setIsEditing(true)
},
},
{
label: 'Delete',
onClick: () => {
closeContextMenu()
store.removeIdea(idea.id)
},
},
]}
/>
)}
</>
)
})
const renderIdeaContent = (idea: TextIdea | ImageIdea | IframeIdea, isEditing: boolean, setIsEditing: (v: boolean) => void) => {
switch (idea.type) {
case 'text':
return <TextIdeaContent idea={idea} isEditing={isEditing} setIsEditing={setIsEditing} />
case 'image':
return <ImageIdeaContent idea={idea} isEditing={isEditing} setIsEditing={setIsEditing} />
case 'iframe':
return <IframeIdeaContent idea={idea} isEditing={isEditing} setIsEditing={setIsEditing} />
}
}
interface EditInputProps {
value: string
onChange: (v: string) => void
onSave: () => void
onCancel: () => void
placeholder: string
isTextarea?: boolean
onKeyDown?: (e: React.KeyboardEvent) => void
}
const EditInput = observer(({value, onChange, onSave, onCancel, placeholder, isTextarea = false, onKeyDown}: EditInputProps) => {
return (
<div className="flex flex-col gap-2" onClick={e => e.stopPropagation()} onMouseDown={e => e.stopPropagation()} onTouchStart={e => e.stopPropagation()}>
{isTextarea ? (
<textarea
value={value}
onChange={e => onChange(e.target.value)}
placeholder={placeholder}
className="w-full text-center flex-1 px-2 py-1 border border-gray-400 rounded resize-none focus:outline-none focus:border-blue-500"
autoFocus
onKeyDown={onKeyDown}
/>
) : (
<input
type="text"
value={value}
onChange={e => onChange(e.target.value)}
placeholder={placeholder}
className="w-full px-2 py-1 border border-gray-400 rounded text-sm focus:outline-none focus:border-blue-500"
autoFocus
onKeyDown={onKeyDown}
/>
)}
<div className="flex gap-1 justify-center">
<button
onClick={(e) => {
e.stopPropagation()
onSave()
}}
onMouseDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
className="px-2 py-1 bg-sky-500 text-white text-xs rounded hover:bg-sky-600"
>
Save
</button>
<button
onClick={(e) => {
e.stopPropagation()
onCancel()
}}
onMouseDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
className="px-2 py-1 bg-gray-400 text-white text-xs rounded hover:bg-gray-500"
>
Cancel
</button>
</div>
</div>
)
})
interface EmptyStateProps {
type: 'text' | 'image' | 'iframe'
onClick: () => void
}
const EmptyState = observer(({type, onClick}: EmptyStateProps) => {
const getIcon = () => {
switch (type) {
case 'text':
return <TextIcon size={32} />
case 'image':
return <ImageIcon size={32} />
case 'iframe':
return <EmbedIcon size={32} />
}
}
const getMessage = () => {
switch (type) {
case 'text':
return 'Click to add text'
case 'image':
return 'Click to add image URL'
case 'iframe':
return 'Click to add embed URL'
}
}
return (
<div className="text-gray-400 flex items-center flex-col cursor-pointer" onClick={onClick}>
<p className="text-2xl mb-2">{getIcon()}</p>
<p className="text-sm">{getMessage()}</p>
</div>
)
})
export const ImageIdeaContent = observer(
({idea, isEditing, setIsEditing}: {idea: ImageIdea; isEditing: boolean; setIsEditing: (v: boolean) => void}) => {
const [input, setInput] = useState(idea.url)
const handleSave = () => {
idea.updateUrl(input)
setIsEditing(false)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSave()
} else if (e.key === 'Escape') {
setIsEditing(false)
setInput(idea.url)
}
}
if (isEditing) {
return (
<EditInput
value={input}
onChange={setInput}
onSave={handleSave}
onCancel={() => {
setIsEditing(false)
setInput(idea.url)
}}
placeholder="Enter image URL..."
onKeyDown={handleKeyDown}
/>
)
}
if (!idea.url) {
return <EmptyState type="image" onClick={() => setIsEditing(true)} />
}
return (
<div className="w-full h-full flex items-center justify-center overflow-hidden">
<img src={idea.url} alt={idea.alt} className="max-w-full max-h-full object-contain pointer-events-none" />
</div>
)
}
)
export const IframeIdeaContent = observer(
({idea, isEditing, setIsEditing}: {idea: IframeIdea; isEditing: boolean; setIsEditing: (v: boolean) => void}) => {
const [input, setInput] = useState(idea.url)
const handleSave = () => {
const detectedType = detectUrlType(input)
if (detectedType === 'iframe') {
const parsed = parseIframe(input)
if (parsed) {
idea.updateUrl(parsed.src)
} else {
idea.updateUrl(input)
}
} else {
idea.updateUrl(input)
}
setIsEditing(false)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSave()
} else if (e.key === 'Escape') {
setIsEditing(false)
setInput(idea.url)
}
}
if (isEditing) {
return (
<EditInput
value={input}
onChange={setInput}
onSave={handleSave}
onCancel={() => {
setIsEditing(false)
setInput(idea.url)
}}
placeholder="Enter embed URL or HTML..."
onKeyDown={handleKeyDown}
/>
)
}
if (!idea.url) {
return <EmptyState type="iframe" onClick={() => setIsEditing(true)} />
}
return (
<div className="w-full h-full flex items-center justify-center" onClick={() => setIsEditing(true)}>
<iframe src={idea.url} title={idea.title} className="w-full h-full border-none" />
</div>
)
}
)
export const TextIdeaContent = observer(
({idea, isEditing, setIsEditing}: {idea: TextIdea; isEditing: boolean; setIsEditing: (v: boolean) => void}) => {
const [input, setInput] = useState(idea.content)
const handleSave = () => {
idea.updateContent(input)
setIsEditing(false)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
setIsEditing(false)
setInput(idea.content)
}
}
if (isEditing) {
return (
<EditInput
value={input}
onChange={setInput}
onSave={handleSave}
onCancel={() => {
setIsEditing(false)
setInput(idea.content)
}}
placeholder="Enter your text..."
isTextarea
onKeyDown={handleKeyDown}
/>
)
}
if (!idea.content) {
return <EmptyState type="text" onClick={() => setIsEditing(true)} />
}
return (
<div className="w-full text-center">
<p onClick={() => setIsEditing(true)} className="break-words cursor-text">{idea.content}</p>
</div>
)
}
)

View File

@@ -0,0 +1,96 @@
export const TextIcon = ({size = 24}: {size?: number}) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="4 7 4 4 20 4 20 7" />
<line x1="9" y1="20" x2="15" y2="20" />
<line x1="12" y1="3" x2="12" y2="20" />
</svg>
)
export const ImageIcon = ({size = 24}: {size?: number}) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
)
export const EmbedIcon = ({size = 24}: {size?: number}) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" />
</svg>
)
export const PlusIcon = ({size = 24}: {size?: number}) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
)
export const CheckIcon = ({size = 24}: {size?: number}) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
)
export const CloseIcon = ({size = 24}: {size?: number}) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
)

View File

@@ -0,0 +1,4 @@
import {createContext} from 'react'
import {BoardStore} from '../store/BoardStore'
export const BoardStoreContext = createContext<BoardStore | null>(null)

View File

@@ -0,0 +1,211 @@
import {detectUrlType, parseIframe} from './autoDetect'
describe('autoDetect - detectUrlType', () => {
describe('Image detection', () => {
it('should detect .jpg images', () => {
const result = detectUrlType('https://example.com/image.jpg')
expect(result).toBe('image')
})
it('should detect .jpeg images', () => {
const result = detectUrlType('https://example.com/photo.jpeg')
expect(result).toBe('image')
})
it('should detect .png images', () => {
const result = detectUrlType('https://example.com/image.png')
expect(result).toBe('image')
})
it('should detect .gif images', () => {
const result = detectUrlType('https://example.com/animation.gif')
expect(result).toBe('image')
})
it('should detect .webp images', () => {
const result = detectUrlType('https://example.com/image.webp')
expect(result).toBe('image')
})
it('should detect imgur URLs', () => {
const result = detectUrlType('https://i.imgur.com/abc123.jpg')
expect(result).toBe('image')
})
it('should detect reddit image URLs', () => {
const result = detectUrlType('https://i.redd.it/abc123.jpg')
expect(result).toBe('image')
})
it('should detect unsplash URLs', () => {
const result = detectUrlType('https://unsplash.com/photos/abc123')
expect(result).toBe('image')
})
it('should be case-insensitive for image extensions', () => {
const result = detectUrlType('https://example.com/image.JPG')
expect(result).toBe('image')
})
})
describe('Iframe detection', () => {
it('should detect YouTube embed URLs', () => {
const result = detectUrlType('https://youtube.com/embed/dQw4w9WgXcQ')
expect(result).toBe('iframe')
})
it('should detect youtu.be short links', () => {
const result = detectUrlType('https://youtu.be/dQw4w9WgXcQ')
expect(result).toBe('iframe')
})
it('should detect Vimeo URLs', () => {
const result = detectUrlType('https://vimeo.com/123456789')
expect(result).toBe('iframe')
})
it('should detect Twitter URLs', () => {
const result = detectUrlType('https://x.com/username/status/123456')
expect(result).toBe('iframe')
})
it('should detect CodePen URLs', () => {
const result = detectUrlType('https://codepen.io/user/pen/abc123')
expect(result).toBe('iframe')
})
it('should detect CodeSandbox URLs', () => {
const result = detectUrlType('https://codesandbox.io/s/abc123')
expect(result).toBe('iframe')
})
it('should detect Figma URLs', () => {
const result = detectUrlType('https://figma.com/file/abc123')
expect(result).toBe('iframe')
})
it('should detect Spotify URLs', () => {
const result = detectUrlType('https://spotify.com/track/abc123')
expect(result).toBe('iframe')
})
it('should be case-insensitive for embed patterns', () => {
const result = detectUrlType('HTTPS://YOUTUBE.COM/EMBED/DQW4W9WGXCQ')
expect(result).toBe('iframe')
})
})
describe('Text detection', () => {
it('should detect plain text', () => {
const result = detectUrlType('Just some regular text')
expect(result).toBe('text')
})
it('should detect generic URLs', () => {
const result = detectUrlType('https://example.com/page')
expect(result).toBe('text')
})
it('should detect regular http URLs', () => {
const result = detectUrlType('https://example.com/page')
expect(result).toBe('text')
})
it('should handle empty strings', () => {
const result = detectUrlType('')
expect(result).toBe('text')
})
it('should handle URLs without extensions', () => {
const result = detectUrlType('https://mywebsite.com/products')
expect(result).toBe('text')
})
})
})
describe('autoDetect - parseIframe', () => {
it('should extract src from valid iframe HTML', () => {
const html = '<iframe src="https://youtube.com/embed/abc123" width="560" height="315"></iframe>'
const result = parseIframe(html)
expect(result).not.toBeNull()
expect(result?.src).toBe('https://youtube.com/embed/abc123')
})
it('should extract title from iframe HTML', () => {
const html = '<iframe src="https://example.com" title="My Embed" width="560" height="315"></iframe>'
const result = parseIframe(html)
expect(result?.title).toBe('My Embed')
})
it('should use default title if not provided', () => {
const html = '<iframe src="https://example.com" width="560" height="315"></iframe>'
const result = parseIframe(html)
expect(result?.title).toBe('Embedded Content')
})
it('should extract width and height', () => {
const html = '<iframe src="https://example.com" width="800" height="600"></iframe>'
const result = parseIframe(html)
expect(result?.width).toBe(800)
expect(result?.height).toBe(600)
})
it('should use default dimensions if not provided', () => {
const html = '<iframe src="https://example.com"></iframe>'
const result = parseIframe(html)
expect(result?.width).toBe(200)
expect(result?.height).toBe(200)
})
it('should return null for invalid HTML', () => {
const result = parseIframe('not valid html at all')
expect(result).toBeNull()
})
it('should return null if no iframe element is found', () => {
const html = '<div><p>Hello World</p></div>'
const result = parseIframe(html)
expect(result).toBeNull()
})
it('should handle iframe with multiple attributes', () => {
const html = '<iframe src="https://example.com/" title="Test" width="560" height="315" frameborder="0" allow="accelerometer" class="my-class"></iframe>'
const result = parseIframe(html)
expect(result?.src).toBe('https://example.com/')
expect(result?.title).toBe('Test')
expect(result?.width).toBe(560)
expect(result?.height).toBe(315)
})
it('should handle iframe with non-numeric dimensions', () => {
const html = '<iframe src="https://example.com" width="abc" height="def"></iframe>'
const result = parseIframe(html)
// parseInt returns NaN which should default to 200
expect(result?.width).toBe(200)
expect(result?.height).toBe(200)
})
it('should handle iframe with no width/height attributes', () => {
const html = '<iframe src="https://example.com"></iframe>'
const result = parseIframe(html)
expect(result?.width).toBe(200)
expect(result?.height).toBe(200)
})
it('should extract src from nested HTML', () => {
const html = '<div><section><iframe src="https://youtube.com/embed/xyz789"></iframe></section></div>'
const result = parseIframe(html)
expect(result?.src).toBe('https://youtube.com/embed/xyz789')
})
})

View File

@@ -0,0 +1,67 @@
export const detectUrlType = (input: string): 'image' | 'iframe' | 'text' => {
const lowerUrl = input.toLowerCase()
// Image extensions
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp', '.ico']
if (imageExtensions.some(ext => lowerUrl.includes(ext))) {
return 'image'
}
// Known image domains
const imageHosts = [
'imgur.com',
'i.redd.it',
'i.imgur.com',
'unsplash.com',
'pexels.com',
'picsum.photos',
]
if (imageHosts.some(host => lowerUrl.includes(host))) {
return 'image'
}
// list of trusted domains
// As iframe may render outside content, we need to account for that
const embedPatterns = [
'youtube.com/embed',
'youtu.be/',
'vimeo.com',
'x.com',
'codepen.io',
'codesandbox.io',
'figma.com',
'spotify.com',
'soundcloud.com',
'google.com'
]
if (embedPatterns.some(pattern => lowerUrl.includes(pattern))) {
const doc = new DOMParser().parseFromString(lowerUrl, 'text/html')
const errorNode = doc.querySelector('parsererror')
if(errorNode) {
// If the parser fails, we assume it's not a valid iframe
console.error('Error parsing iframe:', errorNode)
return 'text'
}
return 'iframe'
}
return 'text'
}
// Parse iframe HTML code and extract relevant information
// This function assumes the input is a valid iframe HTML string
// This is to avoid rendering directly user sent content into the DOM
export const parseIframe = (htmlCode: string): { src: string; title: string, width: number, height: number } | null => {
const doc = new DOMParser().parseFromString(htmlCode, 'text/html')
const iframe = doc.querySelector('iframe')
if (iframe) {
return {
src: iframe.src,
title: iframe.title || 'Embedded Content',
width: parseInt(iframe.width) || 200,
height: parseInt(iframe.height) || 200,
}
}
return null
}

View File

@@ -0,0 +1,89 @@
import { faker } from '@faker-js/faker'
import { Idea, ImageIdea, TextIdea, IframeIdea } from '@klx/models'
import { parseIframe } from '../autoDetect/autoDetect'
const COLORS = [
'rgb(255, 231, 117)',
'rgb(195, 241, 140)',
'rgb(255, 177, 177)',
'rgb(153, 220, 255)',
]
const getRandomColor = () => {
return COLORS[Math.floor(Math.random() * COLORS.length)]
}
export const createIdea = (overrides: Partial<Idea> = {}, text = ''): TextIdea | ImageIdea | IframeIdea => {
const id = String(Date.now())
switch (overrides.type) {
case 'image':
return new ImageIdea({
id,
author: {
uuid: faker.string.uuid(),
name: faker.person.fullName(),
email: faker.internet.email(),
job: faker.person.jobTitle(),
},
position: { x: 40, y: 80 },
size: { width: 200, height: 200 },
title: 'Idea',
color: getRandomColor(),
url: text,
alt: faker.lorem.words(3),
...overrides,
})
case 'iframe': {
const iframeData = parseIframe(text)
return new IframeIdea({
id,
author: {
uuid: faker.string.uuid(),
name: faker.person.fullName(),
email: faker.internet.email(),
job: faker.person.jobTitle(),
},
position: { x: 40, y: 80 },
size: { width: iframeData?.width || 200, height: iframeData?.height || 200 },
title: iframeData?.title || 'Embedded Content',
url: iframeData?.src || '',
color: getRandomColor(),
...overrides,
})
}
case 'text':
return new TextIdea({
id,
author: {
uuid: faker.string.uuid(),
name: faker.person.fullName(),
email: faker.internet.email(),
job: faker.person.jobTitle(),
},
position: { x: 40, y: 80 },
size: { width: 200, height: 200 },
title: 'Idea',
content: text,
color: getRandomColor(),
...overrides,
})
default:
return new TextIdea({
id,
author: {
uuid: faker.string.uuid(),
name: faker.person.fullName(),
email: faker.internet.email(),
job: faker.person.jobTitle(),
},
position: { x: 40, y: 80 },
size: { width: 200, height: 200 },
title: 'Idea',
content: faker.lorem.paragraph(1),
color: getRandomColor(),
...overrides,
})
}
}

View File

@@ -0,0 +1,10 @@
import {useContext} from 'react'
import {BoardStoreContext} from '../context/BoardContext'
export const useBoardStore = () => {
const store = useContext(BoardStoreContext)
if (!store) {
throw new Error('useBoardStore must be used within BoardStoreProvider')
}
return store
}

View File

@@ -0,0 +1,23 @@
import { useEffect, RefObject } from "react";
export const useClickOutside = <T extends HTMLElement = HTMLElement>(ref: RefObject<T | null>, onClickOutside: () => void) => {
useEffect(() => {
const handleClick = (event: MouseEvent | TouchEvent) => {
const target = event.target as Node;
if(!ref.current) return;
if (ref.current && !ref.current.contains(target)) {
onClickOutside();
}
};
document.addEventListener("mousedown", handleClick);
document.addEventListener("touchstart", handleClick);
return () => {
document.removeEventListener("mousedown", handleClick);
document.removeEventListener("touchstart", handleClick);
};
}, [ref, onClickOutside]);
};

View File

@@ -0,0 +1,36 @@
import { useState, useCallback } from "react";
export const useClipboard = () => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const readFromClipboard = useCallback(async (): Promise<string | null> => {
setIsLoading(true);
setError(null);
try {
const text = await navigator.clipboard.readText();
return text;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to read from clipboard";
setError(errorMessage);
return null;
} finally {
setIsLoading(false);
}
}, []);
const copyToClipboard = useCallback(async (text: string): Promise<void> => {
setIsLoading(true);
setError(null);
try {
await navigator.clipboard.writeText(text);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to write to clipboard";
setError(errorMessage);
} finally {
setIsLoading(false);
}
}, []);
return { isLoading, error, readFromClipboard, copyToClipboard };
};

View File

@@ -0,0 +1,39 @@
import { useState, useCallback, useRef } from "react";
export const useContextMenu = () => {
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
const touchStartTimeRef = useRef<number>(0);
const touchStartXRef = useRef<number>(0);
const touchStartYRef = useRef<number>(0);
const handleContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({ x: e.clientX, y: e.clientY });
}, []);
const handleTouchStart = useCallback((e: React.TouchEvent) => {
touchStartTimeRef.current = Date.now();
touchStartXRef.current = e.touches[0].clientX;
touchStartYRef.current = e.touches[0].clientY;
}, []);
const handleTouchEnd = useCallback((e: React.TouchEvent) => {
const touchDuration = Date.now() - touchStartTimeRef.current;
const touchX = e.changedTouches[0].clientX;
const touchY = e.changedTouches[0].clientY;
const touchMoved = Math.abs(touchX - touchStartXRef.current) > 10 || Math.abs(touchY - touchStartYRef.current) > 10;
// Long press: hold for 500ms without moving
if (touchDuration > 500 && !touchMoved) {
e.preventDefault();
setContextMenu({ x: touchX, y: touchY });
}
}, []);
const closeContextMenu = useCallback(() => {
setContextMenu(null);
}, []);
return { contextMenu, handleContextMenu, handleTouchStart, handleTouchEnd, closeContextMenu };
};

14
apps/board/src/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Board</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>

12
apps/board/src/main.tsx Normal file
View File

@@ -0,0 +1,12 @@
import {StrictMode} from 'react'
import * as ReactDOM from 'react-dom/client'
import {App} from './components/App'
import './styles.css'
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)
root.render(
<StrictMode>
<App />
</StrictMode>
)

View File

@@ -0,0 +1,220 @@
import {BoardStore} from './BoardStore'
import {TextIdea, ImageIdea} from '@klx/models'
// Helper function to create test Idea objects
const createTestTextIdea = () => {
return new TextIdea({
id: Math.random().toString(),
author: {name: 'Test User'},
position: {x: 0, y: 0},
size: {width: 200, height: 200},
title: 'Test',
color: '#ffffff',
content: 'Test content',
})
}
const createTestImageIdea = () => {
return new ImageIdea({
id: Math.random().toString(),
author: {name: 'Test User'},
position: {x: 0, y: 0},
size: {width: 200, height: 200},
title: 'Test Image',
color: '#ffffff',
url: 'https://example.com/image.jpg',
alt: 'Test alt',
})
}
describe('BoardStore', () => {
let store: BoardStore
beforeEach(() => {
store = new BoardStore()
})
describe('addIdea', () => {
it('should add a new idea to the store', () => {
const initialCount = store.ideas.length
const textIdea = createTestTextIdea()
store.addIdea(textIdea)
expect(store.ideas.length).toBe(initialCount + 1)
expect(store.ideas).toContain(textIdea)
})
it('should allow adding multiple ideas', () => {
const idea1 = createTestTextIdea()
const idea2 = createTestImageIdea()
store.addIdea(idea1)
store.addIdea(idea2)
expect(store.ideas.length).toBeGreaterThanOrEqual(3) // Initial idea + 2 new ones
expect(store.ideas).toContain(idea1)
expect(store.ideas).toContain(idea2)
})
})
describe('removeIdea', () => {
it('should remove an idea by its ID', () => {
const textIdea = createTestTextIdea()
store.addIdea(textIdea)
const initialCount = store.ideas.length
store.removeIdea(textIdea.id)
expect(store.ideas.length).toBe(initialCount - 1)
expect(store.ideas).not.toContain(textIdea)
})
it('should not crash when removing non-existent ID', () => {
const initialCount = store.ideas.length
expect(() => {
store.removeIdea('non-existent-id')
}).not.toThrow()
expect(store.ideas.length).toBe(initialCount)
})
it('should only remove the specified idea', () => {
const idea1 = createTestTextIdea()
const idea2 = createTestImageIdea()
store.addIdea(idea1)
store.addIdea(idea2)
store.removeIdea(idea1.id)
expect(store.ideas).not.toContain(idea1)
expect(store.ideas).toContain(idea2)
})
})
describe('addIdeaAtPosition', () => {
it('should create and add a text idea at position', () => {
const position = {x: 100, y: 200}
const initialCount = store.ideas.length
store.addIdeaAtPosition(position, 'text', 'Hello')
expect(store.ideas.length).toBe(initialCount + 1)
const newIdea = store.ideas[store.ideas.length - 1]
expect(newIdea.type).toBe('text')
expect(newIdea.position).toEqual(position)
})
it('should create and add an image idea at position', () => {
const position = {x: 50, y: 75}
store.addIdeaAtPosition(position, 'image', 'https://example.com/image.jpg')
const newIdea = store.ideas[store.ideas.length - 1]
expect(newIdea.type).toBe('image')
expect(newIdea.position).toEqual(position)
})
it('should create and add an iframe idea at position', () => {
const position = {x: 150, y: 250}
store.addIdeaAtPosition(position, 'iframe', 'https://youtube.com/embed/abc123')
const newIdea = store.ideas[store.ideas.length - 1]
expect(newIdea.type).toBe('iframe')
expect(newIdea.position).toEqual(position)
})
it('should auto-detect type when not specified', () => {
const position = {x: 100, y: 100}
store.addIdeaAtPosition(position, undefined, 'https://imgur.com/abc123.jpg')
const newIdea = store.ideas[store.ideas.length - 1]
expect(newIdea.type).toBe('image')
})
})
describe('getIdeaById', () => {
it('should return an idea by its ID', () => {
const textIdea = createTestTextIdea()
store.addIdea(textIdea)
const found = store.getIdeaById(textIdea.id)
expect(found).toBe(textIdea)
})
it('should return undefined for non-existent ID', () => {
const found = store.getIdeaById('non-existent-id')
expect(found).toBeUndefined()
})
})
describe('clearAll', () => {
it('should remove all ideas from the store', () => {
const idea1 = createTestTextIdea()
const idea2 = createTestImageIdea()
store.addIdea(idea1)
store.addIdea(idea2)
store.clearAll()
expect(store.ideas.length).toBe(0)
})
})
describe('updateIdea', () => {
it('should update idea properties', () => {
const textIdea = createTestTextIdea()
store.addIdea(textIdea)
const newContent = 'Updated content'
// Update the idea content directly through the store
const updated = Object.assign({}, {content: newContent})
store.updateIdea(textIdea.id, updated)
expect(textIdea.content).toBe(newContent)
})
it('should not crash when updating non-existent ID', () => {
expect(() => {
store.updateIdea('non-existent-id', {})
}).not.toThrow()
})
})
describe('Store initialization', () => {
it('should initialize with one default idea', () => {
const newStore = new BoardStore()
expect(newStore.ideas.length).toBe(1)
})
it('should have observable ideas array', () => {
const idea = createTestTextIdea()
store.addIdea(idea)
// MobX makes the array observable
// Modifying the array should trigger reactivity
expect(store.ideas).toBeDefined()
expect(Array.isArray(store.ideas)).toBe(true)
})
})
describe('Idea uniqueness', () => {
it('should generate unique IDs for each idea', () => {
const idea1 = createTestTextIdea()
const idea2 = createTestTextIdea()
const idea3 = createTestImageIdea()
const ids = [idea1.id, idea2.id, idea3.id]
const uniqueIds = new Set(ids)
expect(uniqueIds.size).toBe(3)
})
})
})

View File

@@ -0,0 +1,46 @@
import type {TextIdea, ImageIdea, IframeIdea} from '@klx/models'
import {makeAutoObservable} from 'mobx'
import {createIdea} from '../features/create/create'
import {detectUrlType} from '../features/autoDetect/autoDetect'
export type Idea = TextIdea | ImageIdea | IframeIdea
export class BoardStore {
ideas: Idea[] = []
constructor() {
// Initialize with one default idea
this.ideas = [createIdea({}, '')]
makeAutoObservable(this)
}
addIdea(idea: Idea) {
this.ideas.push(idea)
}
addIdeaAtPosition(position: {x: number; y: number}, type?: 'text' | 'image' | 'iframe', content = '') {
// Auto-detect type if not specified
const detectedType = type || detectUrlType(content)
const idea = createIdea({position, type: detectedType as 'text' | 'image' | 'iframe'}, content)
this.addIdea(idea)
}
removeIdea(id: string) {
this.ideas = this.ideas.filter(idea => idea.id !== id)
}
updateIdea(id: string, updates: Partial<Idea>) {
const idea = this.ideas.find(i => i.id === id)
if (idea) {
Object.assign(idea, updates)
}
}
clearAll() {
this.ideas = []
}
getIdeaById(id: string): Idea | undefined {
return this.ideas.find(idea => idea.id === id)
}
}

89
apps/board/src/styles.css Normal file
View File

@@ -0,0 +1,89 @@
html {
-webkit-text-size-adjust: 100%;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
line-height: 1.5;
tab-size: 4;
scroll-behavior: smooth;
}
body {
font-family: inherit;
line-height: inherit;
margin: 0;
}
h1,
h2,
p,
pre {
margin: 0;
}
*,
::before,
::after {
box-sizing: border-box;
border-width: 0;
border-style: solid;
border-color: currentColor;
}
h1,
h2 {
font-size: inherit;
font-weight: inherit;
}
a {
color: inherit;
text-decoration: inherit;
}
pre {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
'Liberation Mono', 'Courier New', monospace;
}
svg {
display: block;
vertical-align: middle;
shape-rendering: auto;
text-rendering: optimizeLegibility;
}
pre {
background-color: rgba(55, 65, 81, 1);
border-radius: 0.25rem;
color: rgba(229, 231, 235, 1);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
'Liberation Mono', 'Courier New', monospace;
overflow: auto;
padding: 0.5rem 0.75rem;
}
.shadow {
box-shadow: 0 0 #0000, 0 0 #0000, 0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
.rounded {
border-radius: 1.5rem;
}
.wrapper {
width: 100%;
}
.container {
margin-left: auto;
margin-right: auto;
max-width: 768px;
padding-bottom: 3rem;
padding-left: 1rem;
padding-right: 1rem;
color: rgba(55, 65, 81, 1);
width: 100%;
}

View File

@@ -0,0 +1,35 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"tsBuildInfoFile": "dist/tsconfig.app.tsbuildinfo",
"jsx": "react-jsx",
"lib": ["dom"],
"types": ["node", "@nx/react/typings/cssmodule.d.ts", "@nx/react/typings/image.d.ts"],
"rootDir": "src"
},
"exclude": [
"out-tsc",
"dist",
"src/**/*.spec.ts",
"src/**/*.test.ts",
"src/**/*.spec.tsx",
"src/**/*.test.tsx",
"src/**/*.spec.js",
"src/**/*.test.js",
"src/**/*.spec.jsx",
"src/**/*.test.jsx",
"eslint.config.js",
"eslint.config.cjs",
"eslint.config.mjs"
],
"include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"],
"references": [
{
"path": "../../packages/models/tsconfig.lib.json"
},
{
"path": "../../packages/ui/tsconfig.lib.json"
}
]
}

16
apps/board/tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"files": [],
"include": [],
"references": [
{
"path": "../../packages/models"
},
{
"path": "../../packages/ui"
},
{
"path": "./tsconfig.app.json"
}
],
"extends": "../../tsconfig.base.json"
}

View File

@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc/spec",
"types": ["jest", "node"],
"esModuleInterop": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true
},
"files": ["src/test.ts"],
"include": ["src/**/*.spec.ts", "src/**/*.d.ts"]
}