website refactor
This commit is contained in:
@@ -63,6 +63,38 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Rule 8: No 'use client' directive in templates
|
||||||
|
'no-use-client-in-templates': {
|
||||||
|
meta: {
|
||||||
|
type: 'problem',
|
||||||
|
docs: {
|
||||||
|
description: 'Forbid use client directive in templates',
|
||||||
|
category: 'Template Purity',
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
message: 'Templates must not use "use client" directive - they should be stateless composition',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create(context) {
|
||||||
|
const filename = context.getFilename();
|
||||||
|
const isInTemplates = filename.includes('/templates/');
|
||||||
|
|
||||||
|
if (!isInTemplates) return {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
ExpressionStatement(node) {
|
||||||
|
if (node.expression.type === 'Literal' &&
|
||||||
|
node.expression.value === 'use client') {
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
messageId: 'message',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// Rule 3: No computations in templates
|
// Rule 3: No computations in templates
|
||||||
'no-computations-in-templates': {
|
'no-computations-in-templates': {
|
||||||
meta: {
|
meta: {
|
||||||
|
|||||||
@@ -35,11 +35,18 @@ module.exports = {
|
|||||||
|
|
||||||
if (!isInUi) return {};
|
if (!isInUi) return {};
|
||||||
|
|
||||||
let hasStateHooks = false;
|
|
||||||
let hasEffectHooks = false;
|
|
||||||
let hasContext = false;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
// Check for 'use client' directive
|
||||||
|
ExpressionStatement(node) {
|
||||||
|
if (node.expression.type === 'Literal' &&
|
||||||
|
node.expression.value === 'use client') {
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
messageId: 'noStateInUi',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Check for state hooks
|
// Check for state hooks
|
||||||
CallExpression(node) {
|
CallExpression(node) {
|
||||||
if (node.callee.type !== 'Identifier') return;
|
if (node.callee.type !== 'Identifier') return;
|
||||||
@@ -48,7 +55,6 @@ module.exports = {
|
|||||||
|
|
||||||
// State management hooks
|
// State management hooks
|
||||||
if (['useState', 'useReducer', 'useRef'].includes(hookName)) {
|
if (['useState', 'useReducer', 'useRef'].includes(hookName)) {
|
||||||
hasStateHooks = true;
|
|
||||||
context.report({
|
context.report({
|
||||||
node,
|
node,
|
||||||
messageId: 'noStateInUi',
|
messageId: 'noStateInUi',
|
||||||
@@ -57,7 +63,6 @@ module.exports = {
|
|||||||
|
|
||||||
// Effect hooks
|
// Effect hooks
|
||||||
if (['useEffect', 'useLayoutEffect', 'useInsertionEffect'].includes(hookName)) {
|
if (['useEffect', 'useLayoutEffect', 'useInsertionEffect'].includes(hookName)) {
|
||||||
hasEffectHooks = true;
|
|
||||||
context.report({
|
context.report({
|
||||||
node,
|
node,
|
||||||
messageId: 'noSideEffects',
|
messageId: 'noSideEffects',
|
||||||
@@ -66,7 +71,6 @@ module.exports = {
|
|||||||
|
|
||||||
// Context (can introduce state)
|
// Context (can introduce state)
|
||||||
if (hookName === 'useContext') {
|
if (hookName === 'useContext') {
|
||||||
hasContext = true;
|
|
||||||
context.report({
|
context.report({
|
||||||
node,
|
node,
|
||||||
messageId: 'noStateInUi',
|
messageId: 'noStateInUi',
|
||||||
@@ -76,8 +80,8 @@ module.exports = {
|
|||||||
|
|
||||||
// Check for class components with state
|
// Check for class components with state
|
||||||
ClassDeclaration(node) {
|
ClassDeclaration(node) {
|
||||||
if (node.superClass &&
|
if (node.superClass &&
|
||||||
node.superClass.type === 'Identifier' &&
|
node.superClass.type === 'Identifier' &&
|
||||||
node.superClass.name === 'Component') {
|
node.superClass.name === 'Component') {
|
||||||
context.report({
|
context.report({
|
||||||
node,
|
node,
|
||||||
@@ -90,7 +94,7 @@ module.exports = {
|
|||||||
AssignmentExpression(node) {
|
AssignmentExpression(node) {
|
||||||
if (node.left.type === 'MemberExpression' &&
|
if (node.left.type === 'MemberExpression' &&
|
||||||
node.left.property.type === 'Identifier' &&
|
node.left.property.type === 'Identifier' &&
|
||||||
(node.left.property.name === 'state' ||
|
(node.left.property.name === 'state' ||
|
||||||
node.left.property.name === 'setState')) {
|
node.left.property.name === 'setState')) {
|
||||||
context.report({
|
context.report({
|
||||||
node,
|
node,
|
||||||
|
|||||||
254
docs/architecture/website/REACT_COMPONENT_ARCHITECTURE.md
Normal file
254
docs/architecture/website/REACT_COMPONENT_ARCHITECTURE.md
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
# React Component Architecture (Concept)
|
||||||
|
|
||||||
|
This document defines the clean concept for React component architecture in `apps/website`.
|
||||||
|
|
||||||
|
## Core Principle
|
||||||
|
|
||||||
|
**Separation of concerns by responsibility, not just by file location.**
|
||||||
|
|
||||||
|
## The Four Layers
|
||||||
|
|
||||||
|
### 1. App Layer (`app/`)
|
||||||
|
**Purpose**: Entry points and data orchestration
|
||||||
|
|
||||||
|
**What lives here**:
|
||||||
|
- `page.tsx` - Server Components that fetch data
|
||||||
|
- `layout.tsx` - Root layouts
|
||||||
|
- `route.tsx` - API routes
|
||||||
|
- `*PageClient.tsx` - Client entry points that wire server data to client templates
|
||||||
|
|
||||||
|
**Rules**:
|
||||||
|
- `page.tsx` does ONLY data fetching and passes raw data to client components
|
||||||
|
- `*PageClient.tsx` manages client state and event handlers
|
||||||
|
- No UI rendering logic (except loading/error states)
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
// app/teams/page.tsx
|
||||||
|
export default async function TeamsPage() {
|
||||||
|
const query = new TeamsPageQuery();
|
||||||
|
const result = await query.execute();
|
||||||
|
|
||||||
|
if (result.isErr()) {
|
||||||
|
return <ErrorTeams />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <TeamsPageClient teams={result.value.teams} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// app/teams/TeamsPageClient.tsx
|
||||||
|
'use client';
|
||||||
|
export function TeamsPageClient({ teams }: TeamsViewData) {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleTeamClick = (teamId: string) => {
|
||||||
|
router.push(`/teams/${teamId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TeamsTemplate
|
||||||
|
teams={teams}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
onSearchChange={setSearchQuery}
|
||||||
|
onTeamClick={handleTeamClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Template Layer (`templates/`)
|
||||||
|
**Purpose**: Composition and layout of components
|
||||||
|
|
||||||
|
**What lives here**:
|
||||||
|
- Stateless component compositions
|
||||||
|
- Page-level layouts
|
||||||
|
- Component orchestration
|
||||||
|
|
||||||
|
**Rules**:
|
||||||
|
- Templates ARE stateless (no `useState`, `useEffect`)
|
||||||
|
- Templates CAN use `'use client'` for event handling and composition
|
||||||
|
- Templates receive ViewData (primitives) and event handlers
|
||||||
|
- Templates compose components and UI elements
|
||||||
|
- No business logic
|
||||||
|
- No data fetching
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
// templates/TeamsTemplate.tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
export function TeamsTemplate({ teams, searchQuery, onSearchChange, onTeamClick }: TeamsTemplateProps) {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<Header>
|
||||||
|
<SearchInput value={searchQuery} onChange={onSearchChange} />
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
<TeamLeaderboardPreview
|
||||||
|
teams={teams}
|
||||||
|
onTeamClick={onTeamClick}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TeamGrid teams={teams} onTeamClick={onTeamClick} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Component Layer (`components/`)
|
||||||
|
**Purpose**: Reusable app-specific components
|
||||||
|
|
||||||
|
**What lives here**:
|
||||||
|
- Components that understand app concepts (teams, races, leagues)
|
||||||
|
- Components that may contain state and business logic
|
||||||
|
- Components that orchestrate UI elements for app purposes
|
||||||
|
|
||||||
|
**Rules**:
|
||||||
|
- Can be stateful
|
||||||
|
- Can contain app-specific business logic
|
||||||
|
- Can use Next.js hooks (but not Next.js components like `Link`)
|
||||||
|
- Should be reusable within the app context
|
||||||
|
- Should NOT be generic UI primitives
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
// components/teams/TeamLeaderboardPreview.tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
export function TeamLeaderboardPreview({ teams, onTeamClick }: Props) {
|
||||||
|
// App-specific logic: medal colors, ranking, etc.
|
||||||
|
const getMedalColor = (position: number) => { /* ... */ };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>Top Teams</CardHeader>
|
||||||
|
{teams.map((team, index) => (
|
||||||
|
<TeamRow
|
||||||
|
key={team.id}
|
||||||
|
team={team}
|
||||||
|
position={index}
|
||||||
|
onClick={() => onTeamClick(team.id)}
|
||||||
|
medalColor={getMedalColor(index)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. UI Layer (`ui/`)
|
||||||
|
**Purpose**: Generic, reusable UI primitives
|
||||||
|
|
||||||
|
**What lives here**:
|
||||||
|
- Pure UI elements with no app knowledge
|
||||||
|
- Generic building blocks
|
||||||
|
- Framework-agnostic components
|
||||||
|
|
||||||
|
**Rules**:
|
||||||
|
- Stateless (no `useState`, `useEffect`)
|
||||||
|
- No app-specific logic
|
||||||
|
- No Next.js imports
|
||||||
|
- Only receive props and render
|
||||||
|
- Maximum reusability
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
// ui/Button.tsx
|
||||||
|
export function Button({ children, variant, onClick }: ButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`btn btn-${variant}`}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ui/Card.tsx
|
||||||
|
export function Card({ children, className }: CardProps) {
|
||||||
|
return <div className={`card ${className}`}>{children}</div>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## The Display Object Layer
|
||||||
|
|
||||||
|
**Purpose**: Reusable formatting and presentation logic
|
||||||
|
|
||||||
|
**What lives here**:
|
||||||
|
- `lib/display-objects/`
|
||||||
|
- Deterministic formatting functions
|
||||||
|
- Code-to-label mappings
|
||||||
|
- Value transformations for display
|
||||||
|
|
||||||
|
**Rules**:
|
||||||
|
- Class-based
|
||||||
|
- Immutable
|
||||||
|
- Deterministic
|
||||||
|
- No side effects
|
||||||
|
- No `Intl.*` or `toLocale*`
|
||||||
|
|
||||||
|
**Usage**:
|
||||||
|
```typescript
|
||||||
|
// lib/display-objects/RatingDisplay.ts
|
||||||
|
export class RatingDisplay {
|
||||||
|
static format(rating: number): string {
|
||||||
|
return rating.toFixed(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static getColor(rating: number): string {
|
||||||
|
if (rating >= 90) return 'text-green';
|
||||||
|
if (rating >= 70) return 'text-yellow';
|
||||||
|
return 'text-red';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// In ViewModel Builder
|
||||||
|
const viewModel = {
|
||||||
|
rating: RatingDisplay.format(dto.rating),
|
||||||
|
ratingColor: RatingDisplay.getColor(dto.rating)
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependency Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
app/ (page.tsx)
|
||||||
|
↓ fetches data
|
||||||
|
app/ (*PageClient.tsx)
|
||||||
|
↓ manages state, creates handlers
|
||||||
|
templates/ (composition)
|
||||||
|
↓ uses components
|
||||||
|
components/ (app-specific)
|
||||||
|
↓ uses UI elements
|
||||||
|
ui/ (generic primitives)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Decision Rules
|
||||||
|
|
||||||
|
### When to use *PageClient.tsx?
|
||||||
|
- When you need client-side state management
|
||||||
|
- When you need event handlers that use router/other hooks
|
||||||
|
- When server component needs to be split from client template
|
||||||
|
|
||||||
|
### When to use Template vs Component?
|
||||||
|
- **Template**: Page-level composition, orchestrates multiple components
|
||||||
|
- **Component**: Reusable app-specific element with logic
|
||||||
|
|
||||||
|
### When to use Component vs UI?
|
||||||
|
- **Component**: Understands app concepts (teams, races, leagues)
|
||||||
|
- **UI**: Generic building blocks (button, card, input)
|
||||||
|
|
||||||
|
### When to use Display Objects?
|
||||||
|
- When formatting is reusable across multiple ViewModels
|
||||||
|
- When mapping codes to labels
|
||||||
|
- When presentation logic needs to be deterministic
|
||||||
|
|
||||||
|
## Key Benefits
|
||||||
|
|
||||||
|
1. **Clear boundaries**: Each layer has a single responsibility
|
||||||
|
2. **Testability**: Pure functions at UI/Display layer
|
||||||
|
3. **Reusability**: UI elements can be used anywhere
|
||||||
|
4. **Maintainability**: Changes in one layer don't ripple
|
||||||
|
5. **Type safety**: Clear contracts between layers
|
||||||
Reference in New Issue
Block a user