Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/cn-utility.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: 'cn utility: lint, type-check, test, build'

on:
pull_request:
branches: [main, dev]
push:
branches: [main, dev]

concurrency:
group: '${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}'
cancel-in-progress: true

jobs:
quality:
name: Code Quality & Build
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Bun runtime
uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Install dependencies (locked versions)
run: bun install --frozen-lockfile

- name: Format check & lint (Biome)
run: bunx biome check . --diagnostic-level=warn

- name: Type check (TypeScript)
run: bunx tsc --noEmit

- name: Run unit tests with coverage
run: bunx vitest run --coverage

- name: Build extension (Chrome)
run: bun run build
24 changes: 24 additions & 0 deletions .github/workflows/mirror-to-gitlab.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: πŸ” Mirror to GitLab

on:
push:
branches: ['**']
tags: ['**']
delete:
branches: ['**']
tags: ['**']

jobs:
mirror:
runs-on: ubuntu-latest
steps:
- name: Checkout with full history
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Push to GitLab
run: |
git remote add gitlab https://oauth2:${{ secrets.GITLAB_TOKEN }}@gitlab.com/${{ secrets.GITLAB_USERNAME }}/${{ github.event.repository.name }}.git
git push gitlab --all --force
git push gitlab --tags --force
1,648 changes: 863 additions & 785 deletions bun.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"axios": "1.18.0",
"canvas-confetti": "1.9.4",
"chart.js": "4.5.1",
"clsx": "^2.1.1",
"dompurify": "3.4.11",
"jalali-moment": "3.3.11",
"moment": "2.30.1",
Expand Down Expand Up @@ -62,6 +63,7 @@
"@types/react-dom": "19.2.3",
"@types/styled-jsx": "3.4.4",
"@vitejs/plugin-react": "6.0.2",
"@vitest/coverage-v8": "4.1.10",
"@wxt-dev/auto-icons": "1.1.1",
"@wxt-dev/module-react": "1.2.2",
"autoprefixer": "10.5.0",
Expand Down
66 changes: 65 additions & 1 deletion src/components/button/button.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,79 @@
import { Icon } from '@/src/icons'
import { cn } from '@/src/utils/cn'
import type React from 'react'

/**
* Props interface for the Button component.
*
* Defines all configurable aspects of button rendering: sizing, styling,
* interaction state, and content. Follows semantic HTML conventions for
* accessibility and compatibility with form submission workflows.
*/
interface ButtonProps {
/** Callback fired when the button is clicked */
onClick?: () => void
/** Disables user interaction and updates visual state */
disabled?: boolean
/** Additional CSS classes merged with default styles (overrides via Tailwind merge) */
className?: string
/** Inline CSS styles (lowest precedence, for special cases only) */
style?: React.CSSProperties
/** Optional icon element displayed alongside or instead of text */
icon?: React.ReactNode
/** If true, replaces children with loading indicator and optional text */
loading?: boolean
/** Text or element shown during loading state (defaults to spinner + Persian "Ψ΅Ψ¨Ψ± Ϊ©Ω†ΫŒΨ―...") */
loadingText?: React.ReactNode
/** HTML button type attribute for form semantics */
type?: 'button' | 'submit' | 'reset'
/** If true, button width expands to fill its container */
fullWidth?: boolean
/** Border radius scale: 'sm' (0.375rem), 'md' (0.5rem), 'lg' (0.75rem), 'xl' (1rem), 'full' (9999px) */
rounded?: 'sm' | 'md' | 'lg' | 'xl' | 'full'
/** Button contentβ€”text, nodes, or components */
children?: React.ReactNode
/** If true, applies primary theme colors (blue background, white text) */
isPrimary?: boolean
/** Controls button dimensions: 'xs' (24px), 'sm' (32px), 'md' (40px), 'lg' (48px), 'xl' (56px) */
size: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
/** React ref for imperative access (e.g., .focus(), .blur()) */
ref?: any
}

