reinit repo with sources from zipped dir

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

View File

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

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

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

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

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

View File

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

View File

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

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

View File

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

View File

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

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

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