diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..604332b --- /dev/null +++ b/.babelrc @@ -0,0 +1,7 @@ +{ + "presets": [ + ["@babel/preset-env", { "targets": { "node": "current" } }], + "@babel/preset-typescript", + ["@babel/preset-react", { "runtime": "automatic" }] + ] +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6e87a00 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +# Editor configuration, see http://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6ca8edd --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# See https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files for more about ignoring files. + +# compiled output +dist +tmp +out-tsc + +# dependencies +node_modules + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode + +# misc +/.sass-cache +/connect.lock +/coverage +/libpeerconnection.log +npm-debug.log +yarn-error.log +testem.log +/typings + +# System Files +.DS_Store +Thumbs.db + +.nx/cache +.nx/workspace-data +.cursor/rules/nx-rules.mdc +.github/instructions/nx.instructions.md diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..e26f0b3 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +# Add files here to ignore them from prettier formatting +/dist +/coverage +/.nx/cache +/.nx/workspace-data \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..7779e78 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,11 @@ +{ + "printWidth": 100, + "tabWidth": 4, + "useTabs": false, + "semi": false, + "singleQuote": true, + "trailingComma": "es5", + "bracketSpacing": false, + "bracketSameLine": false, + "arrowParens": "avoid" +} \ No newline at end of file diff --git a/apps/admin/eslint.config.mjs b/apps/admin/eslint.config.mjs new file mode 100644 index 0000000..f4b30e5 --- /dev/null +++ b/apps/admin/eslint.config.mjs @@ -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: {}, + }, +] diff --git a/apps/admin/package.json b/apps/admin/package.json new file mode 100644 index 0000000..ee54934 --- /dev/null +++ b/apps/admin/package.json @@ -0,0 +1,5 @@ +{ + "name": "@klx/admin", + "version": "0.0.1", + "private": true +} diff --git a/apps/admin/project.json b/apps/admin/project.json new file mode 100644 index 0000000..e4877c2 --- /dev/null +++ b/apps/admin/project.json @@ -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 + } + } +} \ No newline at end of file diff --git a/apps/admin/rspack.config.js b/apps/admin/rspack.config.js new file mode 100644 index 0000000..2a5a6a5 --- /dev/null +++ b/apps/admin/rspack.config.js @@ -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 + }), + ], +} diff --git a/apps/admin/src/api/fetchUsers.ts b/apps/admin/src/api/fetchUsers.ts new file mode 100644 index 0000000..e06ee08 --- /dev/null +++ b/apps/admin/src/api/fetchUsers.ts @@ -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 => { + 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(), + })) +} diff --git a/apps/admin/src/assets/.gitkeep b/apps/admin/src/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/admin/src/components/App.tsx b/apps/admin/src/components/App.tsx new file mode 100644 index 0000000..a43bc78 --- /dev/null +++ b/apps/admin/src/components/App.tsx @@ -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 ( +
+
+
+ + +
+
+
+ ) +} diff --git a/apps/admin/src/components/Clock.tsx b/apps/admin/src/components/Clock.tsx new file mode 100644 index 0000000..1c12993 --- /dev/null +++ b/apps/admin/src/components/Clock.tsx @@ -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 ( +
+ {pad(time.getHours())}:{pad(time.getMinutes())}:{pad(time.getSeconds())} +
+ ) +} diff --git a/apps/admin/src/components/ClockWrapper.tsx b/apps/admin/src/components/ClockWrapper.tsx new file mode 100644 index 0000000..7ef9a93 --- /dev/null +++ b/apps/admin/src/components/ClockWrapper.tsx @@ -0,0 +1,9 @@ +import { Clock } from './Clock' + +export const ClockWrapper = () => { + return ( +
+ +
+ ) +} diff --git a/apps/admin/src/components/SortHeader.tsx b/apps/admin/src/components/SortHeader.tsx new file mode 100644 index 0000000..b5846f7 --- /dev/null +++ b/apps/admin/src/components/SortHeader.tsx @@ -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 ( + + {label} + {getSortIndicator()} + + ) +} diff --git a/apps/admin/src/components/UsersTable.tsx b/apps/admin/src/components/UsersTable.tsx new file mode 100644 index 0000000..28ec209 --- /dev/null +++ b/apps/admin/src/components/UsersTable.tsx @@ -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([]) + const [selectedUser, setSelectedUser] = useState(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 ( +
+
+

+ Users Management +

+ + {sortedItems.length} / {users.length} users + +
+ + +
+ ) +} diff --git a/apps/admin/src/components/VirtualizedTable.tsx b/apps/admin/src/components/VirtualizedTable.tsx new file mode 100644 index 0000000..fd9e7e9 --- /dev/null +++ b/apps/admin/src/components/VirtualizedTable.tsx @@ -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) => 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 ( +
+ {/* Filter Bar */} +
+ onFilterChange({ name: e.target.value })} + className={`flex-1 ${inputClass}`} + /> + onFilterChange({ email: e.target.value })} + className={`flex-1 ${inputClass} mx-2`} + /> + onFilterChange({ job: e.target.value })} + className={`flex-1 ${inputClass}`} + /> + {hasActiveFilters && ( + + )} +
+ + {/* Table with scrollable container */} +
+ + + + onSort('name')} + /> + onSort('email')} + /> + onSort('job')} + /> + + + + {items.length > 0 ? ( + items.map((user, index) => ( + 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' + } + `} + > + + + + + )) + ) : ( + + + + )} + +
{user.name}{user.email}{user.job}
+ No users found +
+
+
+ ) +} diff --git a/apps/admin/src/hooks/useFilters.ts b/apps/admin/src/hooks/useFilters.ts new file mode 100644 index 0000000..689cc53 --- /dev/null +++ b/apps/admin/src/hooks/useFilters.ts @@ -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({ 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) => { + 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 } +} diff --git a/apps/admin/src/hooks/useSort.ts b/apps/admin/src/hooks/useSort.ts new file mode 100644 index 0000000..dccef4d --- /dev/null +++ b/apps/admin/src/hooks/useSort.ts @@ -0,0 +1,77 @@ +import { useState, useCallback } from 'react' + +export type SortDirection = 'asc' | 'desc' | null +export type SortableKey = keyof T + +interface UseSortOptions { + initialSortKey?: SortableKey + initialDirection?: SortDirection +} + +export const useSort = >( + items: T[], + options?: UseSortOptions +) => { + const [sortKey, setSortKey] = useState | null>( + options?.initialSortKey ?? null + ) + const [direction, setDirection] = useState( + options?.initialDirection ?? null + ) + + const handleSort = useCallback( + (key: SortableKey) => { + // 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, + } +} diff --git a/apps/admin/src/index.html b/apps/admin/src/index.html new file mode 100644 index 0000000..2469791 --- /dev/null +++ b/apps/admin/src/index.html @@ -0,0 +1,14 @@ + + + + + Admin + + + + + + +
+ + diff --git a/apps/admin/src/main.tsx b/apps/admin/src/main.tsx new file mode 100644 index 0000000..776df35 --- /dev/null +++ b/apps/admin/src/main.tsx @@ -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( + + + +) diff --git a/apps/admin/src/styles.css b/apps/admin/src/styles.css new file mode 100644 index 0000000..e3cffa8 --- /dev/null +++ b/apps/admin/src/styles.css @@ -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%; +} \ No newline at end of file diff --git a/apps/admin/src/types.ts b/apps/admin/src/types.ts new file mode 100644 index 0000000..6c046f0 --- /dev/null +++ b/apps/admin/src/types.ts @@ -0,0 +1,2 @@ +// Re-export User from models package for backward compatibility +export type {User} from '@klx/models' diff --git a/apps/admin/src/utils/filterUsers.ts b/apps/admin/src/utils/filterUsers.ts new file mode 100644 index 0000000..8e4f21b --- /dev/null +++ b/apps/admin/src/utils/filterUsers.ts @@ -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 + }) +} diff --git a/apps/admin/tsconfig.app.json b/apps/admin/tsconfig.app.json new file mode 100644 index 0000000..de7d1a0 --- /dev/null +++ b/apps/admin/tsconfig.app.json @@ -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" + } + ] +} diff --git a/apps/admin/tsconfig.json b/apps/admin/tsconfig.json new file mode 100644 index 0000000..4764a39 --- /dev/null +++ b/apps/admin/tsconfig.json @@ -0,0 +1,16 @@ +{ + "files": [], + "include": [], + "references": [ + { + "path": "../../packages/models" + }, + { + "path": "../../packages/ui" + }, + { + "path": "./tsconfig.app.json" + } + ], + "extends": "../../tsconfig.base.json" +} diff --git a/apps/board/eslint.config.mjs b/apps/board/eslint.config.mjs new file mode 100644 index 0000000..e8e4590 --- /dev/null +++ b/apps/board/eslint.config.mjs @@ -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: {}, + }, +]; diff --git a/apps/board/jest.config.js b/apps/board/jest.config.js new file mode 100644 index 0000000..06cf0f7 --- /dev/null +++ b/apps/board/jest.config.js @@ -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: '/tsconfig.spec.json', + babelConfig: true, + }, + }, +}; diff --git a/apps/board/package.json b/apps/board/package.json new file mode 100644 index 0000000..dc634e9 --- /dev/null +++ b/apps/board/package.json @@ -0,0 +1,5 @@ +{ + "name": "@klx/board", + "version": "0.0.1", + "private": true +} diff --git a/apps/board/project.json b/apps/board/project.json new file mode 100644 index 0000000..f0c7a49 --- /dev/null +++ b/apps/board/project.json @@ -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 + } + } + } + } +} \ No newline at end of file diff --git a/apps/board/rspack.config.js b/apps/board/rspack.config.js new file mode 100644 index 0000000..b7be98e --- /dev/null +++ b/apps/board/rspack.config.js @@ -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 + }), + ], +} diff --git a/apps/board/src/assets/.gitkeep b/apps/board/src/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/board/src/components/AddIdeaButton.tsx b/apps/board/src/components/AddIdeaButton.tsx new file mode 100644 index 0000000..14c31f0 --- /dev/null +++ b/apps/board/src/components/AddIdeaButton.tsx @@ -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(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 ( +
+ + + {isOpen && ( +
+ + + +
+ )} +
+ ) +}) diff --git a/apps/board/src/components/App.tsx b/apps/board/src/components/App.tsx new file mode 100644 index 0000000..6791c01 --- /dev/null +++ b/apps/board/src/components/App.tsx @@ -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 ( + +
+
+
+ +
+
+
+
+ ) +} diff --git a/apps/board/src/components/Board.tsx b/apps/board/src/components/Board.tsx new file mode 100644 index 0000000..5574a1f --- /dev/null +++ b/apps/board/src/components/Board.tsx @@ -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 ( +
+
{ + e.preventDefault() + }} + > + + {store.ideas.map(idea => ( + + ))} +
+ {contextMenu && ( + { + closeContextMenu() + handlePaste() + }, + }, + { + label: 'Close', + onClick: closeContextMenu, + }, + ]} + /> + )} +
+ ) +}) diff --git a/apps/board/src/components/ContextMenu.tsx b/apps/board/src/components/ContextMenu.tsx new file mode 100644 index 0000000..61c5116 --- /dev/null +++ b/apps/board/src/components/ContextMenu.tsx @@ -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(null); + + useClickOutside(menuRef, () => { + onClose(); + }); + + return ( +
+ {actions.map(action => ( + + ))} +
+ ) +} diff --git a/apps/board/src/components/DraggableIdea.tsx b/apps/board/src/components/DraggableIdea.tsx new file mode 100644 index 0000000..70f57f5 --- /dev/null +++ b/apps/board/src/components/DraggableIdea.tsx @@ -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 ( + <> + { + 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}} + > +
{ + e.preventDefault() + e.stopPropagation() + handleContextMenu(e) + }} + onTouchStart={handleTouchStart} + onTouchEnd={handleTouchEnd} + > + {renderIdeaContent(idea, isEditing, setIsEditing)} + {!isEditing && ( +
+ +
+ )} +
+
+ {contextMenu && ( + { + 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 + case 'image': + return + case 'iframe': + return + } +} + +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 ( +
e.stopPropagation()} onMouseDown={e => e.stopPropagation()} onTouchStart={e => e.stopPropagation()}> + {isTextarea ? ( +