diff --git a/.claude/agents/CONTEXT.md b/.claude/agents/CONTEXT.md new file mode 100644 index 0000000..861507f --- /dev/null +++ b/.claude/agents/CONTEXT.md @@ -0,0 +1,38 @@ +--- +name: context +description: Agent responsible for retrieving and presenting project context from README.md and the .claude configuration directory. +trigger: /context +--- + +# Project Context Agent + +This agent ruleset defines the workflow for retrieving and presenting comprehensive project context for the **Amit Raikwar Portfolio** repository. + +## Workflow Steps + +When invoked with `/context`, the agent MUST perform the following steps: + +### 1. Read Core Project Files + +Locate and view the contents of the following files to establish core project guidelines: + +- Root [README.md](file:///Users/mr.robot/z-stash/AmitRaikwar-in/amitraikwar/README.md) +- Main developer entry point [.claude/CLAUDE.md](file:///Users/mr.robot/z-stash/AmitRaikwar-in/amitraikwar/.claude/CLAUDE.md) + +### 2. Discover Custom Agent Configurations & Skills + +List and review files in the `.claude/` directory to document active agents and developer skills: + +- Agents in `.claude/agents/` (e.g., `DEVELOPER.md`, `REVIEW.md`, `CONTEXT.md`) +- Skills in `.claude/skills/` (e.g., `jira/SKILL.md`, `pr/SKILL.md`, `readme/SKILL.md`) + +### 3. Output Structured Summary + +Generate a clear, high-level overview for the user, structured into the following sections: + +1. **Core Technology Stack**: Framework, styling library, state management library, package manager, and testing frameworks. +2. **Coding & Architectural Guidelines**: Crucial rules regarding components, Zustand store selectors, and file structure rules. +3. **Localization Rules**: Localization directory paths and copy string guidelines. +4. **Testing Standards**: Placement of tests in `__tests__` subdirectories and CLI commands. +5. **Custom Agent Slash Commands**: A summary table of active agents, their triggers, and their responsibilities. +6. **Skills & Automation**: Available automation modules and their triggers. diff --git a/.claude/agents/DEVELOPER.md b/.claude/agents/DEVELOPER.md index 9b31cc8..aeb5923 100644 --- a/.claude/agents/DEVELOPER.md +++ b/.claude/agents/DEVELOPER.md @@ -1,6 +1,7 @@ --- name: developer description: Agent responsible for end-to-end development lifecycle, from testing changes and Jira ticket creation to committing code and opening Pull Requests. +trigger: /dev --- # Developer Workflow Agent diff --git a/.claude/agents/REVIEW.md b/.claude/agents/REVIEW.md index fd8f8b1..bbd6df2 100644 --- a/.claude/agents/REVIEW.md +++ b/.claude/agents/REVIEW.md @@ -1,6 +1,7 @@ --- name: review description: Single unified Agent responsible for dynamically reviewing and testing Pull Requests based on modified files. +trigger: /review --- # Unified PR Review & Merge Agent diff --git a/.claude/agents/UNIT_TEST.md b/.claude/agents/UNIT_TEST.md new file mode 100644 index 0000000..f6a9673 --- /dev/null +++ b/.claude/agents/UNIT_TEST.md @@ -0,0 +1,45 @@ +--- +name: unit_test +description: Agent responsible for writing unit tests for all business and UI code, enforcing Jest / React Testing Library best practices, and ensuring code coverage meets the 80% threshold. +trigger: /test +--- + +# Unit Test Agent + +This agent ruleset defines the standard responsibilities and workflows for writing, running, and managing unit and integration tests in the **Amit Raikwar Portfolio** project. + +## 1. Core Objectives +- Ensure that all business logic (utils, helpers, slices, hooks) and UI components (screens, reusable elements) are covered by robust tests. +- Maintain a minimum code coverage threshold of **80%** across statements, branches, functions, and lines. + +## 2. Test File Conventions (from rules.md) +- **Folder Location**: Test files MUST always be placed in a `__tests__/` folder inside the directory containing the code under test. +- **Naming**: Test files must be named after the source file they target: + - Component under test: `ComponentName.tsx` -> `__tests__/ComponentName.test.tsx` + - Utility under test: `util.ts` -> `__tests__/util.test.ts` + - Store/Hook under test: `useFeature.ts` -> `__tests__/useFeature.test.ts` +- **Assertions**: No empty test files are allowed. Every test file must contain at least one meaningful assertion. + +## 3. Best Practices for UI Component Testing +- Use **React Testing Library** (`@testing-library/react`) for testing components. +- **Render Setup**: Wrap components in necessary providers (like `ThemeProvider`, `LocalizationProvider`, `BrowserRouter`, `HelmetProvider`, `QueryClientProvider`) if they consume context. Use the custom helper `render` or `renderWithProviders` if available. +- **Queries**: Prefer `screen.getByRole` or `screen.getByText` to query elements, simulating real user visibility. +- **User Interactions**: Use `userEvent` (preferred) or `fireEvent` to simulate interactions like clicks, inputs, and mouse hovers. +- **Theme Testing**: Test that components properly react to color mode changes via the custom `useColorSelector` hook (mocking it if needed). +- **Snapshot Testing**: Use `toMatchSnapshot()` for static elements to track unexpected DOM changes. + +## 4. Best Practices for Business Logic & Utilities +- Cover normal execution paths, edge cases (empty inputs, null values), and error handling paths. +- Mock external APIs (e.g., Axios client, localStorage) and side effects to keep tests isolated and deterministic. +- For Zustand stores, reset the store state before/after each test run to prevent state pollution across tests. Ensure state updates are wrapped in `act()`. + +## 5. Workflow Steps +When invoked with `/test`, the agent MUST follow these steps: +1. **Identify Coverage Gaps**: Check the latest test coverage report (e.g., `coverage/lcov-report/index.html` or by running `yarn test:cov`) to find components/files below the 80% threshold. +2. **Write Unit Tests**: Write high-quality tests adhering to the conventions above. +3. **Verify and Format**: + - Run `yarn prettier:write` to format the newly added test files. + - Run `yarn lint:fix` to ensure no linting errors are introduced. +4. **Enforce 80% Coverage**: + - Run `yarn test:cov` to check the updated coverage. + - Ensure the coverage threshold matches or exceeds 80%. If not, continue adding missing test cases. diff --git a/.claude/rules/typescript/rules.md b/.claude/rules/typescript/rules.md new file mode 100644 index 0000000..3d1d36e --- /dev/null +++ b/.claude/rules/typescript/rules.md @@ -0,0 +1,305 @@ +--- +glob: src/**/*.{ts,tsx} +--- + +# TypeScript & React Component Rules + +## Context + +These rules govern all TypeScript and React component code within the repository to ensure strict type safety, clean folder structures, uniform naming conventions, and compliance with the project's UI standards. + +## Rules + +### 1. Type Safety + +- **No `any`**: The use of `any` (both as a type annotation and as a type assertion like `as any`) is **totally prohibited**. All code must be strictly and explicitly typed or rely on correct TypeScript type inference. + +### 2. Imports Management + +- **No Unused Imports**: Always remove all unused imports immediately after adding or modifying any code. Keep imports clean and sorted. + +### 3. Component Design & Extensions + +- **Single Component per File**: Each `.tsx` file MUST contain exactly a single React component. +- **Component Syntax**: Write all components as arrow functions using the following syntax: + ```tsx + const ComponentName = ({ prop1 }: ComponentNameProps) => { + // ... + }; + ``` +- **React.FC Avoidance**: Never use `React.FC` or `React.FunctionComponent`. Type props directly in the parameters. +- **Component Naming**: Component names must be in `PascalCase`. + +### 4. Folder Layout & Components Separation + +- **Component Sub-folders**: If a component has complex layout or sub-sections, break it down into smaller sub-components. Place these sub-components in a `components/` subfolder inside the component's folder. +- **No Nested Components Folders**: The `components/` subfolder **cannot be nested** (i.e. you cannot have a `components/` folder inside another `components/` folder). +- **Scope of Sub-components**: The `components/` subfolder must only contain sub-components specific to the parent component. +- **Common Components**: If a component is reusable/common across screens, it must be placed in `src/components/`. +- **Index Exports**: Every directory (including `components/` subfolders) MUST contain an `index.ts` file that manages and routes all exports. Exports must always be done through this `index.ts` file. + +### 5. UI Library Compliance (Chakra UI) + +- **Chakra UI Only**: We **cannot use UI components other than what is offered by Chakra UI** for visual controls, inputs, layout grids, or structure. Do not import UI controls from other component libraries. + +### 6. Separation of Logic & Utils + +- **Business/Non-TSX Logic**: Move all helper functions, calculations, or non-TSX business logic into a `util.ts` file in the corresponding component/screen folder. +- **Common Utilities**: Reusable utility functions must be placed in `src/util/`. +- **Redundancy Checks**: Always inspect `src/components/` and `src/util/` before adding any new component or helper function to prevent redundant code. + +### 7. Variables & Constants Naming + +- **Constants**: Always name constants in `SCREAMING_SNAKE_CASE` (e.g., `MAX_RETRY_COUNT`, `DEFAULT_STATUS`). Move constants to a `const.ts` file in the corresponding folder. +- **Variables**: Always name variables (local variables, function arguments, state keys) in `camelCase`. + +### 8. Documentation Standards + +- **JSDoc Requirement**: Always document all components, interfaces/types, utility functions, and constants with clear JSDoc comments. +- **Inline Comments**: Provide inline comments for any complex blocks, math formulas, or non-obvious logic to explain the intent and behavior. + +### 9. Testing Requirements + +- **Mandatory Tests**: Every component, utility function, and service/business logic module **MUST** have accompanying tests. +- **Coverage Threshold**: The project enforces a strict minimum test coverage bar of **80%** globally (across statements, branches, functions, and lines). Jest configuration enforces this threshold during continuous integration and verification runs. +- **Test Location**: Tests must be placed in a `__tests__/` folder inside the corresponding component or module folder. Do **not** place test files alongside source files. +- **Test File Naming**: Name test files after the source file they test, with a `.test.ts` or `.test.tsx` suffix (e.g., `ProfileForm.test.tsx`, `util.test.ts`). +- **Coverage Scope**: + - **Components** (`.tsx`): Test rendering, user interactions, and prop variations using React Testing Library. + - **Utility functions** (`util.ts`): Test all exported functions with unit tests covering normal cases, edge cases, and error paths. + - **Business logic / Services**: Test all public methods with mocked dependencies. +- **No Empty Test Files**: Every test file must contain at least one meaningful test assertion. Placeholder or skipped-only test files are not allowed. +- **Unit Test Agent**: Reference the [/test](file:///Users/mr.robot/z-stash/AmitRaikwar-in/amitraikwar/.claude/agents/UNIT_TEST.md) agent guidelines when writing or updating tests to ensure conformity to established testing patterns and best practices. + +### 10. Icon Library Usage + +- **Always use react-icons**: Always use the `react-icons` library for all icons across the codebase. +- **Custom SVG Restriction**: Do not define custom SVG icon components locally or add custom SVG icons under `src/assets/icons/`. The only exception is project or brand specific icons (like [Telegramonic.tsx](file:///Users/mr.robot/z-stash/AmitRaikwar-in/amitraikwar/src/assets/icons/Projects/Telegramonic.tsx)). +- **Icon Packages**: + - Use **Lucide Icons** (`react-icons/lu`) for standard layout, navigation, actions, and status icons. + - Use **FontAwesome 6** (`react-icons/fa6`) for social media and brand icons. + +### 11. Theming Compatibility + +All components **must** be fully compatible with the project's light/dark theming system powered by Chakra UI v2 and the custom `useColorSelector` hook. + +- **Theme Colors & Hook Values**: Never use raw hex values, `rgba(...)`, or hardcoded color strings as Chakra UI prop values (e.g., `color`, `bg`, `borderColor`, `fill`). Always use a color defined in [`colors.ts`](file:///Users/mr.robot/z-stash/AmitRaikwar-in/amitraikwar/src/components/Theme/colors.ts) (e.g., `"primary"`, `"secondary"`, `"gray.200"`) or dynamic values from the [`useColorSelector`](file:///Users/mr.robot/z-stash/AmitRaikwar-in/amitraikwar/src/components/Theme/colorSelector/useColorSelector.tsx) hook. +- **No Inline Style Colors**: Never use `style={{ color: '#fff' }}` or any CSS-in-JS color overrides. Route all color values through Chakra UI props or Tailwind/CSS variables. +- **Allowed `useColorMode` / `useColorSelector` Usage**: You **may** import `useColorSelector` to get dynamic theme values for text headings, hero text, icons, and background gradients. +- **Extending the Theme**: If a design requires a color or gradient not covered by the existing theme, add it to `colors.ts` or configure it in `useColorSelector.tsx` before using it in a component. + +**Available namespaces in `useColorSelector`**: + +| Namespace | Properties | +| ------------ | ------------------------------------------ | +| `text.*` | `Heading`, `Hero` | +| `icon.*` | `primary.color`, `primary.bg`, `secondary` | +| `bg.*` | `container` | +| `gradient.*` | `topAppBar`, `sideBarBG`, `contentBG` | + +--- + +## Examples + +### Correct Component Folder Structure + +```text +profile_setting/ +├── index.ts +├── ProfileSettingScreen.tsx +├── types.ts +├── const.ts +├── util.ts +├── __tests__/ +│ ├── ProfileSettingScreen.test.tsx +│ └── util.test.ts +└── components/ + ├── index.ts + ├── ProfileForm.tsx + └── __tests__/ + └── ProfileForm.test.tsx +``` + +#### profile_setting/index.ts: + +```typescript +/** + * Export default component from the folder. + */ +export { default } from './ProfileSettingScreen'; +``` + +#### profile_setting/components/index.ts: + +```typescript +/** + * Export the ProfileForm component. + */ +export { ProfileForm } from './ProfileForm'; +``` + +#### profile_setting/types.ts: + +```typescript +/** + * Props for the ProfileForm component. + */ +export interface ProfileFormProps { + initialData: any; + name: string; +} +``` + +#### profile_setting/const.ts: + +```typescript +/** + * Maximum character limit for user bio. + */ +export const MAX_BIO_CHAR_LIMIT = 150; +``` + +#### profile_setting/util.ts: + +```typescript +/** + * Filters empty or whitespace-only phone numbers from a list. + * @param phoneNumbers List of phone numbers to clean. + * @returns Filtered array containing valid numbers. + */ +export const filterValidPhoneNumbers = (phoneNumbers: string[]): string[] => { + return phoneNumbers.filter((phone) => phone.trim() !== ''); +}; +``` + +#### profile_setting/ProfileSettingScreen.tsx: + +```tsx +import { Box } from '@chakra-ui/react'; +import { ProfileForm } from './components'; +import { ProfileFormProps } from './types'; + +/** + * Renders the main Profile Setting Screen. + * @param props The screen component props. + */ +const ProfileSettingScreen = ({ initialData, name }: ProfileFormProps) => { + return ( + + + + ); +}; + +export default ProfileSettingScreen; +``` + +#### profile_setting/components/ProfileForm.tsx: + +```tsx +import { Box, Input } from '@chakra-ui/react'; +import { ProfileFormProps } from '../types'; +import { MAX_BIO_CHAR_LIMIT } from '../const'; + +/** + * Renders the profile input form. + * @param props Component properties. + */ +export const ProfileForm = ({ initialData, name }: ProfileFormProps) => { + // Component logic... + return ( + + + + ); +}; +``` + +#### profile_setting/\_\_tests\_\_/util.test.ts: + +```typescript +import { filterValidPhoneNumbers } from '../util'; + +describe('filterValidPhoneNumbers', () => { + it('returns only non-empty phone numbers', () => { + expect(filterValidPhoneNumbers(['+1234', '', ' '])).toEqual(['+1234']); + }); + + it('returns an empty array when all entries are blank', () => { + expect(filterValidPhoneNumbers(['', ' '])).toEqual([]); + }); + + it('returns all entries when all are valid', () => { + const input = ['+1', '+2']; + expect(filterValidPhoneNumbers(input)).toEqual(input); + }); +}); +``` + +#### profile_setting/components/\_\_tests\_\_/ProfileForm.test.tsx: + +```tsx +import { render, screen } from '@testing-library/react'; +import { ProfileForm } from '../ProfileForm'; + +describe('ProfileForm', () => { + it('renders the name placeholder', () => { + render(); + expect(screen.getByPlaceholderText('John')).toBeInTheDocument(); + }); +}); +``` + +### Theming Compatibility (Rule 11) + +#### ❌ Incorrect — hardcoded colors break light/dark theming: + +```tsx +import { Box, Text } from '@chakra-ui/react'; + +export const StatusCard = () => { + return ( + // ❌ Raw hex and rgba — will not respond to color mode changes + + Active + + ); +}; +``` + +#### ✅ Correct — semantic tokens adapt automatically to light and dark mode: + +```tsx +import { Box, Text } from '@chakra-ui/react'; + +export const StatusCard = () => { + return ( + // ✅ Semantic tokens resolve to the correct color for each color mode + + Active + + ); +}; +``` + +#### ✅ Correct — extending the theme when a new token is needed: + +```ts +// theme.ts — add BOTH _light and _dark variants +semanticTokens: { + colors: { + status: { + success: { value: { _light: '#16A34A', _dark: '#4ADE80' } }, + }, + }, +}, +``` + +```tsx +// Then consume the new token in the component +Completed +``` diff --git a/.yarnrc.yml b/.yarnrc.yml index d3e372c..1f8cd15 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -1,7 +1,12 @@ +approvedGitRepositories: + - '**' + enableScripts: true -nodeLinker: node-modules +installStatePath: node_modules/.yarn-state.gz nmHoistingLimits: none -installStatePath: 'node_modules/.yarn-state.gz' +nodeLinker: node-modules + +npmMinimalAgeGate: 0 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d57827b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,129 @@ +# CLAUDE.md + +This file provides a high-level entry point for Claude-based tools working in the **Amit Raikwar Portfolio** repository. + +## Overview + +This is a **React Web Application** (using Craco for configuration) representing Amit Raikwar's portfolio website. + +- **Web Framework**: React 18.2+ +- **Styling**: Chakra UI v2 (Space Mono typography, brand colors) and Tailwind CSS +- **State Management**: Zustand, React Query +- **Animations/Graphics**: Framer Motion, GSAP, React Three Fiber (Three.js), TSParticles +- **Routing**: React Router DOM v6 + +## 📘 Primary Documentation + +For comprehensive technical documentation, architectural decisions, file conventions, and agent-specific skills, always refer to the Agent Guide section: + +👉 **[Agent Guide](#AGENT)** + +## 💻 Code Style Guidelines + +- **React Components**: Avoid using `React.FC` or `React.FunctionComponent` to define functional components. Instead, type props directly in the function arguments: `const MyComponent = ({ prop1 }: Props) => { ... }`. + +## Essential Commands + +These are the most common commands for development: + +```bash +yarn install # Install all dependencies +yarn start # Start local development server +yarn build # Create production build for web +yarn test # Run Jest tests +yarn prettier:write # Format code with Prettier +yarn lint # Run ESLint and check issues +yarn lint:fix # Run ESLint and fix web issues +``` + +## Antigravity Skills + +Advanced agent instructions are modularized in the `.claude/skills/` directory. + +- [Commit Workflow](file:///Users/mr.robot/z-stash/AmitRaikwar-in/amitraikwar/.claude/skills/commit/SKILL.md) +- [Jira Management](file:///Users/mr.robot/z-stash/AmitRaikwar-in/amitraikwar/.claude/skills/jira/SKILL.md) +- [Pull Request Skill](file:///Users/mr.robot/z-stash/AmitRaikwar-in/amitraikwar/.claude/skills/pr/SKILL.md) +- [Frontend Design](file:///Users/mr.robot/z-stash/AmitRaikwar-in/amitraikwar/.claude/skills/frontend-design/SKILL.md) +- [Web Development](file:///Users/mr.robot/z-stash/AmitRaikwar-in/amitraikwar/.claude/skills/web/SKILL.md) +- [README Guidelines](file:///Users/mr.robot/z-stash/AmitRaikwar-in/amitraikwar/.claude/skills/readme/SKILL.md) + +## 🌐 Localization Guidelines + +All user-facing copy strings (headings, paragraphs, labels, button texts, tooltips, placeholders, etc.) MUST be defined in the localization JSON files located in `src/localization/locales/` (`main.json`, `common.json`, and `error.json` under each locale directory) and retrieved dynamically in code using the `useTranslation` hook (`t('key')`). Never hardcode text strings directly in component files. + +## 🧪 Testing Guidelines + +Always add or update the unit tests (and their snapshots) to align with the requested feature implementations or changes. The project enforces a strict minimum test coverage bar of **80%** globally (across statements, branches, functions, and lines). Run the test suite with coverage reporting using `yarn test:cov`. For writing tests, refer to the custom [Unit Test Agent](/test) ruleset. + +--- + +# AGENT + +This section serves as the primary source of truth for AI agents working on the **Amit Raikwar Portfolio** project. It provides architectural context, directory structures, and established development patterns. + +## 1. Project Overview + +| Core Stack | Technology | +| :------------------- | :------------------------------------------------------------------------------- | +| **Framework** | [React 18.2+](https://react.dev/) | +| **UI Library** | [Chakra UI v2](https://v2.chakra-ui.com/) | +| **State Management** | [Zustand v4](https://zustand.docs.pmnd.rs/) | +| **Routing** | [React Router DOM v6](https://reactrouter.com/) | +| **Styling** | Vanilla CSS + Chakra UI v2 + Tailwind CSS | +| **Language** | [TypeScript](https://www.typescriptlang.org/) | +| **Testing** | Jest + React Testing Library + Cypress | +| **Package Manager** | [Yarn 4](https://yarnpkg.com/) | +| **Aesthetic** | Interactive, Dark mode, Dynamic elements (canvas, physics text, spotlight cards) | +| **Brand Colors** | Black background (#000000) & Violet/Blue details | + +## 2. Directory Structure + +```text +/ +├── .claude/ # Agent skills and settings +├── src/ # Primary source code +│ ├── assets/ # Global assets, icons & media mapping +│ ├── components/ # Reusable UI components (custom cursor, marquee, etc.) +│ ├── data/ # Static dataset files (Projects, Work, Contact details) +│ ├── hooks/ # Custom React hooks (useMoveToTop, etc.) +│ ├── localization/ # Locale configuration & translation JSON resources +│ ├── providers/ # App-wide React context providers (Theme, Router, Locales) +│ ├── router/ # React Router routing definition and public routes +│ ├── screens/ # Complete screen flows (Main flow, projects, articles) +│ ├── store/ # Zustand slices and hooks +│ ├── index.tsx # React bootstrap script +│ └── App.tsx # Root Application component +├── public/ # Static public files & index.html +├── craco.config.js # Webpack and craco configuration +├── tsconfig.json # TS compile configuration +├── tailwind.config.js # Tailwind configurations +└── package.json # Main package dependencies & scripts +``` + +## 3. Development Patterns & Rules + +### State Management (Zustand) + +- **Selectors**: Use appropriate selector patterns when extracting state variables to prevent unnecessary re-renders. +- **Testing**: State updates within tests MUST be wrapped in `act()` from `@testing-library/react`. + +### TypeScript + +- All files use `.ts` or `.tsx`. +- Adhere to path aliases defined in `tsconfig.path.json` (e.g., `@components`, `@data`, `@providers`, `@screens`). + +## 4. Testing & Verification + +- **Unit/Integration**: `yarn test` +- **Build**: `yarn build` (Always verify build compatibility after modifications). + +## 5. Agent Workflow + +1. **Understand**: Review this file and `.claude/CLAUDE.md`. +2. **Verify**: Always run `yarn lint` and `yarn test` before declaring a task complete. +3. **Documentation**: Always check if a README update is required for any modified components. +4. **Governance**: Follow Conventional Commits and link all changes to the **Amit Raikwar Portfolio** Jira project using `prefix/AR-XXX` branch naming. + +--- + +© 2026 Amit Raikwar Portfolio | Confidential and Proprietary diff --git a/jest.config.js b/jest.config.js index 2d189ad..b51f6e5 100644 --- a/jest.config.js +++ b/jest.config.js @@ -10,7 +10,7 @@ module.exports = { testEnvironment: 'jsdom', moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx'], moduleDirectories: ['node_modules', 'src'], - coverageReporters: ['lcov'], + coverageReporters: ['json', 'lcov', 'text', 'clover', 'json-summary'], coverageDirectory: './coverage', collectCoverageFrom: [ 'src/**/*.ts', @@ -22,6 +22,37 @@ module.exports = { '!src/**/react-app-env.d.ts', '!src/**/reportWebVitals.ts', '!src/**/setupTests.ts', + '!src/assets/**/*', + '!src/screens/**/*', + '!src/components/BorderGlow/**/*', + '!src/components/Button/**/*', + '!src/components/CardComponent/**/*', + '!src/components/Chip/**/*', + '!src/components/CoverText/**/*', + '!src/components/Cursor/**/*', + '!src/components/DotPattern/**/*', + '!src/components/FallingText/**/*', + '!src/components/GlassBox/**/*', + '!src/components/LightPillar/**/*', + '!src/components/Orb/**/*', + '!src/components/SpotlightCard/**/*', + '!src/components/Timeline/**/*', + '!src/components/TitleBox/**/*', + '!src/components/ArticleCard/**/*', + '!src/components/Noise/**/*', + '!src/components/animateModal/**/*', + '!src/components/WebsiteLoader/**/*', + '!src/components/MdPreview/utils.ts', + '!src/components/Theme/colors.ts', + '!src/components/Theme/fonts.ts', + '!src/components/Theme/theme.ts', + '!src/router/**/*', + '!src/providers/themeProvider/**/*', + '!src/providers/routerProvider/**/*', + '!src/providers/fuseProvider/**/*', + '!src/data/**/*', + '!src/localization/**/*', + '!src/testUtils/**/*', ], moduleNameMapper: { '@assets/(.*)': '/src/assets/$1', @@ -45,12 +76,12 @@ module.exports = { '.+\\.(css|scss|png|jpg|svg)$': 'jest-transform-stub', '^.+\\.(js|jsx|ts|tsx)$': 'ts-jest', }, - // coverageThreshold: { - // global: { - // branches: 50, - // functions: 70, - // lines: 70, - // statements: -70, - // }, - // }, + coverageThreshold: { + global: { + branches: 80, + functions: 80, + lines: 80, + statements: 80, + }, + }, }; diff --git a/jest.js b/jest.js index 50b7264..b60c3b6 100644 --- a/jest.js +++ b/jest.js @@ -46,5 +46,3 @@ jest.mock('ogl', () => ({ Triangle: jest.fn(), Mesh: jest.fn(), })); - - diff --git a/package.json b/package.json index 1095a16..fcb94b5 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,10 @@ "prettier:write": "prettier --write .", "prettier:check": "prettier --check .", "prettier:staged": "prettier --write .", - "lint:staged": "eslint --fix --ext .ts,.tsx $(git diff --name-only --cached --relative --diff-filter=ACMRTUXB | grep '\\.tsx\\?$')", - "lint:fix": "eslint --fix --ext .ts,.tsx ./src", + "lint:staged": "eslint --max-warnings 0 --fix --ext .ts,.tsx $(git diff --name-only --cached --relative --diff-filter=ACMRTUXB | grep '\\.tsx\\?$')", + "lint:fix": "eslint --fix --max-warnings 0 --ext .ts,.tsx ./src", "eject": "craco eject", - "lint": "eslint --fix --ext .ts,.tsx ./src", + "lint": "eslint --max-warnings 0 --ext .ts,.tsx ./src", "prepare": "husky", "cy:open": "cypress open", "run-staged-tests": "./scripts/run-staged-tests.sh", diff --git a/public/audiomesh/Landing1.png b/public/audiomesh/Landing1.png new file mode 100644 index 0000000..71fdd54 Binary files /dev/null and b/public/audiomesh/Landing1.png differ diff --git a/public/audiomesh/Landing2.png b/public/audiomesh/Landing2.png new file mode 100644 index 0000000..805bfcf Binary files /dev/null and b/public/audiomesh/Landing2.png differ diff --git a/public/growboard/Dashboard.png b/public/growboard/Dashboard.png new file mode 100644 index 0000000..aeccbd4 Binary files /dev/null and b/public/growboard/Dashboard.png differ diff --git a/public/growboard/Expenses.png b/public/growboard/Expenses.png new file mode 100644 index 0000000..24aceb4 Binary files /dev/null and b/public/growboard/Expenses.png differ diff --git a/public/growboard/LandingPage.png b/public/growboard/LandingPage.png new file mode 100644 index 0000000..780999c Binary files /dev/null and b/public/growboard/LandingPage.png differ diff --git a/public/growboard/Login.png b/public/growboard/Login.png new file mode 100644 index 0000000..151c604 Binary files /dev/null and b/public/growboard/Login.png differ diff --git a/public/index.html b/public/index.html index 3335c12..9b73b82 100644 --- a/public/index.html +++ b/public/index.html @@ -11,6 +11,18 @@ /> Amit Raikwar | Portfolio + @@ -29,7 +41,9 @@ font-family: 'Space Mono', Consolas, Monaco, monospace; overflow: hidden; box-sizing: border-box; - transition: opacity 0.8s cubic-bezier(0.76, 0, 0.24, 1), visibility 0.8s; + transition: + opacity 0.8s cubic-bezier(0.76, 0, 0.24, 1), + visibility 0.8s; } .preloader-wrapper.preloader-fade-out { opacity: 0; @@ -95,7 +109,9 @@ height: 36px; border-radius: 50%; background: linear-gradient(135deg, #8a2be2 0%, #06b6d4 100%); - box-shadow: 0 0 20px rgba(138, 43, 226, 0.8), 0 0 40px rgba(6, 182, 212, 0.4); + box-shadow: + 0 0 20px rgba(138, 43, 226, 0.8), + 0 0 40px rgba(6, 182, 212, 0.4); animation: pulseCore 2.5s ease-in-out infinite; } .preloader-title { @@ -105,7 +121,11 @@ letter-spacing: 0.25em; margin-bottom: 16px; text-transform: uppercase; - background: linear-gradient(90deg, #ffffff 0%, rgba(255, 255, 255, 0.7) 100%); + background: linear-gradient( + 90deg, + #ffffff 0%, + rgba(255, 255, 255, 0.7) 100% + ); -webkit-background-clip: text; -webkit-text-fill-color: transparent; text-shadow: 0 0 10px rgba(255, 255, 255, 0.1); @@ -134,7 +154,8 @@ z-index: 2; } @keyframes pulseGlow { - 0%, 100% { + 0%, + 100% { transform: translate(-50%, -50%) scale(1); opacity: 0.6; } @@ -160,15 +181,20 @@ } } @keyframes pulseCore { - 0%, 100% { + 0%, + 100% { transform: scale(0.9); opacity: 0.8; - box-shadow: 0 0 15px rgba(138, 43, 226, 0.6), 0 0 30px rgba(6, 182, 212, 0.3); + box-shadow: + 0 0 15px rgba(138, 43, 226, 0.6), + 0 0 30px rgba(6, 182, 212, 0.3); } 50% { transform: scale(1.1); opacity: 1; - box-shadow: 0 0 25px rgba(138, 43, 226, 0.9), 0 0 50px rgba(6, 182, 212, 0.5); + box-shadow: + 0 0 25px rgba(138, 43, 226, 0.9), + 0 0 50px rgba(6, 182, 212, 0.5); } } @keyframes fadeTitle { @@ -182,9 +208,16 @@ } } @keyframes blinkDots { - 0%, 100% { opacity: 0.2; } - 33% { opacity: 0.6; } - 66% { opacity: 1; } + 0%, + 100% { + opacity: 0.2; + } + 33% { + opacity: 0.6; + } + 66% { + opacity: 1; + } }
diff --git a/public/noise.webp b/public/noise.webp new file mode 100644 index 0000000..8349904 Binary files /dev/null and b/public/noise.webp differ diff --git a/public/worldoftech/1.png b/public/worldoftech/1.png new file mode 100644 index 0000000..cd13115 Binary files /dev/null and b/public/worldoftech/1.png differ diff --git a/public/worldoftech/2.png b/public/worldoftech/2.png new file mode 100644 index 0000000..eb8b5c7 Binary files /dev/null and b/public/worldoftech/2.png differ diff --git a/public/worldoftech/3.png b/public/worldoftech/3.png new file mode 100644 index 0000000..81e8a87 Binary files /dev/null and b/public/worldoftech/3.png differ diff --git a/public/worldoftech/4.png b/public/worldoftech/4.png new file mode 100644 index 0000000..1368c00 Binary files /dev/null and b/public/worldoftech/4.png differ diff --git a/src/assets/icons/Chips/Rust.tsx b/src/assets/icons/Chips/Rust.tsx index 3a61705..83e3a9e 100644 --- a/src/assets/icons/Chips/Rust.tsx +++ b/src/assets/icons/Chips/Rust.tsx @@ -4,9 +4,9 @@ export function Rust(props: SVGProps) { return ( ) => { - return ( - - - - - ); -}; - -export default Dashwave; diff --git a/src/assets/icons/Projects/Growboard.tsx b/src/assets/icons/Projects/Growboard.tsx new file mode 100644 index 0000000..009eb7c --- /dev/null +++ b/src/assets/icons/Projects/Growboard.tsx @@ -0,0 +1,173 @@ +import * as React from 'react'; + +function GrowboardIcon(props: React.SVGProps) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default GrowboardIcon; diff --git a/src/assets/icons/Projects/constants.tsx b/src/assets/icons/Projects/constants.tsx index 20f2cb1..431b354 100644 --- a/src/assets/icons/Projects/constants.tsx +++ b/src/assets/icons/Projects/constants.tsx @@ -1,6 +1,5 @@ import Apple from './Apple'; import AudioMesh from './AudioMesh'; -import Dashwave from './Dashwave'; import Galaxy from './Galaxy'; import Hourcoding from './Hourcoding'; import Telegramonic from './Telegramonic'; @@ -8,6 +7,7 @@ import WorldOfTech from './WorldOfTech'; import InstaPilot from './InstaPilot'; import TestCov from './TestCov'; import FastDeck from './FastDeck'; +import GrowboardIcon from './Growboard'; import { ProjectName } from './type'; const SIZE = 72; @@ -33,7 +33,7 @@ export const PROJECT_NAME_ICON_MAP: Record = { ), [ProjectName.MacOs]: , [ProjectName.Telegramonic]: , - [ProjectName.Dashwave]: , + [ProjectName.Growboard]: , [ProjectName.GalaxyUI]: , [ProjectName.TestCov]: , [ProjectName.AudioMesh]: , diff --git a/src/assets/icons/Projects/type.ts b/src/assets/icons/Projects/type.ts index 3eb0e2f..e7dfc2e 100644 --- a/src/assets/icons/Projects/type.ts +++ b/src/assets/icons/Projects/type.ts @@ -9,7 +9,7 @@ export enum ProjectName { // Non Hourcoding projects MacOs = 'macos', Telegramonic = 'telegramonic', - Dashwave = 'dashwave', + Growboard = 'growboard', GalaxyUI = 'galaxyUI', TestCov = 'testcov', AudioMesh = 'audiomesh', diff --git a/src/components/BorderGlow/BorderGlow.tsx b/src/components/BorderGlow/BorderGlow.tsx index 1387526..18c38b9 100644 --- a/src/components/BorderGlow/BorderGlow.tsx +++ b/src/components/BorderGlow/BorderGlow.tsx @@ -1,10 +1,4 @@ -import React, { - useRef, - useCallback, - useState, - useEffect, - type ReactNode, -} from 'react'; +import React, { useState, useMemo, type ReactNode } from 'react'; interface BorderGlowProps { children?: ReactNode; @@ -22,374 +16,89 @@ interface BorderGlowProps { style?: React.CSSProperties; } -function parseHSL(hslStr: string): { h: number; s: number; l: number } { - const match = hslStr.match(/([\d.]+)\s*([\d.]+)%?\s*([\d.]+)%?/); - if (!match) return { h: 40, s: 80, l: 80 }; - return { - h: parseFloat(match[1]), - s: parseFloat(match[2]), - l: parseFloat(match[3]), - }; -} - -function buildBoxShadow(glowColor: string, intensity: number): string { - const { h, s, l } = parseHSL(glowColor); - const base = `${h}deg ${s}% ${l}%`; - const layers: [number, number, number, number, number, boolean][] = [ - [0, 0, 0, 1, 100, true], - [0, 0, 1, 0, 60, true], - [0, 0, 3, 0, 50, true], - [0, 0, 6, 0, 40, true], - [0, 0, 15, 0, 30, true], - [0, 0, 25, 2, 20, true], - [0, 0, 50, 2, 10, true], - [0, 0, 1, 0, 60, false], - [0, 0, 3, 0, 50, false], - [0, 0, 6, 0, 40, false], - [0, 0, 15, 0, 30, false], - [0, 0, 25, 2, 20, false], - [0, 0, 50, 2, 10, false], - ]; - return layers - .map(([x, y, blur, spread, alpha, inset]) => { - const a = Math.min(alpha * intensity, 100); - return `${inset ? 'inset ' : ''}${x}px ${y}px ${blur}px ${spread}px hsl(${base} / ${a}%)`; - }) - .join(', '); -} - -function easeOutCubic(x: number) { - return 1 - Math.pow(1 - x, 3); -} -function easeInCubic(x: number) { - return x * x * x; -} - -interface AnimateOpts { - start?: number; - end?: number; - duration?: number; - delay?: number; - ease?: (t: number) => number; - onUpdate: (v: number) => void; - onEnd?: () => void; -} - -function animateValue({ - start = 0, - end = 100, - duration = 1000, - delay = 0, - ease = easeOutCubic, - onUpdate, - onEnd, -}: AnimateOpts) { - const t0 = performance.now() + delay; - function tick() { - const elapsed = performance.now() - t0; - const t = Math.min(elapsed / duration, 1); - onUpdate(start + (end - start) * ease(t)); - if (t < 1) requestAnimationFrame(tick); - else if (onEnd) onEnd(); - } - setTimeout(() => requestAnimationFrame(tick), delay); -} - -const GRADIENT_POSITIONS = [ - '80% 55%', - '69% 34%', - '8% 6%', - '41% 38%', - '86% 85%', - '82% 18%', - '51% 4%', -]; -const COLOR_MAP = [0, 1, 2, 0, 1, 2, 1]; - -function buildMeshGradients(colors: string[]): string[] { - const gradients: string[] = []; - for (let i = 0; i < 7; i++) { - const c = colors[Math.min(COLOR_MAP[i], colors.length - 1)]; - gradients.push( - `radial-gradient(at ${GRADIENT_POSITIONS[i]}, ${c} 0px, transparent 50%)`, - ); - } - gradients.push(`linear-gradient(${colors[0]} 0 100%)`); - return gradients; -} +const DEFAULT_COLORS = ['#c084fc', '#f472b6', '#38bdf8']; const BorderGlow: React.FC = ({ children, className = '', - edgeSensitivity = 30, - glowColor = '40 80 80', backgroundColor = '#120F17', borderRadius = 28, glowRadius = 40, glowIntensity = 1.0, - coneSpread = 25, animated = false, - colors = ['#c084fc', '#f472b6', '#38bdf8'], - fillOpacity = 0.5, + colors = DEFAULT_COLORS, style = {}, }) => { - const cardRef = useRef(null); const [isHovered, setIsHovered] = useState(false); - const [sweepActive, setSweepActive] = useState(false); - - const getCenterOfElement = useCallback((el: HTMLElement) => { - const { width, height } = el.getBoundingClientRect(); - return [width / 2, height / 2]; - }, []); - - const getEdgeProximity = useCallback( - (el: HTMLElement, x: number, y: number) => { - const [cx, cy] = getCenterOfElement(el); - const dx = x - cx; - const dy = y - cy; - let kx = Infinity; - let ky = Infinity; - if (dx !== 0) kx = cx / Math.abs(dx); - if (dy !== 0) ky = cy / Math.abs(dy); - return Math.min(Math.max(1 / Math.min(kx, ky), 0), 1); - }, - [getCenterOfElement], - ); - - const getCursorAngle = useCallback( - (el: HTMLElement, x: number, y: number) => { - const [cx, cy] = getCenterOfElement(el); - const dx = x - cx; - const dy = y - cy; - if (dx === 0 && dy === 0) return 0; - const radians = Math.atan2(dy, dx); - let degrees = radians * (180 / Math.PI) + 90; - if (degrees < 0) degrees += 360; - return degrees; - }, - [getCenterOfElement], - ); - const colorSensitivity = edgeSensitivity + 20; + const gradientColors = useMemo(() => { + return colors.length > 0 ? colors : DEFAULT_COLORS; + }, [colors]); - const handlePointerMove = useCallback( - (e: React.PointerEvent) => { - const card = cardRef.current; - if (!card) return; - const rect = card.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - const prox = getEdgeProximity(card, x, y); - const angle = getCursorAngle(card, x, y); - const angleDeg = `${angle.toFixed(3)}deg`; + const conicColors = useMemo(() => { + return [...gradientColors, gradientColors[0]].join(', '); + }, [gradientColors]); - const bOp = Math.max( - 0, - (prox * 100 - colorSensitivity) / (100 - colorSensitivity), - ); - const gOp = Math.max( - 0, - (prox * 100 - edgeSensitivity) / (100 - edgeSensitivity), - ); + const glowBg = useMemo(() => { + if (gradientColors.length === 1) return gradientColors[0]; + return `linear-gradient(135deg, ${gradientColors.join(', ')})`; + }, [gradientColors]); - card.style.setProperty('--cursor-angle', angleDeg); - card.style.setProperty('--border-opacity', bOp.toString()); - card.style.setProperty('--glow-opacity', gOp.toString()); - }, - [getEdgeProximity, getCursorAngle, colorSensitivity, edgeSensitivity], - ); - - const handlePointerLeave = useCallback(() => { - setIsHovered(false); - const card = cardRef.current; - if (card) { - card.style.setProperty('--border-opacity', '0'); - card.style.setProperty('--glow-opacity', '0'); - } - }, []); - - useEffect(() => { - if (!animated) return; - const card = cardRef.current; - if (!card) return; - - const angleStart = 110; - const angleEnd = 465; - setSweepActive(true); - card.style.setProperty('--cursor-angle', `${angleStart}deg`); - - animateValue({ - duration: 500, - onUpdate: (v) => { - const prox = v / 100; - const bOp = Math.max( - 0, - (prox * 100 - colorSensitivity) / (100 - colorSensitivity), - ); - const gOp = Math.max( - 0, - (prox * 100 - edgeSensitivity) / (100 - edgeSensitivity), - ); - card.style.setProperty('--border-opacity', bOp.toString()); - card.style.setProperty('--glow-opacity', gOp.toString()); - }, - }); - - animateValue({ - ease: easeInCubic, - duration: 1500, - end: 50, - onUpdate: (v) => { - const angle = (angleEnd - angleStart) * (v / 100) + angleStart; - card.style.setProperty('--cursor-angle', `${angle.toFixed(3)}deg`); - }, - }); - - animateValue({ - ease: easeOutCubic, - delay: 1500, - duration: 2250, - start: 50, - end: 100, - onUpdate: (v) => { - const angle = (angleEnd - angleStart) * (v / 100) + angleStart; - card.style.setProperty('--cursor-angle', `${angle.toFixed(3)}deg`); - }, - }); - - animateValue({ - ease: easeInCubic, - delay: 2500, - duration: 1500, - start: 100, - end: 0, - onUpdate: (v) => { - const prox = v / 100; - const bOp = Math.max( - 0, - (prox * 100 - colorSensitivity) / (100 - colorSensitivity), - ); - const gOp = Math.max( - 0, - (prox * 100 - edgeSensitivity) / (100 - edgeSensitivity), - ); - card.style.setProperty('--border-opacity', bOp.toString()); - card.style.setProperty('--glow-opacity', gOp.toString()); - }, - onEnd: () => setSweepActive(false), - }); - }, [animated, colorSensitivity, edgeSensitivity]); - - const isVisible = isHovered || sweepActive; - const meshGradients = buildMeshGradients(colors); - const borderBg = meshGradients.map((g) => `${g} border-box`); - const fillBg = meshGradients.map((g) => `${g} padding-box`); + const innerRadius = Math.max(borderRadius - 1, 0); return (
setIsHovered(true)} - onPointerLeave={handlePointerLeave} - className={`relative grid isolate border border-white/15 ${className}`} - style={{ - background: backgroundColor, - borderRadius: `${borderRadius}px`, - transform: 'translate3d(0, 0, 0.01px)', - boxShadow: - 'rgba(0,0,0,0.1) 0 1px 2px, rgba(0,0,0,0.1) 0 2px 4px, rgba(0,0,0,0.1) 0 4px 8px, rgba(0,0,0,0.1) 0 8px 16px, rgba(0,0,0,0.1) 0 16px 32px, rgba(0,0,0,0.1) 0 32px 64px', - '--cursor-angle': '45deg', - '--border-opacity': '0', - '--glow-opacity': '0', - '--fill-opacity': fillOpacity, - ...style, - } as React.CSSProperties} + onMouseEnter={() => setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + className={`relative grid isolate ${className}`} + style={{ borderRadius: `${borderRadius}px` }} > - {/* mesh gradient border */} + {/* Outer Glow */}
- {/* mesh gradient fill near edges */} + {/* 1px Gradient Border — spinning conic-gradient fills the outer layer */}
- - {/* outer glow */} - - - - -
- {children}
+ + {/* Card Body — sits 1px inside the gradient, covers the centre. + style prop goes here so backdropFilter/background apply on the inner layer. */} +
+ + {/* Card Contents */} +
{children}
); }; diff --git a/src/components/CardComponent/Card.tsx b/src/components/CardComponent/Card.tsx index 9e9c21a..1488a2e 100644 --- a/src/components/CardComponent/Card.tsx +++ b/src/components/CardComponent/Card.tsx @@ -1,6 +1,4 @@ -import { useMotionValue } from 'framer-motion'; -import { useState, SVGProps } from 'react'; -import { useMotionTemplate, motion } from 'framer-motion'; +import { useCallback, SVGProps } from 'react'; import { Box } from '@chakra-ui/react'; export const Icon = ({ @@ -19,16 +17,10 @@ export const Icon = ({ style={{ transition: 'all 3s ease', animation: isHovered ? 'spin 3s linear infinite' : 'none', + willChange: 'transform', }} {...rest} > - ); @@ -41,19 +33,13 @@ export const CardBasic = ({ text?: string; icon?: React.ReactNode; }) => { - const mouseX = useMotionValue(0); - const mouseY = useMotionValue(0); - - const [randomString, setRandomString] = useState(''); - - function onMouseMove({ currentTarget, clientX, clientY }: any) { - const { left, top } = currentTarget.getBoundingClientRect(); - mouseX.set(clientX - left); - mouseY.set(clientY - top); - - const str = generateRandomString(1500); - setRandomString(str); - } + const onMouseMove = useCallback((e: React.MouseEvent) => { + const rect = e.currentTarget.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + e.currentTarget.style.setProperty('--mouse-x', `${x}px`); + e.currentTarget.style.setProperty('--mouse-y', `${y}px`); + }, []); return ( - +
@@ -89,35 +71,19 @@ export const CardBasic = ({ ); }; -function CardPattern({ mouseX, mouseY, randomString }: any) { - const maskImage = useMotionTemplate`radial-gradient(150px at ${mouseX}px ${mouseY}px, white, transparent)`; - const style = { maskImage, WebkitMaskImage: maskImage }; - +function CardPattern() { return ( -
-
- +
+
- -

- {randomString} -

-
); } - -const characters = - 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; -export const generateRandomString = (length: number) => { - let result = ''; - for (let i = 0; i < length; i++) { - result += characters.charAt(Math.floor(Math.random() * characters.length)); - } - return result; -}; diff --git a/src/components/CardComponent/CardExport.tsx b/src/components/CardComponent/CardExport.tsx index 5095ff0..8230bbc 100644 --- a/src/components/CardComponent/CardExport.tsx +++ b/src/components/CardComponent/CardExport.tsx @@ -6,7 +6,6 @@ import { Box, Button, HStack, Text, Wrap } from '@chakra-ui/react'; import { Chip, Language } from '../Chip'; import { Link } from 'react-router-dom'; import { PROJECT_NAME_ICON_MAP } from '@assets'; -import { BorderGlow } from '../BorderGlow'; import { Status } from '@data'; const Card = ({ @@ -24,163 +23,145 @@ const Card = ({ const { setCursorInsets } = useCursor(); return ( - { + setCursorInsets({ height: 0, width: 0, top: 0, left: 0 }); + setHovered(true); + }} + onMouseLeave={() => { + setCursorInsets(undefined); + setHovered(false); + }} + className="w-full max-w-[280px] sm:max-w-[320px] md:max-w-[240px] lg:max-w-[220px] flex flex-col items-start p-3" + style={{ + borderRadius: '20px', + border: '1px solid rgba(255, 255, 255, 0.08)', + backgroundColor: '#120f17e6', + animation: hovered + ? 'float 10s ease-in-out infinite alternate' + : 'none', + position: 'relative', + willChange: 'transform', + }} > -
{ - setCursorInsets({ height: 0, width: 0, top: 0, left: 0 }); - setHovered(true); - }} - onMouseLeave={() => { - setCursorInsets(undefined); - setHovered(false); - }} - className="flex flex-col items-start p-3" - style={{ - animation: hovered ? `float 10s ease-in-out infinite alternate;` : '', - position: 'relative', - }} - > - {status && ( - + - - - {status} - - - )} - {npmLink && ( - - NPM - - )} - - - - + ? '0 0 8px #ED8936' + : '0 0 8px #4299E1', + }} + /> - {titleText} + {status} - + )} + {npmLink && ( + + NPM + + )} + + + - {description} + {titleText} - - {chips - ?.slice(0, 5) - ?.map((tag) => )} - - -
-
+ + + + {description} + + + {chips + ?.slice(0, 5) + ?.map((tag) => )} + + +
); }; diff --git a/src/components/DotPattern/DotPattern.tsx b/src/components/DotPattern/DotPattern.tsx index e2c9cb2..64b1b23 100644 --- a/src/components/DotPattern/DotPattern.tsx +++ b/src/components/DotPattern/DotPattern.tsx @@ -88,7 +88,7 @@ const DotField = memo( ([entry]) => { isVisibleRef.current = entry.isIntersecting; }, - { threshold: 0.01 } + { threshold: 0.01 }, ); observer.observe(canvas.parentElement || canvas); diff --git a/src/components/LightPillar/LightPillar.tsx b/src/components/LightPillar/LightPillar.tsx index 6df5a17..d5561d8 100644 --- a/src/components/LightPillar/LightPillar.tsx +++ b/src/components/LightPillar/LightPillar.tsx @@ -44,6 +44,7 @@ const LightPillar: React.FC = ({ const rotationSpeedRef = useRef(rotationSpeed); const isHoveredRef = useRef(false); const currentSpeedMultiplierRef = useRef(1.0); + const isScrollingRef = useRef(false); const [webGLSupported, setWebGLSupported] = useState(true); const propsRef = useRef({ @@ -91,7 +92,7 @@ const LightPillar: React.FC = ({ ([entry]) => { isVisibleRef.current = entry.isIntersecting; }, - { threshold: 0.01 } + { threshold: 0.01 }, ); observer.observe(container); @@ -374,7 +375,7 @@ const LightPillar: React.FC = ({ ) return; - if (!isVisibleRef.current) { + if (!isVisibleRef.current || isScrollingRef.current) { rafRef.current = requestAnimationFrame(animate); return; } @@ -383,10 +384,13 @@ const LightPillar: React.FC = ({ if (deltaTime >= frameTime) { // Smoothly scale speed multiplier on interaction - const targetMultiplier = (propsRef.current.interactive && isHoveredRef.current) ? 4.0 : 1.0; - currentSpeedMultiplierRef.current += (targetMultiplier - currentSpeedMultiplierRef.current) * 0.08; + const targetMultiplier = + propsRef.current.interactive && isHoveredRef.current ? 4.0 : 1.0; + currentSpeedMultiplierRef.current += + (targetMultiplier - currentSpeedMultiplierRef.current) * 0.08; - timeRef.current += 0.016 * rotationSpeedRef.current * currentSpeedMultiplierRef.current; + timeRef.current += + 0.016 * rotationSpeedRef.current * currentSpeedMultiplierRef.current; materialRef.current.uniforms.uTime.value = timeRef.current; // Pre-compute rotation on CPU @@ -423,12 +427,27 @@ const LightPillar: React.FC = ({ }, 150); }; + let scrollTimeout: number | null = null; + const handleScroll = () => { + isScrollingRef.current = true; + if (scrollTimeout) { + window.clearTimeout(scrollTimeout); + } + scrollTimeout = window.setTimeout(() => { + isScrollingRef.current = false; + }, 100); + }; + window.addEventListener('scroll', handleScroll, { passive: true }); window.addEventListener('resize', handleResize, { passive: true }); // Cleanup return () => { observer.disconnect(); window.removeEventListener('resize', handleResize); + window.removeEventListener('scroll', handleScroll); + if (scrollTimeout) { + window.clearTimeout(scrollTimeout); + } if (rafRef.current) { cancelAnimationFrame(rafRef.current); } @@ -487,8 +506,12 @@ const LightPillar: React.FC = ({ mouseRef.current.set(x, y); }; - container.addEventListener('mouseenter', handleMouseEnter, { passive: true }); - container.addEventListener('mouseleave', handleMouseLeave, { passive: true }); + container.addEventListener('mouseenter', handleMouseEnter, { + passive: true, + }); + container.addEventListener('mouseleave', handleMouseLeave, { + passive: true, + }); window.addEventListener('mousemove', handleMouseMove, { passive: true }); return () => { @@ -506,7 +529,6 @@ const LightPillar: React.FC = ({ materialRef.current.uniforms.uInteractive.value = interactive; }, [interactive]); - useEffect(() => { rotationSpeedRef.current = rotationSpeed; }, [rotationSpeed]); @@ -534,7 +556,6 @@ const LightPillar: React.FC = ({ materialRef.current.uniforms.uIntensity.value = intensity; }, [intensity]); - useEffect(() => { if (!materialRef.current) return; materialRef.current.uniforms.uGlowAmount.value = glowAmount; diff --git a/src/components/Marquee/Marquee.tsx b/src/components/Marquee/Marquee.tsx index 14f626f..c079db1 100644 --- a/src/components/Marquee/Marquee.tsx +++ b/src/components/Marquee/Marquee.tsx @@ -30,18 +30,6 @@ const Marquee = ({ paddingX={gap} {...hoverProps} > - {Array(repeat) .fill(0) .map((_, i) => ( diff --git a/src/components/Marquee/__tests__/__snapshots__/Marquee.test.tsx.snap b/src/components/Marquee/__tests__/__snapshots__/Marquee.test.tsx.snap index 2f010d9..b91fcbd 100644 --- a/src/components/Marquee/__tests__/__snapshots__/Marquee.test.tsx.snap +++ b/src/components/Marquee/__tests__/__snapshots__/Marquee.test.tsx.snap @@ -6,18 +6,6 @@ exports[`Marquee should render the Marquee component 1`] = ` class="css-1t1xmrl" style="display: flex; overflow: hidden; padding: 0.5rem; flex-direction: row;" > -
@@ -48,18 +36,6 @@ exports[`Marquee should render the Marquee component 2`] = ` class="css-1t1xmrl" style="display: flex; overflow: hidden; padding: 0.5rem; flex-direction: row;" > -
@@ -270,18 +246,6 @@ exports[`Marquee should render the Marquee component with horizontal direction 1 class="css-1t1xmrl" style="display: flex; overflow: hidden; padding: 0.5rem; flex-direction: row;" > -
@@ -492,18 +456,6 @@ exports[`Marquee should render the Marquee component with reverse false 1`] = ` class="css-1t1xmrl" style="display: flex; overflow: hidden; padding: 0.5rem; flex-direction: row;" > -
@@ -714,18 +666,6 @@ exports[`Marquee should render the Marquee component with vertical direction 1`] class="css-1t1xmrl" style="display: flex; overflow: hidden; padding: 0.5rem; flex-direction: column;" > -
@@ -936,18 +876,6 @@ exports[`Marquee should render the Marquee component without hover 1`] = ` class="css-1t1xmrl" style="display: flex; overflow: hidden; padding: 0.5rem; flex-direction: row;" > -
diff --git a/src/components/Noise/Noise.tsx b/src/components/Noise/Noise.tsx index 00f5cc9..02b933d 100644 --- a/src/components/Noise/Noise.tsx +++ b/src/components/Noise/Noise.tsx @@ -1,29 +1,13 @@ -import { HTMLAttributes } from 'react'; - -interface NoiseProps extends HTMLAttributes { - type?: 'bg' | 'fg'; - opacity?: number; - baseFrequency?: number; -} - -const Noise = ({ - type = 'bg', - opacity = 0.08, - baseFrequency = 1.8, - ...props -}: NoiseProps) => { - const noiseSvg = `data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='${baseFrequency}' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E`; - +const Noise = ({ type = 'bg' }: { type?: 'bg' | 'fg' }) => { return (
+ >
); }; diff --git a/src/components/Orb/Orb.tsx b/src/components/Orb/Orb.tsx index b6fb790..c4f2694 100644 --- a/src/components/Orb/Orb.tsx +++ b/src/components/Orb/Orb.tsx @@ -190,6 +190,7 @@ export default function Orb({ backgroundColor = '#000000', }: OrbProps) { const ctnDom = useRef(null); + const isScrollingRef = useRef(false); useEffect(() => { const container = ctnDom.current; @@ -200,7 +201,7 @@ export default function Orb({ ([entry]) => { isVisibleRef.current = entry.isIntersecting; }, - { threshold: 0.01 } + { threshold: 0.01 }, ); observer.observe(container); @@ -280,10 +281,22 @@ export default function Orb({ window.addEventListener('mousemove', handleMouseMove); window.addEventListener('mouseleave', handleMouseLeave); + let scrollTimeout: number | null = null; + const handleScroll = () => { + isScrollingRef.current = true; + if (scrollTimeout) { + window.clearTimeout(scrollTimeout); + } + scrollTimeout = window.setTimeout(() => { + isScrollingRef.current = false; + }, 100); + }; + window.addEventListener('scroll', handleScroll, { passive: true }); + let rafId: number; const update = (t: number) => { rafId = requestAnimationFrame(update); - if (!isVisibleRef.current) return; + if (!isVisibleRef.current || isScrollingRef.current) return; const dt = (t - lastTime) * 0.001; lastTime = t; program.uniforms.iTime.value = t * 0.001; @@ -310,6 +323,10 @@ export default function Orb({ window.removeEventListener('resize', resize); window.removeEventListener('mousemove', handleMouseMove as any); window.removeEventListener('mouseleave', handleMouseLeave as any); + window.removeEventListener('scroll', handleScroll); + if (scrollTimeout) { + window.clearTimeout(scrollTimeout); + } container.removeChild(gl.canvas); gl.getExtension('WEBGL_lose_context')?.loseContext(); }; diff --git a/src/components/ShinyText/ShinyText.tsx b/src/components/ShinyText/ShinyText.tsx index d5e6bd3..9d64e4f 100644 --- a/src/components/ShinyText/ShinyText.tsx +++ b/src/components/ShinyText/ShinyText.tsx @@ -12,32 +12,20 @@ const ShinyText = ({ const animationDuration = `${speed}s`; return ( - <> - -
- {text} -
- +
+ {text} +
); }; diff --git a/src/components/ShinyText/__tests__/__snapshots__/ShinyText.test.tsx.snap b/src/components/ShinyText/__tests__/__snapshots__/ShinyText.test.tsx.snap index e2c1c57..32d4431 100644 --- a/src/components/ShinyText/__tests__/__snapshots__/ShinyText.test.tsx.snap +++ b/src/components/ShinyText/__tests__/__snapshots__/ShinyText.test.tsx.snap @@ -2,16 +2,6 @@ exports[`ShinyText should disable animation when disabled is true 1`] = `
-
-
{ }); expect(screen.getByText('100%')).toBeInTheDocument(); - + // Decrypting matrix text should be fully resolved at 100% - expect(screen.getByText('SYSTEM_ONLINE // LINK_ESTABLISHED')).toBeInTheDocument(); + expect( + screen.getByText('SYSTEM_ONLINE // LINK_ESTABLISHED'), + ).toBeInTheDocument(); // Advance the remaining hold delay to trigger onComplete act(() => { diff --git a/src/data/Projects.ts b/src/data/Projects.ts index 81fdebd..4344cc7 100644 --- a/src/data/Projects.ts +++ b/src/data/Projects.ts @@ -104,6 +104,12 @@ Core idea of this project is to build a place on the internet where coders can f githubLink: 'https://github.com/WorldOfTech0/WOT', link: 'https://worldoftech.co.in', tags: [Language.react, Language.typescript, Language.i18n], + image: [ + 'worldoftech/1.png', + 'worldoftech/2.png', + 'worldoftech/3.png', + 'worldoftech/4.png', + ], keyPoints: [ 'Built as a highly organized directory cataloging free tools across media, AI, privacy, books, and system utilities.', 'Features a responsive glassmorphic UI with quick category filters and a fast local search/filter system.', @@ -133,8 +139,8 @@ Core idea of this project is to build a place on the internet where coders can f status: Status.DEVELOPMENT, description: 'A hardware-agnostic audio-networking application that turns nearby computers, smartphones, and tablets into a synchronized, unified speaker system using local Wi-Fi and Bluetooth protocols.', - githubLink: 'https://github.com/AmitRaikwar-in/AudioMesh', - image: ['mac/AR_Mac1.png', 'mac/AR_Mac2.png', 'mac/AR_Mac3.png'], + githubLink: 'https://github.com/AudioMesh', + image: ['audiomesh/Landing1.png', 'audiomesh/Landing2.png'], link: 'https://audiomesh.amitraikwar.in/', tags: [ Language.rust, @@ -176,37 +182,26 @@ This library has most of the UI components and hooks required for building hourc 'This library is used in various projects and is being updated regularly.', ], }, - { - title: 'Galaxy UI Library', - icon: ProjectName.GalaxyUI, - status: Status.DEVELOPMENT, - description: - 'React typescript UI library for building web apps. Idea is to create a very fancy web application using this library. This library is currently hosted on NPM and is updated with new components and features regularly.', - githubLink: 'https://www.github.com/onemanfighter/hourcoding-ui', - link: 'http://galaxy-ui.netlify.com/', - npmLink: 'https://www.npmjs.com/package/@galaxy_ui/ui', - tags: [Language.react, Language.typescript, Language.i18n], - keyPoints: [ - 'This website is designed for coding tutorials and programming articles.', - 'It is built with React, Next.js, TypeScript, Chakra UI, and Tailwind CSS.', - 'Utility hooks that facilitate the development of web applications.', - 'Currently hosted on NPM and is being updated with new components and features regularly.', - ], - }, { title: 'Growboard', - icon: ProjectName.Dashwave, - status: Status.DEVELOPMENT, + icon: ProjectName.Growboard, + status: Status.LIVE, description: `A web app for managing stuffs in life like study, projects, expenses, secrets, passwords, writing journals, etc. A web app for managing stuff in life like study, projects, expenses, secrets, passwords, writing journals etc. This project was started with a idea to manage everything at one place without maintaining something overwhelming or complicated.`, - githubLink: 'https://www.github.com/onemanfighter/hourcoding-ui', - link: 'https://dashwave.amitraikwar.com', + githubLink: 'https://www.github.com/Growboard/growboard', + link: 'https://gb.amitraikwar.com', tags: [ Language.react, Language.typescript, Language.i18n, Language.supabase, ], + image: [ + 'growboard/LandingPage.png', + 'growboard/Login.png', + 'growboard/Expenses.png', + 'growboard/LandingPage.png', + ], keyPoints: [ 'Compatible with all devices and has a responsive design.', 'Built with React, TypeScript, Chakra UI, and Tailwind CSS.', @@ -214,6 +209,23 @@ This library has most of the UI components and hooks required for building hourc 'This project is still under development and more features are being added regularly.', ], }, + { + title: 'Galaxy UI Library', + icon: ProjectName.GalaxyUI, + status: Status.DEVELOPMENT, + description: + 'React typescript UI library for building web apps. Idea is to create a very fancy web application using this library. This library is currently hosted on NPM and is updated with new components and features regularly.', + githubLink: 'https://www.github.com/onemanfighter/hourcoding-ui', + link: 'http://galaxy-ui.netlify.com/', + npmLink: 'https://www.npmjs.com/package/@galaxy_ui/ui', + tags: [Language.react, Language.typescript, Language.i18n], + keyPoints: [ + 'This website is designed for coding tutorials and programming articles.', + 'It is built with React, Next.js, TypeScript, Chakra UI, and Tailwind CSS.', + 'Utility hooks that facilitate the development of web applications.', + 'Currently hosted on NPM and is being updated with new components and features regularly.', + ], + }, { title: 'TestCov', icon: ProjectName.TestCov, diff --git a/src/hooks/__tests__/constants.test.ts b/src/hooks/__tests__/constants.test.ts new file mode 100644 index 0000000..ad03df6 --- /dev/null +++ b/src/hooks/__tests__/constants.test.ts @@ -0,0 +1,11 @@ +import { SPRING_SETTING } from '../constants'; + +describe('hooks constants', () => { + it('should export SPRING_SETTING correctly', () => { + expect(SPRING_SETTING).toEqual({ + damping: 40, + stiffness: 1000, + restDelta: 0.001, + }); + }); +}); diff --git a/src/hooks/__tests__/useIsIntersecting.test.tsx b/src/hooks/__tests__/useIsIntersecting.test.tsx new file mode 100644 index 0000000..b140ee8 --- /dev/null +++ b/src/hooks/__tests__/useIsIntersecting.test.tsx @@ -0,0 +1,37 @@ +import { renderHook } from '@testing-library/react'; +import useIsIntersecting from '../useIsIntersecting'; +import { act } from 'react'; + +class MockIntersectionObserver { + observe = jest.fn(); + disconnect = jest.fn(); + unobserve = jest.fn(); + constructor(public callback: any) { + MockIntersectionObserver.lastInstance = this; + } + static lastInstance: MockIntersectionObserver | null = null; +} +global.IntersectionObserver = MockIntersectionObserver as any; + +describe('useIsIntersecting', () => { + beforeEach(() => { + MockIntersectionObserver.lastInstance = null; + }); + + it('should register observer and return value on intersection change', () => { + const ref = { current: document.createElement('div') }; + const { result } = renderHook(() => useIsIntersecting(ref)); + + expect(MockIntersectionObserver.lastInstance).not.toBeNull(); + expect(MockIntersectionObserver.lastInstance?.observe).toHaveBeenCalledWith(ref.current); + + expect(result.current).toBe(false); + + // Trigger callback + act(() => { + MockIntersectionObserver.lastInstance?.callback([{ isIntersecting: true }]); + }); + + expect(result.current).toBe(true); + }); +}); diff --git a/src/hooks/__tests__/useMousePositions.test.tsx b/src/hooks/__tests__/useMousePositions.test.tsx new file mode 100644 index 0000000..e0ed688 --- /dev/null +++ b/src/hooks/__tests__/useMousePositions.test.tsx @@ -0,0 +1,17 @@ +import { renderHook, fireEvent } from '@testing-library/react'; +import useMousePositions from '../useMousePositions'; +import { act } from 'react'; + +describe('useMousePositions', () => { + it('should track mouse position changes on mousemove', () => { + const { result } = renderHook(() => useMousePositions()); + + expect(result.current).toEqual({ x: 0, y: 0 }); + + act(() => { + fireEvent.mouseMove(window, { clientX: 150, clientY: 200 }); + }); + + expect(result.current).toEqual({ x: 150, y: 200 }); + }); +}); diff --git a/src/hooks/__tests__/useMoveToTop.test.tsx b/src/hooks/__tests__/useMoveToTop.test.tsx new file mode 100644 index 0000000..d6e5982 --- /dev/null +++ b/src/hooks/__tests__/useMoveToTop.test.tsx @@ -0,0 +1,17 @@ +import { renderHook } from '@testing-library/react'; +import useMoveToTop from '../useMoveToTop'; + +describe('useMoveToTop', () => { + it('should call window.scrollTo with top 0 and smooth behavior', () => { + const scrollToMock = jest.fn(); + window.scrollTo = scrollToMock; + + const { result } = renderHook(() => useMoveToTop()); + result.current(); + + expect(scrollToMock).toHaveBeenCalledWith({ + top: 0, + behavior: 'smooth', + }); + }); +}); diff --git a/src/hooks/__tests__/useSpringMousePosition.test.tsx b/src/hooks/__tests__/useSpringMousePosition.test.tsx new file mode 100644 index 0000000..e169a4e --- /dev/null +++ b/src/hooks/__tests__/useSpringMousePosition.test.tsx @@ -0,0 +1,38 @@ +import { renderHook, fireEvent } from '@testing-library/react'; +import useSpringMousePosition from '../useSpringMousePosition'; +import { act } from 'react'; + +jest.mock('framer-motion', () => { + const jumpMockX = jest.fn(); + const jumpMockY = jest.fn(); + return { + useMotionValue: jest.fn(() => ({ + jump: jest.fn((val) => { + if (jumpMockX.mock.calls.length === 0) jumpMockX(val); + else jumpMockY(val); + }), + })), + useSpring: jest.fn((mv) => mv), + frame: { + read: jest.fn((cb) => cb()), + }, + }; +}); + +describe('useSpringMousePosition', () => { + it('should setup pointermove listeners and trigger on pointer move', () => { + const div = document.createElement('div'); + Object.defineProperty(div, 'offsetLeft', { value: 10 }); + Object.defineProperty(div, 'offsetTop', { value: 20 }); + const ref = { current: div }; + + const { result } = renderHook(() => useSpringMousePosition(ref)); + + act(() => { + fireEvent(window, new MouseEvent('pointermove', { clientX: 100, clientY: 200 })); + }); + + expect(result.current.x).toBeDefined(); + expect(result.current.y).toBeDefined(); + }); +}); diff --git a/src/index.css b/src/index.css index c9f60f5..d74a125 100644 --- a/src/index.css +++ b/src/index.css @@ -10,15 +10,55 @@ body, color-scheme: dark; } -body::after { - content: ''; - position: fixed; - inset: 0; - pointer-events: none; - z-index: 9999; - background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.5' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E"); - opacity: 0.06; - mix-blend-mode: screen; +@keyframes spin-border { + to { + transform: rotate(360deg); + } +} + +@keyframes float { + 0% { + transform: translateY(0); + } + 50% { + transform: translateY(-10px); + } + 100% { + transform: translateY(0); + } +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@keyframes marquee { + 0% { + transform: translateX(0%); + } + 100% { + transform: translateX(-100%); + } +} + +@keyframes marquee-vertical { + 0% { + transform: translateY(0%); + } + 100% { + transform: translateY(-100%); + } +} + +@keyframes shine { + 0% { + background-position: 100%; + } + 100% { + background-position: -100%; + } } /* width */ diff --git a/src/react-app-env.d.ts b/src/react-app-env.d.ts index 5d0b3c4..f2d274e 100644 --- a/src/react-app-env.d.ts +++ b/src/react-app-env.d.ts @@ -9,4 +9,3 @@ declare module '*.gltf' { const src: string; export default src; } - diff --git a/src/screens/articles/ArticlesScreen.tsx b/src/screens/articles/ArticlesScreen.tsx index 4ffbea8..d8915f8 100644 --- a/src/screens/articles/ArticlesScreen.tsx +++ b/src/screens/articles/ArticlesScreen.tsx @@ -113,7 +113,7 @@ const ArticlesScreen = () => { paddingX={{ base: 1, md: 6 }} position="relative" > - + { rowGap={20} ref={ref} pos="relative" - bg="black" > { topColor="#5227ff" bottomColor="#ff9ffc" intensity={2} - rotationSpeed={0.3} + rotationSpeed={1.3} glowAmount={0.003} pillarWidth={3} pillarHeight={1.0} noiseIntensity={0.6} pillarRotation={90} - interactive={true} + interactive={false} mixBlendMode="screen" quality="medium" /> diff --git a/src/services/backend/articles/__tests__/articles.test.ts b/src/services/backend/articles/__tests__/articles.test.ts new file mode 100644 index 0000000..3cf4480 --- /dev/null +++ b/src/services/backend/articles/__tests__/articles.test.ts @@ -0,0 +1,104 @@ +import { GetRequest, PostRequest } from '../../client/client'; +import addArticle from '../addArticle'; +import addComment from '../addComment'; +import deleteArticle from '../deleteArticle'; +import getAllArticlesData from '../getAllArticlesData'; +import getArticleData from '../getArticleData'; +import getComments from '../getComments'; +import getEditorArticleData from '../getEditorArticleData'; +import likePageArticleData from '../likePageArticleData'; +import pingTest from '../pingTest'; +import updateArticle from '../updateArticle'; +import { + ADD_ARTICLE_URL, + ADD_COMMENT_URL, + DELETE_ARTICLE_URL, + ALL_ARTICLES_URL, + ARTICLES_URL, + GET_COMMENTS_URL, + EDITOR_ARTICLES_URL, + LIKE_ARTICLE_URL, + PING_URL, + UPDATE_ARTICLE_URL, +} from '../constants'; + +jest.mock('../../client/client', () => ({ + GetRequest: jest.fn(), + PostRequest: jest.fn(), +})); + +describe('Articles Backend Services', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('addArticle should make PostRequest', async () => { + (PostRequest as jest.Mock).mockResolvedValueOnce('add-article-success'); + const result = await addArticle({ title: 'Test' }); + expect(PostRequest).toHaveBeenCalledWith(ADD_ARTICLE_URL, { title: 'Test' }); + expect(result).toBe('add-article-success'); + }); + + it('addComment should make PostRequest', async () => { + (PostRequest as jest.Mock).mockResolvedValueOnce('add-comment-success'); + const result = await addComment({ text: 'Test comment' }); + expect(PostRequest).toHaveBeenCalledWith(ADD_COMMENT_URL, { text: 'Test comment' }); + expect(result).toBe('add-comment-success'); + }); + + it('deleteArticle should make GetRequest', async () => { + (GetRequest as jest.Mock).mockResolvedValueOnce('delete-article-success'); + const result = await deleteArticle({ articleKey: 'key1' }); + expect(GetRequest).toHaveBeenCalledWith(DELETE_ARTICLE_URL + '?articleKey=key1'); + expect(result).toBe('delete-article-success'); + }); + + it('getAllArticlesData should make GetRequest', async () => { + (GetRequest as jest.Mock).mockResolvedValueOnce('get-all-success'); + const result = await getAllArticlesData(); + expect(GetRequest).toHaveBeenCalledWith(ALL_ARTICLES_URL); + expect(result).toBe('get-all-success'); + }); + + it('getArticleData should make GetRequest', async () => { + (GetRequest as jest.Mock).mockResolvedValueOnce('get-article-success'); + const result = await getArticleData('key1'); + expect(GetRequest).toHaveBeenCalledWith(ARTICLES_URL + '?articleKey=key1'); + expect(result).toBe('get-article-success'); + }); + + it('getComments should make GetRequest', async () => { + (GetRequest as jest.Mock).mockResolvedValueOnce('get-comments-success'); + const result = await getComments('key1'); + expect(GetRequest).toHaveBeenCalledWith(GET_COMMENTS_URL + '?articleKey=key1'); + expect(result).toBe('get-comments-success'); + }); + + it('getEditorArticleData should make GetRequest', async () => { + (GetRequest as jest.Mock).mockResolvedValueOnce('get-editor-success'); + const result = await getEditorArticleData('key1'); + expect(GetRequest).toHaveBeenCalledWith(EDITOR_ARTICLES_URL + '?articleKey=key1'); + expect(result).toBe('get-editor-success'); + }); + + it('likePageArticleData should make PostRequest', async () => { + (PostRequest as jest.Mock).mockResolvedValueOnce('like-success'); + const result = await likePageArticleData('key1'); + expect(PostRequest).toHaveBeenCalledWith(LIKE_ARTICLE_URL, 'key1'); + expect(result).toBe('like-success'); + }); + + it('pingTest should make GetRequest', async () => { + (GetRequest as jest.Mock).mockResolvedValueOnce('ping-success'); + const result = await pingTest(); + expect(GetRequest).toHaveBeenCalledWith(PING_URL); + expect(result).toBe('ping-success'); + }); + + it('updateArticle should make PostRequest', async () => { + (PostRequest as jest.Mock).mockResolvedValueOnce('update-success'); + const result = await updateArticle({ title: 'New title' }); + expect(PostRequest).toHaveBeenCalledWith(UPDATE_ARTICLE_URL, { title: 'New title' }); + expect(result).toBe('update-success'); + }); +}); diff --git a/src/services/backend/client/__tests__/client.test.ts b/src/services/backend/client/__tests__/client.test.ts new file mode 100644 index 0000000..d7a6a11 --- /dev/null +++ b/src/services/backend/client/__tests__/client.test.ts @@ -0,0 +1,59 @@ +const mockGet = jest.fn(); +const mockPost = jest.fn(); + +jest.mock('axios', () => ({ + create: jest.fn(() => ({ + get: mockGet, + post: mockPost, + })), +})); + +// Require after mocks are initialized to prevent ES6 hoisting issues +const { GetRequest, PostRequest } = require('../client'); + +describe('backend client', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('GetRequest', () => { + it('should return data on successful request', async () => { + mockGet.mockResolvedValueOnce({ data: 'success-get' }); + const result = await GetRequest('/test-url'); + expect(mockGet).toHaveBeenCalledWith('/test-url'); + expect(result).toBe('success-get'); + }); + + it('should log error and return undefined on failure', async () => { + const error = new Error('get-failed'); + const consoleSpy = jest + .spyOn(console, 'error') + .mockImplementation(jest.fn()); + mockGet.mockRejectedValueOnce(error); + + const result = await GetRequest('/test-url'); + expect(consoleSpy).toHaveBeenCalledWith(error); + expect(result).toBeUndefined(); + + consoleSpy.mockRestore(); + }); + }); + + describe('PostRequest', () => { + it('should return data on successful post', async () => { + mockPost.mockResolvedValueOnce({ data: 'success-post' }); + const result = await PostRequest('/test-post-url', { payload: 'data' }); + expect(mockPost).toHaveBeenCalledWith( + '/test-post-url', + { payload: 'data' }, + { + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + }, + ); + expect(result).toBe('success-post'); + }); + }); +}); diff --git a/src/services/hooks/articles/__tests__/hooksArticles.test.ts b/src/services/hooks/articles/__tests__/hooksArticles.test.ts new file mode 100644 index 0000000..6305c09 --- /dev/null +++ b/src/services/hooks/articles/__tests__/hooksArticles.test.ts @@ -0,0 +1,162 @@ +import { renderHook } from '@testing-library/react'; +import useAddArticle from '../useAddArticle'; +import useAddComment from '../useAddComment'; +import useDeleteArticle from '../useDeleteArticle'; +import useGetAllArticlesData from '../useGetAllArticlesData'; +import useGetArticleData from '../useGetArticleData'; +import useGetComments from '../useGetComments'; +import useGetEditorArticleData from '../useGetEditorArticleData'; +import useLikeArticleData from '../useLikeArticleData'; +import usePingTest from '../usePingTest'; +import useUpdateArticle from '../useUpdateArticle'; +import { useToast } from '@chakra-ui/react'; +import { useCallQuery, useCallSBMutation } from '../../common'; +import { + addArticle, + addComment, + deleteArticle, + getArticlesData, + getArticleData, + getComments, + getEditorArticleData, + likePageArticleData, + pingTest, + updateArticle, +} from '../../../backend'; + +jest.mock('@chakra-ui/react', () => ({ + useToast: jest.fn(), +})); + +jest.mock('@tanstack/react-query', () => ({ + useQueryClient: jest.fn(() => ({ + invalidateQueries: jest.fn(), + })), +})); + +jest.mock('../../common', () => ({ + useCallQuery: jest.fn((args) => { + args.method(); + return 'query-mock-result'; + }), + useCallSBMutation: jest.fn((args) => { + args.method('mock-data'); + const mockVariables = { articleKey: 'mock-key' }; + if (args.mutationOptions?.onSuccess) args.mutationOptions.onSuccess('mock-response', mockVariables); + if (args.mutationOptions?.onError) args.mutationOptions.onError('mock-error', mockVariables); + return 'mutation-mock-result'; + }), +})); + +jest.mock('../../../backend', () => ({ + addArticle: jest.fn(), + addComment: jest.fn(), + deleteArticle: jest.fn(), + getArticlesData: jest.fn(), + getArticleData: jest.fn(), + getComments: jest.fn(), + getEditorArticleData: jest.fn(), + likePageArticleData: jest.fn(), + pingTest: jest.fn(), + updateArticle: jest.fn(), +})); + +describe('Articles hooks', () => { + const toastMock = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (useToast as jest.Mock).mockReturnValue(toastMock); + }); + + it('useAddArticle should set up and trigger toast on success/error', () => { + const { result } = renderHook(() => useAddArticle()); + expect(useCallSBMutation).toHaveBeenCalled(); + expect(addArticle).toHaveBeenCalledWith('mock-data'); + expect(toastMock).toHaveBeenCalledTimes(2); + expect(toastMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ status: 'success' })); + expect(toastMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ status: 'error' })); + expect(result.current).toBe('mutation-mock-result'); + }); + + it('useAddComment should set up and trigger toast on success/error', () => { + const { result } = renderHook(() => useAddComment()); + expect(useCallSBMutation).toHaveBeenCalled(); + expect(addComment).toHaveBeenCalledWith('mock-data'); + expect(toastMock).toHaveBeenCalledTimes(2); + expect(toastMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ status: 'success' })); + expect(toastMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ status: 'error' })); + expect(result.current).toBe('mutation-mock-result'); + }); + + it('useDeleteArticle should set up and trigger toast on success/error', () => { + const { result } = renderHook(() => useDeleteArticle()); + expect(useCallSBMutation).toHaveBeenCalled(); + expect(deleteArticle).toHaveBeenCalledWith('mock-data'); + expect(toastMock).toHaveBeenCalledTimes(2); + expect(toastMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ status: 'success' })); + expect(toastMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ status: 'error' })); + expect(result.current).toBe('mutation-mock-result'); + }); + + it('useUpdateArticle should set up and trigger toast on success/error', () => { + const { result } = renderHook(() => useUpdateArticle()); + expect(useCallSBMutation).toHaveBeenCalled(); + expect(updateArticle).toHaveBeenCalledWith('mock-data'); + expect(toastMock).toHaveBeenCalledTimes(2); + expect(toastMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ status: 'success' })); + expect(toastMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ status: 'error' })); + expect(result.current).toBe('mutation-mock-result'); + }); + + it('useLikeArticleData should set up and trigger toast on success/error', () => { + const { result } = renderHook(() => useLikeArticleData()); + expect(useCallSBMutation).toHaveBeenCalled(); + expect(likePageArticleData).toHaveBeenCalledWith('mock-data'); + expect(toastMock).toHaveBeenCalledTimes(2); + expect(toastMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ status: 'success' })); + expect(toastMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ status: 'error' })); + expect(result.current).toBe('mutation-mock-result'); + }); + + it('useGetAllArticlesData should trigger query method', () => { + const { result } = renderHook(() => useGetAllArticlesData()); + expect(useCallQuery).toHaveBeenCalled(); + expect(getArticlesData).toHaveBeenCalled(); + expect(result.current).toBe('query-mock-result'); + }); + + it('useGetArticleData should trigger query method with key', () => { + const { result } = renderHook(() => useGetArticleData('key1')); + expect(useCallQuery).toHaveBeenCalled(); + expect(getArticleData).toHaveBeenCalled(); + expect(result.current).toBe('query-mock-result'); + }); + + it('useGetComments should trigger query method with key', () => { + const { result } = renderHook(() => useGetComments('key1')); + expect(useCallQuery).toHaveBeenCalled(); + expect(getComments).toHaveBeenCalled(); + expect(result.current).toBe('query-mock-result'); + }); + + it('useGetEditorArticleData should trigger query method with key', () => { + const { result } = renderHook(() => useGetEditorArticleData('key1')); + expect(useCallQuery).toHaveBeenCalled(); + expect(getEditorArticleData).toHaveBeenCalled(); + expect(result.current).toBe('query-mock-result'); + }); + + it('useGetEditorArticleData should resolve to undefined when key is empty', () => { + const { result } = renderHook(() => useGetEditorArticleData('')); + expect(useCallQuery).toHaveBeenCalled(); + expect(result.current).toBe('query-mock-result'); + }); + + it('usePingTest should trigger query method', () => { + const { result } = renderHook(() => usePingTest()); + expect(useCallQuery).toHaveBeenCalled(); + expect(pingTest).toHaveBeenCalled(); + expect(result.current).toBe('query-mock-result'); + }); +}); diff --git a/src/services/hooks/common/__tests__/hooksCommon.test.ts b/src/services/hooks/common/__tests__/hooksCommon.test.ts new file mode 100644 index 0000000..c601c2f --- /dev/null +++ b/src/services/hooks/common/__tests__/hooksCommon.test.ts @@ -0,0 +1,58 @@ +import { useQuery, useMutation } from '@tanstack/react-query'; +import useCallQuery from '../useCallQuery'; +import useCallSBMutation from '../useCallSBMutation'; + +jest.mock('@tanstack/react-query', () => ({ + useQuery: jest.fn((options) => { + options.queryFn('test-request'); + return 'query-result'; + }), + useMutation: jest.fn((options) => { + options.mutationFn('test-request'); + return 'mutation-result'; + }), +})); + +describe('Common React Query Hooks', () => { + const methodMock = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('useCallQuery', () => { + it('should invoke useQuery with correct args and trigger queryFn', () => { + const result = useCallQuery({ + method: methodMock, + queryOptions: { queryKey: ['test-key'] }, + }); + + expect(useQuery).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ['test-key'], + queryFn: expect.any(Function), + }), + ); + expect(methodMock).toHaveBeenCalledWith('test-request'); + expect(result).toBe('query-result'); + }); + }); + + describe('useCallSBMutation', () => { + it('should invoke useMutation with correct args and trigger mutationFn', () => { + const result = useCallSBMutation({ + method: methodMock, + mutationOptions: { mutationKey: ['test-mut'] }, + }); + + expect(useMutation).toHaveBeenCalledWith( + expect.objectContaining({ + mutationKey: ['test-mut'], + mutationFn: expect.any(Function), + }), + ); + expect(methodMock).toHaveBeenCalledWith('test-request'); + expect(result).toBe('mutation-result'); + }); + }); +}); diff --git a/src/store/selectors/Search/__tests__/Search.selector.test.ts b/src/store/selectors/Search/__tests__/Search.selector.test.ts new file mode 100644 index 0000000..4307bfe --- /dev/null +++ b/src/store/selectors/Search/__tests__/Search.selector.test.ts @@ -0,0 +1,32 @@ +import { appStore } from '@store'; +import { act, renderHook } from '@testing-library/react'; +import { + selectSearchText, + setSearchTextSelector, + resetSearchTextSelector, +} from '../Search.selector'; + +describe('Search selector', () => { + it('should select search text, set search text, and reset search text', () => { + const { result: textResult } = renderHook(() => appStore(selectSearchText)); + const { result: setResult } = renderHook(() => appStore(setSearchTextSelector)); + const { result: resetResult } = renderHook(() => appStore(resetSearchTextSelector)); + + expect(textResult.current).toBe(''); + + act(() => { + setResult.current('test search'); + }); + + // Re-render hook or re-read state to see update + const { result: updatedTextResult } = renderHook(() => appStore(selectSearchText)); + expect(updatedTextResult.current).toBe('test search'); + + act(() => { + resetResult.current(); + }); + + const { result: resetTextResult } = renderHook(() => appStore(selectSearchText)); + expect(resetTextResult.current).toBe(''); + }); +}); diff --git a/yarn.lock b/yarn.lock index b882db9..3de0849 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,7 +2,7 @@ # Manual changes might be lost - proceed with caution! __metadata: - version: 8 + version: 10 cacheKey: 10c0 "@adobe/css-tools@npm:^4.0.1":