reinit repo with sources from zipped dir
This commit is contained in:
7
.babelrc
Normal file
7
.babelrc
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"presets": [
|
||||||
|
["@babel/preset-env", { "targets": { "node": "current" } }],
|
||||||
|
"@babel/preset-typescript",
|
||||||
|
["@babel/preset-react", { "runtime": "automatic" }]
|
||||||
|
]
|
||||||
|
}
|
||||||
13
.editorconfig
Normal file
13
.editorconfig
Normal file
@@ -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
|
||||||
40
.gitignore
vendored
Normal file
40
.gitignore
vendored
Normal file
@@ -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
|
||||||
5
.prettierignore
Normal file
5
.prettierignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Add files here to ignore them from prettier formatting
|
||||||
|
/dist
|
||||||
|
/coverage
|
||||||
|
/.nx/cache
|
||||||
|
/.nx/workspace-data
|
||||||
11
.prettierrc
Normal file
11
.prettierrc
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 4,
|
||||||
|
"useTabs": false,
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"bracketSpacing": false,
|
||||||
|
"bracketSameLine": false,
|
||||||
|
"arrowParens": "avoid"
|
||||||
|
}
|
||||||
12
apps/admin/eslint.config.mjs
Normal file
12
apps/admin/eslint.config.mjs
Normal 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
5
apps/admin/package.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "@klx/admin",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
25
apps/admin/project.json
Normal file
25
apps/admin/project.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
35
apps/admin/rspack.config.js
Normal file
35
apps/admin/rspack.config.js
Normal 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
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}
|
||||||
15
apps/admin/src/api/fetchUsers.ts
Normal file
15
apps/admin/src/api/fetchUsers.ts
Normal 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(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
0
apps/admin/src/assets/.gitkeep
Normal file
0
apps/admin/src/assets/.gitkeep
Normal file
18
apps/admin/src/components/App.tsx
Normal file
18
apps/admin/src/components/App.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
20
apps/admin/src/components/Clock.tsx
Normal file
20
apps/admin/src/components/Clock.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
9
apps/admin/src/components/ClockWrapper.tsx
Normal file
9
apps/admin/src/components/ClockWrapper.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
35
apps/admin/src/components/SortHeader.tsx
Normal file
35
apps/admin/src/components/SortHeader.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
53
apps/admin/src/components/UsersTable.tsx
Normal file
53
apps/admin/src/components/UsersTable.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
130
apps/admin/src/components/VirtualizedTable.tsx
Normal file
130
apps/admin/src/components/VirtualizedTable.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
48
apps/admin/src/hooks/useFilters.ts
Normal file
48
apps/admin/src/hooks/useFilters.ts
Normal 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 }
|
||||||
|
}
|
||||||
77
apps/admin/src/hooks/useSort.ts
Normal file
77
apps/admin/src/hooks/useSort.ts
Normal 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
14
apps/admin/src/index.html
Normal 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
11
apps/admin/src/main.tsx
Normal 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
89
apps/admin/src/styles.css
Normal 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
2
apps/admin/src/types.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// Re-export User from models package for backward compatibility
|
||||||
|
export type {User} from '@klx/models'
|
||||||
11
apps/admin/src/utils/filterUsers.ts
Normal file
11
apps/admin/src/utils/filterUsers.ts
Normal 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
|
||||||
|
})
|
||||||
|
}
|
||||||
35
apps/admin/tsconfig.app.json
Normal file
35
apps/admin/tsconfig.app.json
Normal 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
16
apps/admin/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"include": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "../../packages/models"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../../packages/ui"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"extends": "../../tsconfig.base.json"
|
||||||
|
}
|
||||||
12
apps/board/eslint.config.mjs
Normal file
12
apps/board/eslint.config.mjs
Normal 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
17
apps/board/jest.config.js
Normal 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
5
apps/board/package.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "@klx/board",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
39
apps/board/project.json
Normal file
39
apps/board/project.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
35
apps/board/rspack.config.js
Normal file
35
apps/board/rspack.config.js
Normal 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
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}
|
||||||
0
apps/board/src/assets/.gitkeep
Normal file
0
apps/board/src/assets/.gitkeep
Normal file
59
apps/board/src/components/AddIdeaButton.tsx
Normal file
59
apps/board/src/components/AddIdeaButton.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
})
|
||||||
24
apps/board/src/components/App.tsx
Normal file
24
apps/board/src/components/App.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
67
apps/board/src/components/Board.tsx
Normal file
67
apps/board/src/components/Board.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
})
|
||||||
31
apps/board/src/components/ContextMenu.tsx
Normal file
31
apps/board/src/components/ContextMenu.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
341
apps/board/src/components/DraggableIdea.tsx
Normal file
341
apps/board/src/components/DraggableIdea.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
96
apps/board/src/components/Icons.tsx
Normal file
96
apps/board/src/components/Icons.tsx
Normal 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>
|
||||||
|
)
|
||||||
4
apps/board/src/context/BoardContext.ts
Normal file
4
apps/board/src/context/BoardContext.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import {createContext} from 'react'
|
||||||
|
import {BoardStore} from '../store/BoardStore'
|
||||||
|
|
||||||
|
export const BoardStoreContext = createContext<BoardStore | null>(null)
|
||||||
211
apps/board/src/features/autoDetect/autoDetect.spec.ts
Normal file
211
apps/board/src/features/autoDetect/autoDetect.spec.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
67
apps/board/src/features/autoDetect/autoDetect.ts
Normal file
67
apps/board/src/features/autoDetect/autoDetect.ts
Normal 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
|
||||||
|
}
|
||||||
89
apps/board/src/features/create/create.ts
Normal file
89
apps/board/src/features/create/create.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
10
apps/board/src/hooks/useBoardStore.ts
Normal file
10
apps/board/src/hooks/useBoardStore.ts
Normal 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
|
||||||
|
}
|
||||||
23
apps/board/src/hooks/useClickOutside.ts
Normal file
23
apps/board/src/hooks/useClickOutside.ts
Normal 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]);
|
||||||
|
};
|
||||||
36
apps/board/src/hooks/useClipboard.ts
Normal file
36
apps/board/src/hooks/useClipboard.ts
Normal 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 };
|
||||||
|
};
|
||||||
39
apps/board/src/hooks/useContextMenu.ts
Normal file
39
apps/board/src/hooks/useContextMenu.ts
Normal 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
14
apps/board/src/index.html
Normal 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
12
apps/board/src/main.tsx
Normal 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>
|
||||||
|
)
|
||||||
220
apps/board/src/store/BoardStore.spec.ts
Normal file
220
apps/board/src/store/BoardStore.spec.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
46
apps/board/src/store/BoardStore.ts
Normal file
46
apps/board/src/store/BoardStore.ts
Normal 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
89
apps/board/src/styles.css
Normal 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%;
|
||||||
|
}
|
||||||
35
apps/board/tsconfig.app.json
Normal file
35
apps/board/tsconfig.app.json
Normal 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
16
apps/board/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"include": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "../../packages/models"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../../packages/ui"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"extends": "../../tsconfig.base.json"
|
||||||
|
}
|
||||||
12
apps/board/tsconfig.spec.json
Normal file
12
apps/board/tsconfig.spec.json
Normal 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"]
|
||||||
|
}
|
||||||
42
eslint.config.mjs
Normal file
42
eslint.config.mjs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import nx from '@nx/eslint-plugin';
|
||||||
|
|
||||||
|
export default [
|
||||||
|
...nx.configs['flat/base'],
|
||||||
|
...nx.configs['flat/typescript'],
|
||||||
|
...nx.configs['flat/javascript'],
|
||||||
|
{
|
||||||
|
ignores: ['**/dist'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
|
||||||
|
rules: {
|
||||||
|
'@nx/enforce-module-boundaries': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
enforceBuildableLibDependency: true,
|
||||||
|
allow: ['^.*/eslint(\\.base)?\\.config\\.[cm]?[jt]s$'],
|
||||||
|
depConstraints: [
|
||||||
|
{
|
||||||
|
sourceTag: '*',
|
||||||
|
onlyDependOnLibsWithTags: ['*'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: [
|
||||||
|
'**/*.ts',
|
||||||
|
'**/*.tsx',
|
||||||
|
'**/*.cts',
|
||||||
|
'**/*.mts',
|
||||||
|
'**/*.js',
|
||||||
|
'**/*.jsx',
|
||||||
|
'**/*.cjs',
|
||||||
|
'**/*.mjs',
|
||||||
|
],
|
||||||
|
// Override or add rules here
|
||||||
|
rules: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
3
jest.preset.js
Normal file
3
jest.preset.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
const nxPreset = require('@nx/jest/preset').default;
|
||||||
|
|
||||||
|
module.exports = { ...nxPreset };
|
||||||
80
nx.json
Normal file
80
nx.json
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/nx/schemas/nx-schema.json",
|
||||||
|
"namedInputs": {
|
||||||
|
"default": [
|
||||||
|
"{projectRoot}/**/*",
|
||||||
|
"sharedGlobals"
|
||||||
|
],
|
||||||
|
"production": [
|
||||||
|
"default",
|
||||||
|
"!{projectRoot}/.eslintrc.json",
|
||||||
|
"!{projectRoot}/eslint.config.mjs"
|
||||||
|
],
|
||||||
|
"sharedGlobals": []
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"plugin": "@nx/js/typescript",
|
||||||
|
"options": {
|
||||||
|
"typecheck": {
|
||||||
|
"targetName": "typecheck"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"targetName": "build",
|
||||||
|
"configName": "tsconfig.lib.json",
|
||||||
|
"buildDepsName": "build-deps",
|
||||||
|
"watchDepsName": "watch-deps"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"plugin": "@nx/react/router-plugin",
|
||||||
|
"options": {
|
||||||
|
"buildTargetName": "build",
|
||||||
|
"devTargetName": "dev",
|
||||||
|
"startTargetName": "start",
|
||||||
|
"watchDepsTargetName": "watch-deps",
|
||||||
|
"buildDepsTargetName": "build-deps",
|
||||||
|
"typecheckTargetName": "typecheck"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"plugin": "@nx/rspack/plugin",
|
||||||
|
"options": {
|
||||||
|
"buildTargetName": "build",
|
||||||
|
"serveTargetName": "serve",
|
||||||
|
"serveStaticTargetName": "serve-static",
|
||||||
|
"previewTargetName": "preview",
|
||||||
|
"buildDepsTargetName": "build-deps",
|
||||||
|
"watchDepsTargetName": "watch-deps"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"plugin": "@nx/eslint/plugin",
|
||||||
|
"options": {
|
||||||
|
"targetName": "lint"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"generators": {
|
||||||
|
"@nx/react": {
|
||||||
|
"application": {
|
||||||
|
"babel": true,
|
||||||
|
"style": "css",
|
||||||
|
"linter": "eslint",
|
||||||
|
"bundler": "rspack"
|
||||||
|
},
|
||||||
|
"component": {
|
||||||
|
"style": "css"
|
||||||
|
},
|
||||||
|
"library": {
|
||||||
|
"style": "css",
|
||||||
|
"linter": "eslint",
|
||||||
|
"unitTestRunner": "none"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sync": {
|
||||||
|
"applyChanges": true
|
||||||
|
}
|
||||||
|
}
|
||||||
25005
package-lock.json
generated
Normal file
25005
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
71
package.json
Normal file
71
package.json
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
{
|
||||||
|
"name": "@klx/source",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"test:board": "nx test board",
|
||||||
|
"serve:board": "nx serve board",
|
||||||
|
"build:board": "nx build board",
|
||||||
|
"serve:admin": "nx serve admin",
|
||||||
|
"build:admin": "nx build admin",
|
||||||
|
"build:all": "nx run-many -t build"
|
||||||
|
},
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emotion/react": "^11.14.0",
|
||||||
|
"@faker-js/faker": "^10.0.0",
|
||||||
|
"mobx": "^6.13.7",
|
||||||
|
"mobx-react-lite": "^4.1.1",
|
||||||
|
"react": "19.0.0",
|
||||||
|
"react-dom": "19.0.0",
|
||||||
|
"react-rnd": "^10.5.2",
|
||||||
|
"react-window": "^2.2.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.14.5",
|
||||||
|
"@babel/preset-env": "^7.28.5",
|
||||||
|
"@babel/preset-react": "^7.14.5",
|
||||||
|
"@babel/preset-typescript": "^7.28.5",
|
||||||
|
"@eslint/js": "^9.8.0",
|
||||||
|
"@nx/eslint": "21.5.2",
|
||||||
|
"@nx/eslint-plugin": "21.5.2",
|
||||||
|
"@nx/jest": "21.5.2",
|
||||||
|
"@nx/js": "21.5.2",
|
||||||
|
"@nx/react": "21.5.2",
|
||||||
|
"@nx/rspack": "21.5.2",
|
||||||
|
"@nx/workspace": "21.5.2",
|
||||||
|
"@rspack/cli": "^1.5.0",
|
||||||
|
"@rspack/core": "^1.5.0",
|
||||||
|
"@rspack/dev-server": "^1.1.4",
|
||||||
|
"@rspack/plugin-react-refresh": "^1.0.0",
|
||||||
|
"@swc-node/register": "~1.9.1",
|
||||||
|
"@swc/cli": "~0.6.0",
|
||||||
|
"@swc/core": "~1.5.7",
|
||||||
|
"@swc/helpers": "~0.5.11",
|
||||||
|
"@types/jest": "^30.0.0",
|
||||||
|
"@types/node": "^20.0.0",
|
||||||
|
"@types/react": "19.0.0",
|
||||||
|
"@types/react-dom": "19.0.0",
|
||||||
|
"@types/react-window": "^1.8.8",
|
||||||
|
"babel-jest": "^30.2.0",
|
||||||
|
"eslint": "^9.8.0",
|
||||||
|
"eslint-config-prettier": "^10.0.0",
|
||||||
|
"eslint-plugin-import": "2.31.0",
|
||||||
|
"eslint-plugin-jsx-a11y": "6.10.1",
|
||||||
|
"eslint-plugin-react": "7.35.0",
|
||||||
|
"eslint-plugin-react-hooks": "5.0.0",
|
||||||
|
"jest": "^30.2.0",
|
||||||
|
"jest-environment-jsdom": "^30.2.0",
|
||||||
|
"nx": "21.5.2",
|
||||||
|
"prettier": "^2.6.2",
|
||||||
|
"react-refresh": "~0.14.0",
|
||||||
|
"ts-jest": "^29.4.5",
|
||||||
|
"tslib": "^2.3.0",
|
||||||
|
"typescript": "~5.9.2",
|
||||||
|
"typescript-eslint": "^8.40.0"
|
||||||
|
},
|
||||||
|
"workspaces": [
|
||||||
|
"apps/*",
|
||||||
|
"packages/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
12
packages/models/.babelrc
Normal file
12
packages/models/.babelrc
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"presets": [
|
||||||
|
[
|
||||||
|
"@nx/react/babel",
|
||||||
|
{
|
||||||
|
"runtime": "automatic",
|
||||||
|
"useBuiltIns": "usage"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"plugins": []
|
||||||
|
}
|
||||||
7
packages/models/README.md
Normal file
7
packages/models/README.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# @klx/models
|
||||||
|
|
||||||
|
This library was generated with [Nx](https://nx.dev).
|
||||||
|
|
||||||
|
## Running unit tests
|
||||||
|
|
||||||
|
Run `nx test @klx/models` to execute the unit tests via [Jest](https://jestjs.io).
|
||||||
12
packages/models/eslint.config.mjs
Normal file
12
packages/models/eslint.config.mjs
Normal 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: {},
|
||||||
|
},
|
||||||
|
]
|
||||||
14
packages/models/package.json
Normal file
14
packages/models/package.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "@klx/models",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"main": "./src/index.ts",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./src/index.ts",
|
||||||
|
"default": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"./package.json": "./package.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
142
packages/models/src/idea/Idea.ts
Normal file
142
packages/models/src/idea/Idea.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import {action, makeObservable, observable} from 'mobx'
|
||||||
|
import {IBoardObject, BoardObjectType, IImageIdea, ITextIdea, IIframeIdea} from './idea.type'
|
||||||
|
import {ID, Point, Size, User} from '../types'
|
||||||
|
|
||||||
|
export class Idea implements IBoardObject {
|
||||||
|
id: ID
|
||||||
|
author: User
|
||||||
|
position: Point
|
||||||
|
size: Size
|
||||||
|
title: string
|
||||||
|
color: string
|
||||||
|
type: BoardObjectType
|
||||||
|
|
||||||
|
constructor(data: {
|
||||||
|
id: ID
|
||||||
|
author: User
|
||||||
|
position: Point
|
||||||
|
size: Size
|
||||||
|
title: string
|
||||||
|
content?: string
|
||||||
|
color: string
|
||||||
|
type?: BoardObjectType
|
||||||
|
}) {
|
||||||
|
this.id = data.id
|
||||||
|
this.author = data.author
|
||||||
|
this.position = data.position
|
||||||
|
this.size = data.size
|
||||||
|
this.title = data.title
|
||||||
|
this.color = data.color
|
||||||
|
this.type = "text"
|
||||||
|
|
||||||
|
makeObservable(this, {
|
||||||
|
position: observable,
|
||||||
|
size: observable,
|
||||||
|
updatePosition: action,
|
||||||
|
updateSize: action,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePosition(x: number, y: number) {
|
||||||
|
this.position.x = x
|
||||||
|
this.position.y = y
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSize(width: number, height: number) {
|
||||||
|
this.size.width = width
|
||||||
|
this.size.height = height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Implémente IImageIdea + narrowed type
|
||||||
|
export class ImageIdea extends Idea implements IImageIdea {
|
||||||
|
url: string
|
||||||
|
alt: string
|
||||||
|
override type = 'image' as const
|
||||||
|
|
||||||
|
constructor(data: {
|
||||||
|
id: ID
|
||||||
|
author: User
|
||||||
|
position: Point
|
||||||
|
size: Size
|
||||||
|
title: string
|
||||||
|
color: string
|
||||||
|
url: string
|
||||||
|
alt: string
|
||||||
|
}) {
|
||||||
|
super({...data, type: 'image'})
|
||||||
|
this.url = data.url
|
||||||
|
this.alt = data.alt
|
||||||
|
|
||||||
|
makeObservable(this, {
|
||||||
|
url: observable,
|
||||||
|
alt: observable,
|
||||||
|
updateUrl: action,
|
||||||
|
updateAlt: action,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
updateUrl(url: string) {
|
||||||
|
this.url = url
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAlt(alt: string) {
|
||||||
|
this.alt = alt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Implémente ITextIdea + narrowed type
|
||||||
|
export class TextIdea extends Idea implements ITextIdea {
|
||||||
|
content: string
|
||||||
|
override type = 'text' as const
|
||||||
|
|
||||||
|
constructor(data: {
|
||||||
|
id: ID
|
||||||
|
author: User
|
||||||
|
position: Point
|
||||||
|
size: Size
|
||||||
|
title: string
|
||||||
|
color: string
|
||||||
|
content: string
|
||||||
|
}) {
|
||||||
|
super({...data, type: 'text'})
|
||||||
|
this.content = data.content
|
||||||
|
|
||||||
|
makeObservable(this, {
|
||||||
|
content: observable,
|
||||||
|
updateContent: action,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
updateContent(content: string) {
|
||||||
|
this.content = content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Implémente IIframeIdea + narrowed type
|
||||||
|
export class IframeIdea extends Idea implements IIframeIdea {
|
||||||
|
url: string
|
||||||
|
override type = 'iframe' as const
|
||||||
|
|
||||||
|
constructor(data: {
|
||||||
|
id: ID
|
||||||
|
author: User
|
||||||
|
position: Point
|
||||||
|
size: Size
|
||||||
|
title: string
|
||||||
|
color: string
|
||||||
|
url: string
|
||||||
|
}) {
|
||||||
|
super({...data, type: 'iframe'})
|
||||||
|
this.url = data.url
|
||||||
|
|
||||||
|
makeObservable(this, {
|
||||||
|
url: observable,
|
||||||
|
updateUrl: action,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
updateUrl(url: string) {
|
||||||
|
this.url = url
|
||||||
|
}
|
||||||
|
}
|
||||||
31
packages/models/src/idea/idea.type.ts
Normal file
31
packages/models/src/idea/idea.type.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import {BaseObject} from '../types'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export type BoardObjectType = 'text' | 'image' | 'iframe'
|
||||||
|
|
||||||
|
export type IBoardObject = BaseObject & {
|
||||||
|
type: BoardObjectType,
|
||||||
|
title: string,
|
||||||
|
color: string,
|
||||||
|
updatePosition(x: number, y: number): void
|
||||||
|
updateSize(width: number, height: number): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ITextIdea = IBoardObject & {
|
||||||
|
type: 'text'
|
||||||
|
content?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IImageIdea = IBoardObject & {
|
||||||
|
type: 'image'
|
||||||
|
url: string
|
||||||
|
alt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IIframeIdea = IBoardObject & {
|
||||||
|
type: 'iframe'
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BoardObject = ITextIdea | IImageIdea | IIframeIdea
|
||||||
3
packages/models/src/index.ts
Normal file
3
packages/models/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export {Idea, TextIdea, ImageIdea, IframeIdea} from './idea/Idea'
|
||||||
|
export type {IBoardObject, IImageIdea, IIframeIdea, ITextIdea, BoardObject} from './idea/idea.type'
|
||||||
|
export type {ID, Point, Size, BaseObject, User} from './types'
|
||||||
25
packages/models/src/types.ts
Normal file
25
packages/models/src/types.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export type ID = string
|
||||||
|
|
||||||
|
export type Point = {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Size = {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type User = {
|
||||||
|
uuid: string
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
job: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BaseObject = {
|
||||||
|
id: ID
|
||||||
|
author: User
|
||||||
|
position: Point
|
||||||
|
size: Size
|
||||||
|
}
|
||||||
10
packages/models/tsconfig.json
Normal file
10
packages/models/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"include": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.lib.json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"extends": "../../tsconfig.base.json"
|
||||||
|
}
|
||||||
28
packages/models/tsconfig.lib.json
Normal file
28
packages/models/tsconfig.lib.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"types": ["node", "@nx/react/typings/cssmodule.d.ts", "@nx/react/typings/image.d.ts"],
|
||||||
|
"rootDir": "src",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo"
|
||||||
|
},
|
||||||
|
"exclude": [
|
||||||
|
"out-tsc",
|
||||||
|
"dist",
|
||||||
|
"jest.config.ts",
|
||||||
|
"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": []
|
||||||
|
}
|
||||||
12
packages/ui/.babelrc
Normal file
12
packages/ui/.babelrc
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"presets": [
|
||||||
|
[
|
||||||
|
"@nx/react/babel",
|
||||||
|
{
|
||||||
|
"runtime": "automatic",
|
||||||
|
"useBuiltIns": "usage"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"plugins": []
|
||||||
|
}
|
||||||
7
packages/ui/README.md
Normal file
7
packages/ui/README.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# @klx/ui
|
||||||
|
|
||||||
|
This library was generated with [Nx](https://nx.dev).
|
||||||
|
|
||||||
|
## Running unit tests
|
||||||
|
|
||||||
|
Run `nx test @klx/ui` to execute the unit tests via [Jest](https://jestjs.io).
|
||||||
12
packages/ui/eslint.config.mjs
Normal file
12
packages/ui/eslint.config.mjs
Normal 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: {},
|
||||||
|
},
|
||||||
|
]
|
||||||
14
packages/ui/package.json
Normal file
14
packages/ui/package.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "@klx/ui",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"main": "./src/index.ts",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./src/index.ts",
|
||||||
|
"default": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"./package.json": "./package.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
4
packages/ui/src/index.ts
Normal file
4
packages/ui/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export {Avatar} from './lib/components/Avatar/Avatar'
|
||||||
|
export {Main} from './lib/components/Main/Main'
|
||||||
|
export {Header} from './lib/components/Header/Header'
|
||||||
|
export {Footer} from './lib/components/Footer/Footer'
|
||||||
15
packages/ui/src/lib/components/Avatar/Avatar.tsx
Normal file
15
packages/ui/src/lib/components/Avatar/Avatar.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/** @jsxImportSource @emotion/react */
|
||||||
|
import {css} from '@emotion/react'
|
||||||
|
|
||||||
|
const avatarStyles = css`
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
`
|
||||||
|
|
||||||
|
export const Avatar = ({src}: {src: string}) => {
|
||||||
|
return <img css={avatarStyles} src={src} alt="Profile" />
|
||||||
|
}
|
||||||
7
packages/ui/src/lib/components/Footer/Footer.tsx
Normal file
7
packages/ui/src/lib/components/Footer/Footer.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export const Footer = () => {
|
||||||
|
return (
|
||||||
|
<footer className="h-10 bg-gray-100 border-t flex items-center justify-center">
|
||||||
|
<span className="text-sm text-gray-600">© Klaxoon</span>
|
||||||
|
</footer>
|
||||||
|
)
|
||||||
|
}
|
||||||
31
packages/ui/src/lib/components/Header/Header.tsx
Normal file
31
packages/ui/src/lib/components/Header/Header.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import {Avatar} from '../Avatar/Avatar'
|
||||||
|
import {faker} from '@faker-js/faker'
|
||||||
|
import {useState} from 'react'
|
||||||
|
|
||||||
|
type User = {
|
||||||
|
uuid: string
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
job: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Header = ({title}: {title: string}) => {
|
||||||
|
const [user] = useState<User>(() => {
|
||||||
|
return {
|
||||||
|
uuid: faker.string.uuid(),
|
||||||
|
name: faker.person.fullName(),
|
||||||
|
email: faker.internet.email(),
|
||||||
|
job: faker.person.jobTitle(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="flex justify-between items-center px-8 py-4 bg-white border-b border-gray-200 shadow-sm">
|
||||||
|
<div className="text-2xl font-bold text-gray-800">{title}</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-gray-500">{user.name}</span>
|
||||||
|
<Avatar src={`https://i.pravatar.cc/150?u=${user.uuid}`} />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
3
packages/ui/src/lib/components/Main/Main.tsx
Normal file
3
packages/ui/src/lib/components/Main/Main.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export const Main = ({children}: {children: React.ReactNode}) => {
|
||||||
|
return <main className="flex-1 p-4 h-full">{children}</main>
|
||||||
|
}
|
||||||
10
packages/ui/tsconfig.json
Normal file
10
packages/ui/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"include": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.lib.json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"extends": "../../tsconfig.base.json"
|
||||||
|
}
|
||||||
31
packages/ui/tsconfig.lib.json
Normal file
31
packages/ui/tsconfig.lib.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"types": [
|
||||||
|
"node",
|
||||||
|
"@nx/react/typings/cssmodule.d.ts",
|
||||||
|
"@nx/react/typings/image.d.ts"
|
||||||
|
],
|
||||||
|
"rootDir": "src",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo"
|
||||||
|
},
|
||||||
|
"exclude": [
|
||||||
|
"out-tsc",
|
||||||
|
"dist",
|
||||||
|
"jest.config.ts",
|
||||||
|
"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"]
|
||||||
|
}
|
||||||
21
tsconfig.base.json
Normal file
21
tsconfig.base.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"emitDeclarationOnly": true,
|
||||||
|
"importHelpers": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"lib": ["es2022"],
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"noEmitOnError": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"target": "es2022",
|
||||||
|
"customConditions": ["@klx/source"]
|
||||||
|
}
|
||||||
|
}
|
||||||
19
tsconfig.json
Normal file
19
tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.base.json",
|
||||||
|
"compileOnSave": false,
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./apps/board"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./apps/admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./packages/ui"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./packages/models"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user