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