/**
* A reusable button component with flexible sizing, theming, and states.
*
* This component wraps a native HTML `<button>` with Tailwind-based styling,
* loading states, and configurable appearance. The `cn()` utility ensures
* that user-provided className overrides are properly merged with built-in
* styles without Tailwind conflicts (e.g., duplicate spacing, color utilities).
*
* Accessibility: Use `disabled` prop to disable the button; avoid disabling
* via className alone as screen readers rely on the HTML attribute.
*
* @example
* ```tsx
* <Button size="md" onClick={handleClick}>
* Click me
* </Button>
*
* <Button
* size="lg"
* isPrimary
* fullWidth
* loading={isSubmitting}
* loadingText="Submitting..."
* type="submit"
* >
* Submit Form
* </Button>
* ```
*/
export function Button(prop: ButtonProps) {
/**
* Maps size prop to Tailwind height and padding utility tokens.
* These classes are combined via `cn()` to avoid conflicts.
*/
const sizes: Record<string, string> = {
xs: 'btn-xs',
sm: 'btn-sm',
Expand All @@ -31,7 +87,15 @@ export function Button(prop: ButtonProps) {
type={prop.type || 'button'}
onClick={prop.onClick}
disabled={prop.disabled}
className={`btn cursor-pointer ${prop.fullWidth ? 'full-width' : ''} ${prop.className} ${prop.rounded ? `rounded-${prop.rounded}` : ''} ${prop.isPrimary ? 'btn-primary text-white' : ''} ${sizes[prop.size] || 'btn-md'} active:!translate-y-0`}
className={cn(
'btn cursor-pointer',
prop.fullWidth && 'full-width',
prop.rounded && `rounded-${prop.rounded}`,
prop.isPrimary && 'btn-primary text-white',
sizes[prop.size] || 'btn-md',
'active:!translate-y-0',
prop.className
)}
style={prop.style}
ref={prop.ref}
>
Expand Down
77 changes: 72 additions & 5 deletions src/components/checkbox.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,58 @@
import { cn } from '@/src/utils/cn'
import { memo } from 'react'

/**
* Props for the CustomCheckbox component.
*
* Provides granular control over styling, state management, and interaction
* handlers. Supports both controlled and uncontrolled patterns via onChange.
*/
interface CustomCheckboxProps {
/** Whether the checkbox is currently checked (controlled prop) */
checked: boolean
/** Fired when the user toggles the checkbox (controlled mode) */
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
/** Label text displayed next to the checkbox */
label?: string
/** Click handler for custom interaction logic (fires after onChange if not disabled) */
onClick?: (e: React.MouseEvent<HTMLInputElement>) => void
/** Additional CSS classes merged with computed styles via cn() */
className?: string
/** Disables interaction and dims visual appearance */
disabled?: boolean
/** CSS classes applied when checkbox is unchecked (overrides default border-content) */
unCheckedCheckBoxClassName?: string
/** CSS classes applied when checkbox is checked (overrides default blue background) */
checkedCheckBoxClassName?: string
/** Font weight for label text: 'font-light', 'font-normal', or 'font-bold' */
fontSize?: 'font-light' | 'font-normal' | 'font-bold'
}

/**
* A styled, accessible checkbox component built with HTML5 `<input type="checkbox">`.
*
* Features:
* - Animated checkmark that scales in/out on toggle
* - Customizable checked/unchecked styles via className props
* - Disabled state prevents interaction and changes cursor
* - Memoized to prevent unnecessary re-renders
* - Uses semantic `<label>` element for better accessibility and click target
*
* The `cn()` utility merges provided className overrides with computed state-based
* styles, ensuring no conflicting Tailwind utilities (e.g., border colors).
*
* @example
* ```tsx
* const [isChecked, setIsChecked] = useState(false)
*
* <CustomCheckbox
* checked={isChecked}
* onChange={(e) => setIsChecked(e.target.checked)}
* label="I agree to the terms"
* fontSize="font-semibold"
* />
* ```
*/
const CustomCheckbox = ({
checked,
onChange,
Expand All @@ -23,6 +64,12 @@ const CustomCheckbox = ({
checkedCheckBoxClassName = '',
onClick,
}: CustomCheckboxProps) => {
/**
* Determines the appropriate Tailwind classes for the checkbox box
* based on checked state and user-provided overrides.
*
* Priority: checkedCheckBoxClassName > default checked style
*/
const getCheckboxStyle = () => {
if (checked) {
if (checkedCheckBoxClassName) return checkedCheckBoxClassName
Expand All @@ -33,13 +80,21 @@ const CustomCheckbox = ({
return 'border-content'
}

/**
* Wrapper for onChange that prevents default browser behavior
* and respects the disabled state.
*/
const onChangeEvent = (e: React.ChangeEvent<HTMLInputElement>) => {
e.preventDefault()
if (!disabled) {
onChange?.(e)
}
}

/**
* Wrapper for onClick that prevents default browser behavior
* and respects the disabled state.
*/
const onClickEvent = (e: React.MouseEvent<HTMLInputElement>) => {
e.preventDefault()
if (!disabled) {
Expand All @@ -50,26 +105,37 @@ const CustomCheckbox = ({
return (
<label className="relative flex items-center transition-transform cursor-pointer group active:scale95">
<div className="relative">
{/* Hidden native checkbox for accessibility and form submission */}
<input
type="checkbox"
className={'sr-only'}
className="sr-only"
checked={checked}
onChange={onChangeEvent}
disabled={disabled}
onClick={onClickEvent}
/>
{/* Visual checkbox box with animated checkmark */}
<div
className={`w-5 h-5 border rounded-md flex items-center justify-center transition-colors duration-200 ${getCheckboxStyle()} ${className}`}
className={cn(
'w-5 h-5 border rounded-md flex items-center justify-center transition-colors duration-200',
getCheckboxStyle(),
className
)}
>
{/* SVG checkmark animates in/out via scale transform */}
<svg
className={`transition-all duration-150 ${checked ? 'scale-100' : 'scale-0'}`}
className={`transition-all duration-150 ${
checked ? 'scale-100' : 'scale-0'
}`}
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
>
<path
className={`transition-all duration-200 ${checked ? 'stroke-dashoffset-0' : 'stroke-dashoffset-full'}`}
className={`transition-all duration-200 ${
checked ? 'stroke-dashoffset-0' : 'stroke-dashoffset-full'
}`}
d="M2.5 6L5 8.5L9.5 4"
stroke="white"
strokeWidth="2"
Expand All @@ -81,8 +147,9 @@ const CustomCheckbox = ({
</svg>
</div>
</div>
{/* Label text with configurable font weight */}
{label && (
<span className={`ml-2 mr-2 ${fontSize} text-sm text-content`}>
<span className={cn('ml-2 mr-2 text-sm text-content', fontSize)}>
{label}
</span>
)}
Expand Down
Loading