From 2501eedfb34bd08c89c347a9fc5a487cb9a6b6b0 Mon Sep 17 00:00:00 2001 From: Shak Date: Thu, 13 Aug 2026 20:41:35 +0330 Subject: [PATCH 01/10] feat(ui): add cn() helper with tailwind-merge config for custom utilities --- src/common/utils/cn.ts | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 src/common/utils/cn.ts diff --git a/src/common/utils/cn.ts b/src/common/utils/cn.ts new file mode 100644 index 00000000..d555533d --- /dev/null +++ b/src/common/utils/cn.ts @@ -0,0 +1,37 @@ +import { type ClassValue, clsx } from 'clsx' +import { extendTailwindMerge } from 'tailwind-merge' + +const twMerge = extendTailwindMerge<'wg-backdrop'>({ + extend: { + classGroups: { + 'wg-backdrop': ['bg-glass'], + + 'bg-color': ['bg-widget', 'bg-content', 'bg-raised'], + 'text-color': ['text-strong', 'text-content', 'text-muted', 'text-subtle'], + 'border-color': ['border-content', 'border-strong'], + + rounded: ['rounded-widget', 'rounded-card'], + shadow: ['elevation-sm', 'elevation', 'elevation-lg'], + transition: ['transition-ui'], + 'outline-style': ['focus-ring'], + z: [ + 'z-base', + 'z-raised', + 'z-sticky', + 'z-drag', + 'z-nav', + 'z-backdrop', + 'z-sheet', + 'z-modal', + 'z-popover', + 'z-tooltip', + 'z-toast', + 'z-pet', + ], + }, + }, +}) + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} From 28ad959f514b2d55d990ee7e727c09a18b0cbfd1 Mon Sep 17 00:00:00 2001 From: Shak Date: Thu, 13 Aug 2026 20:41:40 +0330 Subject: [PATCH 02/10] chore: ignore local .claude directory Local Claude Code session config shouldn't be tracked. --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index f9b65d12..201c15df 100644 --- a/.gitignore +++ b/.gitignore @@ -31,4 +31,5 @@ dev local_output diff.txt -docs/* \ No newline at end of file +docs/* +.claude \ No newline at end of file From aeeead33d795ea7c74152e278a129195278448a8 Mon Sep 17 00:00:00 2001 From: Shak Date: Sat, 15 Aug 2026 17:38:00 +0330 Subject: [PATCH 03/10] fix(deps): declare clsx as an explicit dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cn.ts imports clsx directly, but it was never added to package.json — it only worked locally because it happened to already be present in node_modules from an unrelated install. A clean install (e.g. CI) can't resolve it. Adding it explicitly and regenerating the lockfile. --- bun.lock | 5 ++++- package.json | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/bun.lock b/bun.lock index 6a08ae6c..3afa5194 100644 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,7 @@ "axios": "1.18.0", "canvas-confetti": "1.9.4", "chart.js": "4.5.1", + "clsx": "2.1.1", "framer-motion": "12.40.0", "jalali-moment": "3.3.11", "moment": "2.30.1", @@ -24,7 +25,7 @@ "react-chartjs-2": "5.3.1", "react-colorful": "5.7.0", "react-dom": "19.2.7", - "react-easy-crop": "^6.2.2", + "react-easy-crop": "6.2.2", "react-hot-toast": "2.6.0", "react-icons": "5.6.0", "react-joyride": "3.1.0", @@ -539,6 +540,8 @@ "clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], diff --git a/package.json b/package.json index 525a8b01..4bcf9e79 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "axios": "1.18.0", "canvas-confetti": "1.9.4", "chart.js": "4.5.1", + "clsx": "2.1.1", "framer-motion": "12.40.0", "jalali-moment": "3.3.11", "moment": "2.30.1", From 0fe06d3f302de35ac80c1f16be09eb39f0f7d03d Mon Sep 17 00:00:00 2001 From: Shak Date: Fri, 31 Jul 2026 03:29:51 +0330 Subject: [PATCH 04/10] refactor(ui): move primitives into components/ui with cva variants and tw-merge --- src/components/auth/auth-required-modal.tsx | 11 +- src/components/auth/require-auth.tsx | 10 +- src/components/auth/require-verification.tsx | 2 +- src/components/badges/new.badge.tsx | 10 -- src/components/blur-mode/blur-mode.button.tsx | 4 +- src/components/button/button.tsx | 11 +- src/components/chip.component.tsx | 25 ---- src/components/date-picker/index.ts | 1 - src/components/dropdown/index.ts | 2 - src/components/extension-installed-modal.tsx | 14 ++- src/components/filter-tooltip/index.ts | 5 - src/components/loading/icon-loading.tsx | 25 ---- src/components/pagination/index.ts | 2 - src/components/section-panel.tsx | 60 ---------- src/components/text-input.tsx | 7 ++ src/components/toggle-switch.component.tsx | 38 ------ .../avatar/avatar.tsx} | 25 ++-- src/components/ui/avatar/avatar.variants.ts | 21 ++++ src/components/ui/badge/badge.tsx | 14 +++ .../{ => ui}/bottom-sheet/bottom-sheet.tsx | 0 src/components/ui/button/button.tsx | 56 +++++++++ src/components/ui/button/button.variants.ts | 74 ++++++++++++ .../checkbox/checkbox-description.tsx} | 4 +- src/components/{ => ui/checkbox}/checkbox.tsx | 8 +- src/components/ui/chip/chip.tsx | 28 +++++ src/components/ui/chip/chip.variants.ts | 33 +++++ .../color-picker/color-picker.tsx} | 7 +- .../context-menu/context-menu.tsx} | 7 +- .../{ => ui}/date-picker/date-picker.tsx | 8 +- src/components/{ => ui}/dropdown/dropdown.tsx | 7 +- .../{ => ui}/dropdown/use-dropdown.tsx | 0 .../filter-tooltip/filter-tooltip.tsx | 4 +- src/components/ui/index.ts | 73 +++++++++++ src/components/ui/input/input.tsx | 113 ++++++++++++++++++ src/components/ui/input/input.variants.ts | 54 +++++++++ .../{ => ui/item-selector}/item-selector.tsx | 7 +- src/components/ui/loading/loading.tsx | 25 ++++ .../{ => ui}/modal/confirmation-modal.tsx | 63 ++++------ .../ui/modal/confirmation-modal.variants.ts | 49 ++++++++ src/components/{ => ui/modal}/modal.tsx | 56 ++------- src/components/ui/modal/modal.variants.ts | 65 ++++++++++ .../offline-indicator}/offline-indicator.tsx | 2 +- .../{ => ui}/pagination/pagination.tsx | 8 +- src/components/{ => ui}/portal/portal.tsx | 0 .../ui/section-panel/section-panel.tsx | 38 ++++++ .../section-panel/section-panel.variants.ts | 57 +++++++++ .../selectbox.tsx => ui/select/select.tsx} | 0 .../{tab-navigation.tsx => ui/tabs/tabs.tsx} | 35 +++--- src/components/ui/tabs/tabs.variants.ts | 63 ++++++++++ src/components/ui/toggle/toggle.tsx | 29 +++++ src/components/ui/toggle/toggle.variants.ts | 57 +++++++++ .../{ => ui/tooltip}/clickable-tooltip.tsx | 0 .../{tool-tip.tsx => ui/tooltip/tooltip.tsx} | 9 +- src/components/update-release-notes-modal.tsx | 2 +- src/components/updater/updater.tsx | 4 +- src/components/user/user-card-portal.tsx | 3 +- src/components/user/user-card.tsx | 12 +- src/components/welcome-wizard.tsx | 6 +- src/layouts/bookmark/bookmark-grid.tsx | 2 +- .../components/bookmark-empty-slot.tsx | 2 +- .../bookmark/components/bookmark-folder.tsx | 4 +- .../components/bookmark-icon.picker.tsx | 2 +- .../components/bookmark-suggestions.tsx | 2 +- .../bookmark/components/folder-header.tsx | 4 +- .../bookmark/components/folder-path.tsx | 2 +- .../components/modal/add-bookmark.modal.tsx | 4 +- .../components/modal/advanced.modal.tsx | 16 +-- .../modal/bookmark-context-menu.tsx | 2 +- .../components/modal/edit-bookmark.modal.tsx | 4 +- .../modal/import-browser-bookmarks.modal.tsx | 2 +- src/layouts/bookmark/components/shared.tsx | 2 +- .../explorer/components/content-site.tsx | 2 +- src/layouts/friends/components/activities.tsx | 2 +- .../activity-card/activity-card.tsx | 4 +- .../activity-card/empty-activity-card.tsx | 2 +- .../manage-activity.bottom-sheet.tsx | 10 +- .../components/add-friend.bottom-sheet.tsx | 8 +- .../buttons/friend-requests.button.tsx | 4 +- .../components/friend-item/friend.item.tsx | 2 +- .../friend-requests.bottom-sheet.tsx | 2 +- .../friends/components/friends-list.tsx | 2 +- .../components/select-friend.layout.tsx | 2 +- src/layouts/friends/friends.tsx | 2 +- .../coin-package-purchase-modal.tsx | 2 +- src/layouts/market/market-coins/index.tsx | 2 +- src/layouts/market/market-container.tsx | 2 +- src/layouts/market/market-wallpaper/index.tsx | 2 +- .../components/market-item-purchase-modal.tsx | 2 +- src/layouts/market/other-items/index.tsx | 4 +- .../components/card/mini-app-card.tsx | 2 +- src/layouts/mini-apps/mini-app-runner.tsx | 2 +- src/layouts/mini-apps/mini-apps.layout.tsx | 2 +- .../navbar/components/settings-dropdown.tsx | 2 +- .../navbar/friends-list/friends.navbar.tsx | 4 +- src/layouts/navbar/market/market-button.tsx | 2 +- src/layouts/navbar/navbar.layout.tsx | 5 +- src/layouts/navbar/profile/profile.tsx | 4 +- .../browser-bookmark/bookmark-popover.tsx | 2 +- .../browser-bookmark/browser-bookmark.tsx | 4 +- src/layouts/search/history.portal.tsx | 2 +- .../search/image/image-search.button.tsx | 2 +- .../search/image/image-search.portal.tsx | 2 +- .../search/select-engine/engine-selector.tsx | 2 +- .../suggestion/autocomplete-consent.modal.tsx | 2 +- .../search/voice/voice-search.button.tsx | 2 +- .../search/voice/voice-search.portal.tsx | 2 +- src/layouts/setting/setting-modal.tsx | 2 +- .../setting/tabs/about-us/about-us.tsx | 4 +- .../auth-form/components/input-text-error.tsx | 4 +- .../components/login-google.button.tsx | 2 +- .../account-verification-status.tsx | 2 +- .../account/components/interests-selector.tsx | 4 +- .../components/modals/add-email.modal.tsx | 4 +- .../components/modals/add-phone.modal.tsx | 4 +- .../modals/avatar/avatar-crop.modal.tsx | 2 +- .../components/modals/avatar/edit.avatar.tsx | 6 +- .../components/modals/edit-birthday.tsx | 4 +- .../account/components/modals/edit-city.tsx | 2 +- .../account/components/modals/edit-email.tsx | 2 +- .../account/components/modals/edit-gender.tsx | 4 +- .../components/modals/edit-interests.tsx | 4 +- .../account/components/modals/edit-name.tsx | 4 +- .../components/modals/edit-occupation.tsx | 4 +- .../components/modals/edit-username.tsx | 4 +- .../components/occupation-selector.tsx | 4 +- .../components/profile-date-picker.tsx | 2 +- .../account/components/profile-display.tsx | 6 +- .../tabs/account/components/user-coin.tsx | 2 +- .../tabs/connection/connections-tab.tsx | 2 +- .../components/referral-code-section.tsx | 2 +- .../account/tabs/rewards/components/tasks.tsx | 2 +- .../tabs/account/tabs/rewards/rewards-tab.tsx | 4 +- .../components/connection-modal.tsx | 2 +- .../tabs/user-profile/user-profile.tsx | 6 +- .../tabs/account/user-account.modal.tsx | 2 +- .../components/browser-title-selector.tsx | 4 +- .../components/content-alignment-settings.tsx | 4 +- .../appearance/components/font-selector.tsx | 4 +- .../appearance/components/theme-selector.tsx | 4 +- .../appearance/components/ui-selector.tsx | 4 +- .../tabs/general/components/select-city.tsx | 6 +- .../general/components/timezone-settings.tsx | 8 +- src/layouts/setting/tabs/general/general.tsx | 4 +- .../components/search-autocomplete.switch.tsx | 2 +- .../setting/tabs/privacy/privacy-settings.tsx | 4 +- .../setting/tabs/shortcuts/shortcuts.tsx | 2 +- .../components/coin-purchase-modal.tsx | 2 +- .../components/upload-area.component.tsx | 2 +- .../components/wallpaper-sidebar.tsx | 3 +- .../wallpaper-item/wallpaper-item.tsx | 14 +-- src/layouts/simplify/tools-simplify.tsx | 4 +- .../pets/setting/pet-setting.tsx | 4 +- .../widgetify-card/profile-progress.tsx | 2 +- .../manage-widgets/manage-widgets.tsx | 6 +- .../widget-settings-modal.tsx | 2 +- .../calendar/components/calendar-grid.tsx | 2 +- .../widgets/calendar/components/day/day.tsx | 4 +- .../components/day/tool-tip-content.tsx | 2 +- .../google-calendar/google-event.item.tsx | 2 +- .../combo-widget/combo-widget.layout.tsx | 2 +- .../components/habit-calendar-heatmap.tsx | 2 +- .../habit/components/habit-detail.modal.tsx | 6 +- .../habit/components/habit-form.modal.tsx | 6 +- .../habit/components/item/habit.item.tsx | 4 +- src/layouts/widgets/habit/habits.layout.tsx | 4 +- .../network/components/network-ip-card.tsx | 2 +- .../network/components/network-ping-card.tsx | 10 +- .../widgets/network/network.layout.tsx | 10 +- src/layouts/widgets/news/rss-feed-setting.tsx | 6 +- .../widgets/notes/components/note-editor.tsx | 4 +- .../notes/components/note-navigation.tsx | 4 +- .../todos/components/friend-item.todo.tsx | 4 +- .../todos/components/priority.dropdown.tsx | 2 +- .../todos/components/select-friends.todo.tsx | 2 +- .../widgets/todos/expandable-todo-input.tsx | 8 +- src/layouts/widgets/todos/todo.item.tsx | 14 +-- src/layouts/widgets/todos/todos.tsx | 6 +- .../tools/currency/currency-converter.tsx | 2 +- .../components/request-notification-modal.tsx | 2 +- .../pomodoro/components/settings-panel.tsx | 6 +- .../pomodoro/components/timer-display.tsx | 4 +- .../widgets/tools/pomodoro/pomodoro-timer.tsx | 6 +- .../pomodoro/top-users/top-user-item.tsx | 2 +- .../tools/pomodoro/top-users/top-users.tsx | 2 +- src/layouts/widgets/tools/tools.layout.tsx | 2 +- .../weather/current/current-box.weather.tsx | 2 +- src/layouts/widgets/widget-container.tsx | 2 +- .../wigi-arz/components/currency-box.tsx | 6 +- .../wigi-arz/components/currency-modal.tsx | 6 +- .../widgets/wigi-arz/wigi-arz-setting.tsx | 6 +- .../wigi-pad/clock-display/clock-setting.tsx | 4 +- .../clock-display/clocks/analog.clock.tsx | 2 +- .../wigi-pad/date-display/date-setting.tsx | 2 +- .../widgets/wigi-pad/wigi-pad-setting.tsx | 2 +- src/layouts/widgets/yadkar/yadkar.tsx | 2 +- src/pages/home/dialog/dialog.tsx | 2 +- src/pages/home/home.page.tsx | 2 +- src/pages/root.tsx | 10 +- 198 files changed, 1258 insertions(+), 603 deletions(-) delete mode 100644 src/components/badges/new.badge.tsx delete mode 100644 src/components/chip.component.tsx delete mode 100644 src/components/date-picker/index.ts delete mode 100644 src/components/dropdown/index.ts delete mode 100644 src/components/filter-tooltip/index.ts delete mode 100644 src/components/loading/icon-loading.tsx delete mode 100644 src/components/pagination/index.ts delete mode 100644 src/components/section-panel.tsx delete mode 100644 src/components/toggle-switch.component.tsx rename src/components/{avatar.component.tsx => ui/avatar/avatar.tsx} (70%) create mode 100644 src/components/ui/avatar/avatar.variants.ts create mode 100644 src/components/ui/badge/badge.tsx rename src/components/{ => ui}/bottom-sheet/bottom-sheet.tsx (100%) create mode 100644 src/components/ui/button/button.tsx create mode 100644 src/components/ui/button/button.variants.ts rename src/components/{checkbox-description.component.tsx => ui/checkbox/checkbox-description.tsx} (90%) rename src/components/{ => ui/checkbox}/checkbox.tsx (94%) create mode 100644 src/components/ui/chip/chip.tsx create mode 100644 src/components/ui/chip/chip.variants.ts rename src/components/{popover-color-picker.tsx => ui/color-picker/color-picker.tsx} (91%) rename src/components/{context-menu.component.tsx => ui/context-menu/context-menu.tsx} (79%) rename src/components/{ => ui}/date-picker/date-picker.tsx (97%) rename src/components/{ => ui}/dropdown/dropdown.tsx (93%) rename src/components/{ => ui}/dropdown/use-dropdown.tsx (100%) rename src/components/{ => ui}/filter-tooltip/filter-tooltip.tsx (95%) create mode 100644 src/components/ui/index.ts create mode 100644 src/components/ui/input/input.tsx create mode 100644 src/components/ui/input/input.variants.ts rename src/components/{ => ui/item-selector}/item-selector.tsx (89%) create mode 100644 src/components/ui/loading/loading.tsx rename src/components/{ => ui}/modal/confirmation-modal.tsx (63%) create mode 100644 src/components/ui/modal/confirmation-modal.variants.ts rename src/components/{ => ui/modal}/modal.tsx (68%) create mode 100644 src/components/ui/modal/modal.variants.ts rename src/components/{ => ui/offline-indicator}/offline-indicator.tsx (97%) rename src/components/{ => ui}/pagination/pagination.tsx (88%) rename src/components/{ => ui}/portal/portal.tsx (100%) create mode 100644 src/components/ui/section-panel/section-panel.tsx create mode 100644 src/components/ui/section-panel/section-panel.variants.ts rename src/components/{selectbox/selectbox.tsx => ui/select/select.tsx} (100%) rename src/components/{tab-navigation.tsx => ui/tabs/tabs.tsx} (54%) create mode 100644 src/components/ui/tabs/tabs.variants.ts create mode 100644 src/components/ui/toggle/toggle.tsx create mode 100644 src/components/ui/toggle/toggle.variants.ts rename src/components/{ => ui/tooltip}/clickable-tooltip.tsx (100%) rename src/components/{tool-tip.tsx => ui/tooltip/tooltip.tsx} (97%) diff --git a/src/components/auth/auth-required-modal.tsx b/src/components/auth/auth-required-modal.tsx index fbeced95..93c28766 100644 --- a/src/components/auth/auth-required-modal.tsx +++ b/src/components/auth/auth-required-modal.tsx @@ -1,6 +1,6 @@ import { callEvent } from '@/common/utils/call-event' -import Modal from '@/components/modal' -import { Button } from '../button/button' +import { Modal } from '@/components/ui' +import { Button } from '@/components/ui' import { Icon } from '@/src/icons' interface AuthRequiredModalProps { @@ -35,7 +35,7 @@ export function AuthRequiredModal({ closeOnBackdropClick={true} >
-
+
{loginButtonText} @@ -60,7 +60,8 @@ export function AuthRequiredModal({ diff --git a/src/components/auth/require-auth.tsx b/src/components/auth/require-auth.tsx index 20b0b95f..e43ce02d 100644 --- a/src/components/auth/require-auth.tsx +++ b/src/components/auth/require-auth.tsx @@ -2,7 +2,7 @@ import { Motion as motion } from '@/common/motion' import type { ReactNode } from 'react' import { callEvent } from '@/common/utils/call-event' import { useAuth } from '@/context/auth.context' -import { Button } from '../button/button' +import { Button } from '@/components/ui' interface RequireAuthProps { children: ReactNode @@ -49,8 +49,8 @@ export const RequireAuth = ({ children, fallback, mode = 'block' }: RequireAuthP @@ -76,8 +76,8 @@ export const RequireAuth = ({ children, fallback, mode = 'block' }: RequireAuthP diff --git a/src/components/auth/require-verification.tsx b/src/components/auth/require-verification.tsx index 2aae3ca1..8908fc0d 100644 --- a/src/components/auth/require-verification.tsx +++ b/src/components/auth/require-verification.tsx @@ -2,7 +2,7 @@ import { Motion as motion } from '@/common/motion' import type { ReactNode } from 'react' import { callEvent } from '@/common/utils/call-event' import { useAuth } from '@/context/auth.context' -import { Button } from '../button/button' +import { Button } from '@/components/ui' import { Icon } from '@/src/icons' interface RequireVerificationProps { diff --git a/src/components/badges/new.badge.tsx b/src/components/badges/new.badge.tsx deleted file mode 100644 index 7ff24ffa..00000000 --- a/src/components/badges/new.badge.tsx +++ /dev/null @@ -1,10 +0,0 @@ -interface Prop { - className: string -} -export function NewBadge({ className }: Prop) { - return ( - - ) -} diff --git a/src/components/blur-mode/blur-mode.button.tsx b/src/components/blur-mode/blur-mode.button.tsx index d31a4522..e8bd33da 100644 --- a/src/components/blur-mode/blur-mode.button.tsx +++ b/src/components/blur-mode/blur-mode.button.tsx @@ -1,8 +1,8 @@ import { useState } from 'react' import { useGeneralSetting } from '@/context/general-setting.context' -import Tooltip from '../tool-tip' +import { Tooltip } from '@/components/ui' import { Icon } from '@/src/icons' -import { ConfirmationModal } from '../modal/confirmation-modal' +import { ConfirmationModal } from '@/components/ui' import Analytics from '@/analytics' export function BlurModeButton() { diff --git a/src/components/button/button.tsx b/src/components/button/button.tsx index 5ac12d5d..b9e04e38 100644 --- a/src/components/button/button.tsx +++ b/src/components/button/button.tsx @@ -17,6 +17,15 @@ interface ButtonProps { size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' ref?: any } +/** + * @deprecated Use `import { Button } from '@/components/ui'`. + * + * Not a re-export shim on purpose: the new Button drops daisyUI's `btn` base, + * which would make the `btn-*` modifiers that 16+ call sites pass in + * `className` inert (in daisyUI 5 they only set `--btn-color`, which nothing + * reads without `.btn`). Those buttons would silently render grey instead of + * red/transparent/full-width. Migrate call sites in reviewed batches instead. + */ export function Button(prop: ButtonProps) { const sizes: Record = { xs: 'btn-xs', @@ -31,7 +40,7 @@ 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={`btn cursor-pointer ${prop.fullWidth ? 'full-width' : ''} ${prop.className} ${prop.rounded ? `rounded-${prop.rounded}` : ''} ${prop.isPrimary ? 'btn-primary' : ''} ${sizes[prop.size] || 'btn-md'} active:!translate-y-0`} style={prop.style} ref={prop.ref} > diff --git a/src/components/chip.component.tsx b/src/components/chip.component.tsx deleted file mode 100644 index 461076ce..00000000 --- a/src/components/chip.component.tsx +++ /dev/null @@ -1,25 +0,0 @@ -interface ChipProps { - selected: boolean - onClick: () => void - children: React.ReactNode - className?: string - dir?: string -} - -export const Chip: React.FC = ({ - selected, - onClick, - children, - className = '', - dir, -}) => { - return ( - - ) -} diff --git a/src/components/date-picker/index.ts b/src/components/date-picker/index.ts deleted file mode 100644 index 6bc7ab0d..00000000 --- a/src/components/date-picker/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { DatePicker } from './date-picker' diff --git a/src/components/dropdown/index.ts b/src/components/dropdown/index.ts deleted file mode 100644 index 90f5fb6a..00000000 --- a/src/components/dropdown/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export type { DropdownOption, DropdownProps } from './dropdown' -export { Dropdown } from './dropdown' diff --git a/src/components/extension-installed-modal.tsx b/src/components/extension-installed-modal.tsx index cd89e9e6..106521f0 100644 --- a/src/components/extension-installed-modal.tsx +++ b/src/components/extension-installed-modal.tsx @@ -1,8 +1,8 @@ import { useState } from 'react' import keepItImage from '@/assets/images/keep-it.png' -import { Button } from './button/button' -import Checkbox from './checkbox' -import Modal from './modal' +import { Button } from './ui' +import { Checkbox } from '@/components/ui' +import { Modal } from '@/components/ui' import { Icon } from '../icons' interface ExtensionInstalledModalProps { @@ -72,7 +72,7 @@ const StepOne = ({ onGetStarted }: StepOneProps) => { size="md" onClick={onGetStarted} className="w-full text-base font-light shadow-sm rounded-2xl shadow-primary outline-none!" - isPrimary={true} + variant="primary" > شروع کنید @@ -158,14 +158,16 @@ const StepFirefoxConsent = ({ onGetStarted }: StepFirefoxConsentProps) => { diff --git a/src/components/filter-tooltip/index.ts b/src/components/filter-tooltip/index.ts deleted file mode 100644 index 27d286d1..00000000 --- a/src/components/filter-tooltip/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { - FilterTooltip, - type FilterOption, - type FilterTooltipProps, -} from './filter-tooltip' diff --git a/src/components/loading/icon-loading.tsx b/src/components/loading/icon-loading.tsx deleted file mode 100644 index 3f885630..00000000 --- a/src/components/loading/icon-loading.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Icon } from '@/src/icons' -import Tooltip from '../tool-tip' - -interface IconLoadingProps { - title?: string - className?: string -} -export function IconLoading({ title, className }: IconLoadingProps) { - if (!title) { - return ( - - ) - } - return ( - - - - ) -} diff --git a/src/components/pagination/index.ts b/src/components/pagination/index.ts deleted file mode 100644 index 93a761b4..00000000 --- a/src/components/pagination/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export type { PaginationProps } from './pagination' -export { Pagination } from './pagination' diff --git a/src/components/section-panel.tsx b/src/components/section-panel.tsx deleted file mode 100644 index eb6e6e57..00000000 --- a/src/components/section-panel.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import type { ReactNode } from 'react' -import React from 'react' - -interface SectionPanelProps { - title: ReactNode - children: ReactNode - delay?: number - size?: 'xs' | 'sm' | 'md' | 'lg' - icon?: React.ReactElement -} - -export function SectionPanel({ title, children, size = 'md', icon }: SectionPanelProps) { - const getSizeStyles = () => { - switch (size) { - case 'xs': - return { - container: 'rounded-sm', - header: 'p-1', - title: 'text-sm', - content: 'p-1', - } - case 'sm': - return { - container: 'rounded-lg', - header: 'p-3', - title: 'text-base', - content: 'p-3', - } - case 'lg': - return { - container: 'rounded-xl', - header: 'p-5', - title: 'text-xl', - content: 'p-5', - } - default: // md - return { - container: 'rounded-xl', - header: 'p-4', - title: 'text-lg', - content: 'p-4', - } - } - } - const sizeStyles = getSizeStyles() - - return ( -
-
-
- {icon && React.cloneElement(icon, {})} -

- {title} -

-
-
-
{children}
-
- ) -} diff --git a/src/components/text-input.tsx b/src/components/text-input.tsx index 93f230ee..b71e3848 100644 --- a/src/components/text-input.tsx +++ b/src/components/text-input.tsx @@ -39,6 +39,13 @@ const sizes: Record = { [TextInputSize.XL]: 'input-xl', } +/** + * @deprecated Use `import { TextInput } from '@/components/ui'`. + * + * The replacement is source-compatible (no call site passes `size`, and the + * unexported `TextInputSize` enum is now a string union), so migration is a + * pure import swap — done in batches so each screen gets looked at. + */ export const TextInput = memo(function TextInput({ onChange, value, diff --git a/src/components/toggle-switch.component.tsx b/src/components/toggle-switch.component.tsx deleted file mode 100644 index 93a9ec7c..00000000 --- a/src/components/toggle-switch.component.tsx +++ /dev/null @@ -1,38 +0,0 @@ -interface ToggleSwitchProps { - enabled: boolean - disabled?: boolean - loading?: boolean - onToggle: () => void -} - -export const ToggleSwitch = ({ - enabled, - disabled = false, - loading = false, - onToggle, -}: ToggleSwitchProps) => { - const getTrackStyle = () => { - if (enabled) { - return 'bg-primary' - } - - return 'bg-base-300' - } - - return ( -
- -
- ) -} diff --git a/src/components/avatar.component.tsx b/src/components/ui/avatar/avatar.tsx similarity index 70% rename from src/components/avatar.component.tsx rename to src/components/ui/avatar/avatar.tsx index e5c33098..056366f3 100644 --- a/src/components/avatar.component.tsx +++ b/src/components/ui/avatar/avatar.tsx @@ -1,26 +1,22 @@ +import type { VariantProps } from 'class-variance-authority' import { useEffect, useState } from 'react' +import { cn } from '@/common/utils/cn' +import { avatarVariants } from './avatar.variants' -interface AvatarProps { +export interface AvatarProps extends VariantProps { url?: string | null file?: File | null placeholder?: string - size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' className?: string onClick?: () => void } -const sizeClasses = { - xs: 'w-6 h-6 text-xs', - sm: 'w-8 h-8 text-sm', - md: 'w-10 h-10 text-base', - lg: 'w-12 h-12 text-lg', - xl: 'w-16 h-16 text-xl', -} + export function AvatarComponent({ url, file, placeholder = '', - size = 'md', - className = '', + size, + className, onClick, }: AvatarProps) { const [imageError, setImageError] = useState(false) @@ -52,10 +48,7 @@ export function AvatarComponent({ const shouldShowImage = displayUrl && !imageError return ( -
+
{shouldShowImage ? ( ) : ( -
+
{placeholder.charAt(0)?.toUpperCase() || '?'}
)} diff --git a/src/components/ui/avatar/avatar.variants.ts b/src/components/ui/avatar/avatar.variants.ts new file mode 100644 index 00000000..62a31ffa --- /dev/null +++ b/src/components/ui/avatar/avatar.variants.ts @@ -0,0 +1,21 @@ +import { cva } from 'class-variance-authority' + +export const avatarVariants = cva( + ['rounded-full', 'overflow-hidden', 'flex', 'items-center', 'justify-center'], + { + variants: { + size: { + xs: ['w-6', 'h-6', 'text-xs'], + sm: ['w-8', 'h-8', 'text-sm'], + md: ['w-10', 'h-10', 'text-base'], + lg: ['w-12', 'h-12', 'text-lg'], + xl: ['w-16', 'h-16', 'text-xl'], + }, + }, + defaultVariants: { + size: 'md', + }, + } +) + +export type AvatarVariant = typeof avatarVariants diff --git a/src/components/ui/badge/badge.tsx b/src/components/ui/badge/badge.tsx new file mode 100644 index 00000000..bc1bfa41 --- /dev/null +++ b/src/components/ui/badge/badge.tsx @@ -0,0 +1,14 @@ +import { twMerge } from 'tailwind-merge' +interface Prop { + className: string +} +export function NewBadge({ className }: Prop) { + return ( + + ) +} diff --git a/src/components/bottom-sheet/bottom-sheet.tsx b/src/components/ui/bottom-sheet/bottom-sheet.tsx similarity index 100% rename from src/components/bottom-sheet/bottom-sheet.tsx rename to src/components/ui/bottom-sheet/bottom-sheet.tsx diff --git a/src/components/ui/button/button.tsx b/src/components/ui/button/button.tsx new file mode 100644 index 00000000..8143cd73 --- /dev/null +++ b/src/components/ui/button/button.tsx @@ -0,0 +1,56 @@ +import type React from 'react' +import { cn } from '@/common/utils/cn' +import { Icon } from '@/src/icons' +import { type ButtonVariantProps, buttonVariants } from './button.variants' + +export interface ButtonProps + extends Omit, 'color'>, + ButtonVariantProps { + loading?: boolean + loadingText?: React.ReactNode + icon?: React.ReactNode + /** @deprecated Use `variant="primary"` instead. */ + isPrimary?: boolean +} + +export function Button({ + className, + variant, + size, + rounded, + fullWidth, + isPrimary, + loading, + loadingText, + icon, + type = 'button', + children, + ...rest +}: ButtonProps) { + const resolvedVariant = variant ?? (isPrimary ? 'primary' : 'default') + + return ( + + ) +} diff --git a/src/components/ui/button/button.variants.ts b/src/components/ui/button/button.variants.ts new file mode 100644 index 00000000..14c426bd --- /dev/null +++ b/src/components/ui/button/button.variants.ts @@ -0,0 +1,74 @@ +import { cva, type VariantProps } from 'class-variance-authority' + +/** + * Button variants. + * + * cva declares WHICH classes a variant contributes. It never merges — merging + * is `cn()`'s job (see common/utils/cn.ts). + * + * Sizes mirror daisyUI's `--size-field * n` ladder (24/32/40/48/56px) and its + * padding/font ramp on purpose: a call site migrating off `btn` keeps its exact + * geometry, so the only thing that changes is colour — which is the part worth + * reviewing. + * + * Colour comes from the project's semantic tokens, never from `btn-*`. + */ +export const buttonVariants = cva( + [ + 'inline-flex shrink-0 items-center justify-center gap-1.5', + 'whitespace-nowrap align-middle select-none cursor-pointer', + 'border font-semibold', + 'transition-ui', + 'focus-visible:focus-ring', + 'disabled:pointer-events-none disabled:opacity-50', + ], + { + variants: { + variant: { + default: 'bg-content text-content border-content hover:bg-raised', + primary: + 'bg-primary text-primary-content border-transparent hover:bg-primary/90', + secondary: + 'bg-secondary text-secondary-content border-transparent hover:bg-secondary/90', + outline: 'bg-transparent text-content border-content hover:bg-raised', + ghost: 'bg-transparent text-muted border-transparent hover:bg-raised hover:text-content', + danger: 'bg-error text-error-content border-transparent hover:bg-error/90', + success: + 'bg-success text-success-content border-transparent hover:bg-success/90', + }, + size: { + xs: 'h-6 px-2 text-[0.6875rem]', + sm: 'h-8 px-3 text-xs', + md: 'h-10 px-4 text-sm', + lg: 'h-12 px-5 text-lg', + xl: 'h-14 px-6 text-[1.375rem]', + }, + /** + * Literal class names only. The previous implementation built + * `rounded-${prop.rounded}` at runtime, which Tailwind v4's source + * scanner cannot see — those classes only shipped by coincidence, + * when the same literal happened to appear elsewhere in the codebase. + */ + rounded: { + sm: 'rounded-sm', + md: 'rounded-md', + lg: 'rounded-lg', + xl: 'rounded-xl', + full: 'rounded-full', + card: 'rounded-card', + }, + fullWidth: { + true: 'w-full', + false: '', + }, + }, + defaultVariants: { + variant: 'default', + size: 'md', + rounded: 'card', + fullWidth: false, + }, + } +) + +export type ButtonVariantProps = VariantProps diff --git a/src/components/checkbox-description.component.tsx b/src/components/ui/checkbox/checkbox-description.tsx similarity index 90% rename from src/components/checkbox-description.component.tsx rename to src/components/ui/checkbox/checkbox-description.tsx index d293b1b9..b4202268 100644 --- a/src/components/checkbox-description.component.tsx +++ b/src/components/ui/checkbox/checkbox-description.tsx @@ -1,4 +1,4 @@ -import CustomCheckbox from '@/components/checkbox' +import { Checkbox } from '@/components/ui' interface Props { isEnabled: boolean @@ -20,7 +20,7 @@ export function CheckBoxWithDescription({ } onClick={onToggle} > - { if (checked) { if (checkedCheckBoxClassName) return checkedCheckBoxClassName - return 'bg-blue-500 border-blue-500' + return 'bg-primary border-primary' } if (unCheckedCheckBoxClassName) return unCheckedCheckBoxClassName @@ -90,4 +90,6 @@ const CustomCheckbox = ({ ) } -export default memo(CustomCheckbox) +export const Checkbox = memo(CheckboxBase) + +Checkbox.displayName = 'Checkbox' diff --git a/src/components/ui/chip/chip.tsx b/src/components/ui/chip/chip.tsx new file mode 100644 index 00000000..0dca9ba2 --- /dev/null +++ b/src/components/ui/chip/chip.tsx @@ -0,0 +1,28 @@ +import type { VariantProps } from 'class-variance-authority' +import { cn } from '@/common/utils/cn' +import { chipVariants } from './chip.variants' + +export interface ChipProps extends VariantProps { + onClick: () => void + children: React.ReactNode + className?: string + dir?: string +} + +export const Chip: React.FC = ({ + selected, + onClick, + children, + className, + dir, +}) => { + return ( + + ) +} diff --git a/src/components/ui/chip/chip.variants.ts b/src/components/ui/chip/chip.variants.ts new file mode 100644 index 00000000..4c65f3ef --- /dev/null +++ b/src/components/ui/chip/chip.variants.ts @@ -0,0 +1,33 @@ +import { cva } from 'class-variance-authority' + +export const chipVariants = cva( + [ + 'px-4', + 'py-2', + 'cursor-pointer', + 'rounded-full', + 'text-xs', + 'font-bold', + 'border-2', + 'transition-all', + 'active:scale-95', + ], + { + variants: { + selected: { + true: ['bg-primary', 'border-primary', 'text-primary-content'], + false: [ + 'bg-base-100', + 'border-base-300/30', + 'text-muted', + 'hover:border-primary/30', + ], + }, + }, + defaultVariants: { + selected: false, + }, + } +) + +export type ChipVariant = typeof chipVariants diff --git a/src/components/popover-color-picker.tsx b/src/components/ui/color-picker/color-picker.tsx similarity index 91% rename from src/components/popover-color-picker.tsx rename to src/components/ui/color-picker/color-picker.tsx index 164d6a80..6b152fb3 100644 --- a/src/components/popover-color-picker.tsx +++ b/src/components/ui/color-picker/color-picker.tsx @@ -7,7 +7,7 @@ interface PopoverColorPickerProps { onChange: (color: string) => void } -const PopoverColorPicker: React.FC = ({ color, onChange }) => { +export const ColorPicker: React.FC = ({ color, onChange }) => { const triggerRef = useRef(null) const popoverRef = useRef(null) const [isOpen, setIsOpen] = useState(false) @@ -71,12 +71,11 @@ const PopoverColorPicker: React.FC = ({ color, onChange createPortal(
@@ -86,5 +85,3 @@ const PopoverColorPicker: React.FC = ({ color, onChange ) } - -export default PopoverColorPicker diff --git a/src/components/context-menu.component.tsx b/src/components/ui/context-menu/context-menu.tsx similarity index 79% rename from src/components/context-menu.component.tsx rename to src/components/ui/context-menu/context-menu.tsx index 45f4fc42..d8f90694 100644 --- a/src/components/context-menu.component.tsx +++ b/src/components/ui/context-menu/context-menu.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef } from 'react' +import { cn } from '@/common/utils/cn' interface ContextMenuProps { className?: string @@ -32,11 +33,13 @@ export function ContextMenu({ return (
{children} diff --git a/src/components/date-picker/date-picker.tsx b/src/components/ui/date-picker/date-picker.tsx similarity index 97% rename from src/components/date-picker/date-picker.tsx rename to src/components/ui/date-picker/date-picker.tsx index ce556a90..f3cb33e5 100644 --- a/src/components/date-picker/date-picker.tsx +++ b/src/components/ui/date-picker/date-picker.tsx @@ -1,3 +1,4 @@ +import { twMerge } from 'tailwind-merge' import { Icon } from '@/src/icons' import jalaliMoment from 'jalali-moment' import { useState } from 'react' @@ -134,7 +135,7 @@ export function DatePicker({ if (isDaySelected) return '' if (isFriday) { - return 'hover:bg-red-400/10' + return 'hover:bg-error/10' } return 'hover:bg-primary/10' @@ -189,7 +190,10 @@ export function DatePicker({ return (

diff --git a/src/components/dropdown/dropdown.tsx b/src/components/ui/dropdown/dropdown.tsx similarity index 93% rename from src/components/dropdown/dropdown.tsx rename to src/components/ui/dropdown/dropdown.tsx index 735c1e5e..99f7f733 100644 --- a/src/components/dropdown/dropdown.tsx +++ b/src/components/ui/dropdown/dropdown.tsx @@ -1,3 +1,4 @@ +import { twMerge } from 'tailwind-merge' import type { ReactNode } from 'react' import { Portal } from '../portal/portal' import { useDropdown } from './use-dropdown' @@ -198,7 +199,7 @@ export function Dropdown({ ) return ( -
+
-
+
.variants.ts` exporting a `cva` config, and + * composes it with `cn(xVariants({ … }), className)`. + * - A component whose styling is only conditional (booleans, states) gets NO + * variants file — it merges with `twMerge` directly. + * - `cva` declares variants. It never merges. + */ + +export * from './avatar/avatar' +export * from './avatar/avatar.variants' + +export * from './badge/badge' + +export * from './bottom-sheet/bottom-sheet' + +export * from './button/button' +export * from './button/button.variants' + +export * from './checkbox/checkbox' +export * from './checkbox/checkbox-description' + +export * from './chip/chip' +export * from './chip/chip.variants' + +export * from './color-picker/color-picker' + +export * from './context-menu/context-menu' + +export * from './date-picker/date-picker' + +export * from './dropdown/dropdown' + +export * from './filter-tooltip/filter-tooltip' + +export * from './input/input' +export * from './input/input.variants' + +export * from './item-selector/item-selector' + +export * from './loading/loading' + +export * from './modal/confirmation-modal' +export * from './modal/confirmation-modal.variants' +export * from './modal/modal' +export * from './modal/modal.variants' + +export * from './offline-indicator/offline-indicator' + +export * from './pagination/pagination' + +export * from './portal/portal' + +export * from './section-panel/section-panel' +export * from './section-panel/section-panel.variants' + +export * from './select/select' + +export * from './tabs/tabs' +export * from './tabs/tabs.variants' + +export * from './toggle/toggle' +export * from './toggle/toggle.variants' + +export * from './tooltip/clickable-tooltip' +export * from './tooltip/tooltip' diff --git a/src/components/ui/input/input.tsx b/src/components/ui/input/input.tsx new file mode 100644 index 00000000..a5688e62 --- /dev/null +++ b/src/components/ui/input/input.tsx @@ -0,0 +1,113 @@ +import { memo, useCallback, useEffect, useRef } from 'react' +import { cn } from '@/common/utils/cn' +import { type TextInputSize, textInputVariants } from './input.variants' + +export interface TextInputProps { + id?: string + value?: string + defaultValue?: string + onChange: (value: string) => void + placeholder?: string + onFocus?: () => void + onKeyDown?: (e: React.KeyboardEvent) => void + disabled?: boolean + name?: string + type?: string + direction?: 'rtl' | 'ltr' | 'auto' + className?: string + ref?: React.RefObject + debounce?: boolean + debounceTime?: number + maxLength?: number + size?: TextInputSize + invalid?: boolean + min?: number + max?: number + autoComplete?: 'on' | 'off' +} + +export const TextInput = memo(function TextInput({ + onChange, + value, + defaultValue, + placeholder, + onFocus, + onKeyDown, + disabled = false, + name, + id, + type = 'text', + direction, + className, + ref, + debounce = false, + debounceTime = 150, + maxLength = 1000, + size = 'md', + invalid = false, + min, + max, + autoComplete = 'off', +}: TextInputProps) { + const debounceTimerRef = useRef(null) + const isControlled = value !== undefined + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const newValue = e.target.value + + if (!debounce) { + onChange(newValue) + return + } + + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current) + } + + debounceTimerRef.current = setTimeout( + () => { + onChange(newValue) + }, + // Colour pickers fire continuously while dragging, so they need a + // much shorter debounce to still feel live. + type === 'color' ? 50 : debounceTime + ) + }, + [onChange, debounce, type, debounceTime] + ) + + useEffect(() => { + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current) + } + } + }, []) + + const inputProps = { + ref, + id, + type, + name, + disabled, + onFocus, + onKeyDown, + dir: direction, + placeholder: placeholder || '', + className: cn(textInputVariants({ size, invalid }), className), + onChange: handleChange, + maxLength, + autoComplete, + min, + max, + } + + if (isControlled) { + return + } + + return +}) + +TextInput.displayName = 'TextInput' diff --git a/src/components/ui/input/input.variants.ts b/src/components/ui/input/input.variants.ts new file mode 100644 index 00000000..ed9a5a24 --- /dev/null +++ b/src/components/ui/input/input.variants.ts @@ -0,0 +1,54 @@ +import { cva, type VariantProps } from 'class-variance-authority' + +/** + * Text input variants. + * + * Same `--size-field * n` ladder as Button (24/32/40/48/56px), matching + * daisyUI's `input-*` geometry so migrated call sites keep their dimensions. + * + * `rounded-xl` is kept rather than `rounded-card` so this pilot is a zero-delta + * visual swap; tokenising the radius is a deliberate follow-up, not something + * to slip into the migration commit. + */ +export const textInputVariants = cva( + [ + 'w-full inline-flex items-center', + 'bg-content text-content', + 'border border-content rounded-xl', + 'font-light', + 'transition-ui', + 'placeholder:text-subtle', + 'outline-none focus:outline-none', + 'focus:border-primary focus:ring-1 focus:ring-primary/20', + 'disabled:cursor-not-allowed disabled:opacity-50', + ], + { + variants: { + size: { + xs: 'h-6 px-2 text-[0.6875rem]', + sm: 'h-8 px-3 text-xs', + md: 'h-10 px-3 text-sm', + lg: 'h-12 px-4 text-lg', + xl: 'h-14 px-4 text-[1.375rem]', + }, + invalid: { + true: 'border-error focus:border-error focus:ring-error/20', + false: '', + }, + }, + defaultVariants: { + size: 'md', + invalid: false, + }, + } +) + +export type TextInputVariantProps = VariantProps + +/** + * Replaces the old `TextInputSize` enum, which was never exported (so it could + * not be part of the public API) and which no call site passed. A string union + * is a pure widening — `size="sm"` becomes newly legal, nothing breaks — and it + * avoids shipping an enum's runtime object. + */ +export type TextInputSize = NonNullable diff --git a/src/components/item-selector.tsx b/src/components/ui/item-selector/item-selector.tsx similarity index 89% rename from src/components/item-selector.tsx rename to src/components/ui/item-selector/item-selector.tsx index 0639a91f..6621e046 100644 --- a/src/components/item-selector.tsx +++ b/src/components/ui/item-selector/item-selector.tsx @@ -1,4 +1,5 @@ import type React from 'react' +import { cn } from '@/common/utils/cn' interface Props { isActive: boolean @@ -27,11 +28,13 @@ export function ItemSelector({ return (
diff --git a/src/components/ui/loading/loading.tsx b/src/components/ui/loading/loading.tsx new file mode 100644 index 00000000..8d018d66 --- /dev/null +++ b/src/components/ui/loading/loading.tsx @@ -0,0 +1,25 @@ +import { cn } from '@/common/utils/cn' +import { Icon } from '@/src/icons' +import { Tooltip } from '../tooltip/tooltip' + +interface IconLoadingProps { + title?: string + className?: string +} + +export function IconLoading({ title, className }: IconLoadingProps) { + const icon = ( + + ) + + if (!title) return icon + + return ( + + {icon} + + ) +} diff --git a/src/components/modal/confirmation-modal.tsx b/src/components/ui/modal/confirmation-modal.tsx similarity index 63% rename from src/components/modal/confirmation-modal.tsx rename to src/components/ui/modal/confirmation-modal.tsx index 2dcb2b7f..e02bbbe8 100644 --- a/src/components/modal/confirmation-modal.tsx +++ b/src/components/ui/modal/confirmation-modal.tsx @@ -1,8 +1,13 @@ import type { ReactNode } from 'react' import { Button } from '../button/button' -import Modal from '../modal' -import { IconLoading } from '../loading/icon-loading' +import { Modal } from './modal' +import { IconLoading } from '../loading/loading' import { Icon } from '@/src/icons' +import { cn } from '@/common/utils/cn' +import { + confirmationConfirmButtonVariants, + confirmationIconVariants, +} from './confirmation-modal.variants' interface ConfirmationModalProps { isOpen: boolean @@ -18,39 +23,12 @@ interface ConfirmationModalProps { direction?: 'rtl' | 'ltr' } -const variantConfig = { - danger: { - icon: , - accentBar: 'bg-error', - iconBg: 'bg-error/10', - iconColor: 'text-error', - confirmBg: 'bg-error hover:bg-error/90', - confirmText: 'text-white', - }, - warning: { - icon: , - accentBar: 'bg-warning', - iconBg: 'bg-warning/10', - iconColor: 'text-warning', - confirmBg: 'bg-warning hover:bg-warning/90', - confirmText: 'text-white', - }, - info: { - icon: , - accentBar: 'bg-info', - iconBg: 'bg-info/10', - iconColor: 'text-info', - confirmBg: 'bg-info/80 hover:bg-info/90', - confirmText: 'text-white', - }, - primary: { - icon: , - accentBar: 'bg-primary', - iconBg: 'bg-primary/10', - iconColor: 'text-primary', - confirmBg: 'bg-primary/80 hover:bg-primary/90', - confirmText: 'text-white', - }, +/** Only the icon element lives here now — all classes come from cva. */ +const variantIcon = { + danger: , + warning: , + info: , + primary: , } export function ConfirmationModal({ @@ -66,8 +44,7 @@ export function ConfirmationModal({ icon, direction = 'rtl', }: ConfirmationModalProps) { - const config = variantConfig[variant] - const displayIcon = icon || config.icon + const displayIcon = icon || variantIcon[variant] const handleConfirm = () => { if (!isLoading) { @@ -92,9 +69,12 @@ export function ConfirmationModal({ title={
-
{displayIcon}
+
{displayIcon}
{title && (

{title}

@@ -129,7 +109,10 @@ export function ConfirmationModal({ در حال انجام...
} - className={`${config.confirmBg} ${config.confirmText} w-fit px-8 border-none rounded-2xl`} + className={cn( + confirmationConfirmButtonVariants({ variant }), + 'w-fit px-8 border-none rounded-2xl' + )} > {confirmText} diff --git a/src/components/ui/modal/confirmation-modal.variants.ts b/src/components/ui/modal/confirmation-modal.variants.ts new file mode 100644 index 00000000..f6a91a05 --- /dev/null +++ b/src/components/ui/modal/confirmation-modal.variants.ts @@ -0,0 +1,49 @@ +import { cva } from 'class-variance-authority' + +/** + * Confirmation modal accent colours. + * + * Four slots share one `variant` key. The icon element itself stays in the + * component — cva describes classes, not JSX. + */ + +export const confirmationAccentBarVariants = cva(['h-1', 'w-full'], { + variants: { + variant: { + danger: ['bg-error'], + warning: ['bg-warning'], + info: ['bg-info'], + primary: ['bg-primary'], + }, + }, + defaultVariants: { variant: 'danger' }, +}) + +export const confirmationIconVariants = cva( + ['flex', 'items-center', 'justify-center', 'rounded-full'], + { + variants: { + variant: { + danger: ['bg-error/10', 'text-error'], + warning: ['bg-warning/10', 'text-warning'], + info: ['bg-info/10', 'text-info'], + primary: ['bg-primary/10', 'text-primary'], + }, + }, + defaultVariants: { variant: 'danger' }, + } +) + +export const confirmationConfirmButtonVariants = cva([], { + variants: { + variant: { + danger: ['bg-error', 'hover:bg-error/90', 'text-error-content'], + warning: ['bg-warning', 'hover:bg-warning/90', 'text-warning-content'], + info: ['bg-info/80', 'hover:bg-info/90', 'text-info-content'], + primary: ['bg-primary/80', 'hover:bg-primary/90', 'text-primary-content'], + }, + }, + defaultVariants: { variant: 'danger' }, +}) + +export type ConfirmationVariant = typeof confirmationAccentBarVariants diff --git a/src/components/modal.tsx b/src/components/ui/modal/modal.tsx similarity index 68% rename from src/components/modal.tsx rename to src/components/ui/modal/modal.tsx index f328805b..8c9563ed 100644 --- a/src/components/modal.tsx +++ b/src/components/ui/modal/modal.tsx @@ -1,12 +1,14 @@ +import type { VariantProps } from 'class-variance-authority' import React, { type ReactNode, useEffect, useRef } from 'react' import { createPortal } from 'react-dom' -import { Icon } from '../icons' +import { cn } from '@/common/utils/cn' +import { Icon } from '@/src/icons' +import { modalBoxVariants, modalScrollVariants } from './modal.variants' -type ModalProps = { +export type ModalProps = VariantProps & { isOpen: boolean onClose: () => void title?: React.ReactNode - size?: 'sm' | 'md' | 'lg' | 'xl' | 'full' children: ReactNode direction?: 'rtl' | 'ltr' closeOnBackdropClick?: boolean @@ -14,47 +16,18 @@ type ModalProps = { className?: string } -const sizeClasses = { - sm: { - w: 'w-[calc(100vw-2rem)] max-w-sm', - h: 'max-h-[calc(100vh-4rem)] md:max-h-[560px]', - minH: 'min-h-[180px]', - }, - md: { - w: 'w-[calc(100vw-2rem)] max-w-md', - h: 'max-h-[calc(100vh-4rem)] md:max-h-[640px]', - minH: 'min-h-[200px]', - }, - lg: { - w: 'w-[calc(100vw-2rem)] max-w-lg', - h: 'max-h-[calc(100vh-4rem)] md:max-h-[720px]', - minH: 'min-h-[240px]', - }, - xl: { - w: 'w-[calc(100vw-2rem)] max-w-4xl', - h: 'max-h-[calc(100vh-4rem)] md:max-h-[800px]', - minH: 'min-h-[280px]', - }, - full: { - w: 'w-[calc(100vw-1rem)] max-w-5xl', - h: 'h-[calc(100vh-4rem)] md:h-[calc(100vh-6rem)]', - minH: 'min-h-[calc(100vh-4rem)]', - }, -} as const - -export default function Modal({ +export function Modal({ isOpen, onClose, title, - size = 'md', + size, children, closeOnBackdropClick = true, direction = 'ltr', showCloseButton = true, - className = '', + className, }: ModalProps) { const modalRef = useRef(null) - const sizeValue = sizeClasses[size] || sizeClasses.md // Lock body scroll when modal is open useEffect(() => { @@ -97,10 +70,7 @@ export default function Modal({ } }, [isOpen]) - const modalBoxClasses = ` - modal-box overflow-hidden rounded-xl md:rounded-2xl p-3 md:p-4 shadow-xl transition-all duration-200 - ${sizeValue.w} ${sizeValue.minH} ${size === 'full' ? sizeValue.h : ''} ${className} - ` + const modalBoxClasses = cn(modalBoxVariants({ size }), className) if (!isOpen) return null @@ -116,7 +86,7 @@ export default function Modal({
e.stopPropagation()} - className={`${modalBoxClasses} animate-modal-in`} + className={cn(modalBoxClasses, 'animate-modal-in')} > {(title || showCloseButton) && (
@@ -145,11 +115,7 @@ export default function Modal({ )}
)} -
- {children} -
+
{children}
, document.body diff --git a/src/components/ui/modal/modal.variants.ts b/src/components/ui/modal/modal.variants.ts new file mode 100644 index 00000000..483023b3 --- /dev/null +++ b/src/components/ui/modal/modal.variants.ts @@ -0,0 +1,65 @@ +import { cva } from 'class-variance-authority' + +/** + * The modal box: width and min-height. The per-size max-height lives on the + * inner scroll container instead (see modalScrollVariants), which is what + * actually bounds the content. + */ +export const modalBoxVariants = cva( + [ + 'modal-box', + 'overflow-hidden', + 'rounded-widget', + 'p-3', + 'md:p-4', + 'elevation-lg', + 'transition-all', + 'duration-200', + ], + { + variants: { + size: { + sm: ['w-[calc(100vw-2rem)]', 'max-w-sm', 'min-h-[180px]'], + md: ['w-[calc(100vw-2rem)]', 'max-w-md', 'min-h-[200px]'], + lg: ['w-[calc(100vw-2rem)]', 'max-w-lg', 'min-h-[240px]'], + xl: ['w-[calc(100vw-2rem)]', 'max-w-4xl', 'min-h-[280px]'], + full: [ + 'w-[calc(100vw-1rem)]', + 'max-w-5xl', + 'min-h-[calc(100vh-4rem)]', + 'h-[calc(100vh-4rem)]', + 'md:h-[calc(100vh-6rem)]', + ], + }, + }, + defaultVariants: { + size: 'md', + }, + } +) + +export type ModalBoxVariant = typeof modalBoxVariants + +/** + * The scrollable content area. This is what carries the per-size max-height — + * `full` stretches to the box instead of capping. + */ +export const modalScrollVariants = cva( + ['overflow-y-auto', 'overflow-x-hidden', 'pr-0.5', 'md:pr-1'], + { + variants: { + size: { + sm: ['max-h-[calc(100vh-4rem)]', 'md:max-h-[560px]'], + md: ['max-h-[calc(100vh-4rem)]', 'md:max-h-[640px]'], + lg: ['max-h-[calc(100vh-4rem)]', 'md:max-h-[720px]'], + xl: ['max-h-[calc(100vh-4rem)]', 'md:max-h-[800px]'], + full: ['h-full'], + }, + }, + defaultVariants: { + size: 'md', + }, + } +) + +export type ModalScrollVariant = typeof modalScrollVariants diff --git a/src/components/offline-indicator.tsx b/src/components/ui/offline-indicator/offline-indicator.tsx similarity index 97% rename from src/components/offline-indicator.tsx rename to src/components/ui/offline-indicator/offline-indicator.tsx index 663b2a54..97d94e3e 100644 --- a/src/components/offline-indicator.tsx +++ b/src/components/ui/offline-indicator/offline-indicator.tsx @@ -1,4 +1,4 @@ -import { Icon } from '../icons' +import { Icon } from '@/src/icons' type OfflineIndicatorMode = 'badge' | 'status' | 'notification' diff --git a/src/components/pagination/pagination.tsx b/src/components/ui/pagination/pagination.tsx similarity index 88% rename from src/components/pagination/pagination.tsx rename to src/components/ui/pagination/pagination.tsx index c8a20e36..6fdc88d1 100644 --- a/src/components/pagination/pagination.tsx +++ b/src/components/ui/pagination/pagination.tsx @@ -1,3 +1,4 @@ +import { twMerge } from 'tailwind-merge' import { Button } from '@/components/button/button' import { Icon } from '@/src/icons' @@ -23,7 +24,12 @@ export function Pagination({ } return ( -
+
diff --git a/src/layouts/navbar/profile/profile.tsx b/src/layouts/navbar/profile/profile.tsx index 6677e944..215cb9d1 100644 --- a/src/layouts/navbar/profile/profile.tsx +++ b/src/layouts/navbar/profile/profile.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react' import { listenEvent } from '@/common/utils/call-event' -import { AvatarComponent } from '@/components/avatar.component' -import Tooltip from '@/components/tool-tip' +import { AvatarComponent } from '@/components/ui' +import { Tooltip } from '@/components/ui' import { useAuth } from '@/context/auth.context' import { UserAccountModal } from '../../setting/tabs/account/user-account.modal' import { WelcomeWizard } from '@/components/welcome-wizard' diff --git a/src/layouts/search/browser-bookmark/bookmark-popover.tsx b/src/layouts/search/browser-bookmark/bookmark-popover.tsx index f6cfc935..b1393e32 100644 --- a/src/layouts/search/browser-bookmark/bookmark-popover.tsx +++ b/src/layouts/search/browser-bookmark/bookmark-popover.tsx @@ -76,7 +76,7 @@ export function BookmarkPopover({ isOpen, onClose, coords }: BookmarkPopoverProp return createPortal(
void }) { const onClickHandle = () => { diff --git a/src/layouts/search/image/image-search.portal.tsx b/src/layouts/search/image/image-search.portal.tsx index b5ac74a4..6fe10b26 100644 --- a/src/layouts/search/image/image-search.portal.tsx +++ b/src/layouts/search/image/image-search.portal.tsx @@ -6,7 +6,7 @@ import Analytics from '@/analytics' import { RequireAuth } from '@/components/auth/require-auth' import { getMainClient } from '@/services/api' import { translateError } from '@/common/utils/translate-error' -import { Portal } from '@/components/portal/portal' +import { Portal } from '@/components/ui' import { Icon } from '@/src/icons' interface ImageSearchPortalProps { diff --git a/src/layouts/search/select-engine/engine-selector.tsx b/src/layouts/search/select-engine/engine-selector.tsx index 6019dc6b..093c1f3a 100644 --- a/src/layouts/search/select-engine/engine-selector.tsx +++ b/src/layouts/search/select-engine/engine-selector.tsx @@ -1,6 +1,6 @@ import { getFromStorage, setToStorage } from '@/common/storage' import { callEvent } from '@/common/utils/call-event' -import { Dropdown } from '@/components/dropdown' +import { Dropdown } from '@/components/ui' import { useAuth } from '@/context/auth.context' import { useChangeSearchEngine } from '@/services/hooks/extension/update-setting.hook' import { type EngineMeta, useGetSearchboxData } from '@/services/hooks/trends/get-trends' diff --git a/src/layouts/search/suggestion/autocomplete-consent.modal.tsx b/src/layouts/search/suggestion/autocomplete-consent.modal.tsx index 70b2d205..ae269806 100644 --- a/src/layouts/search/suggestion/autocomplete-consent.modal.tsx +++ b/src/layouts/search/suggestion/autocomplete-consent.modal.tsx @@ -1,6 +1,6 @@ import { autoFormatErrorToast } from '@/common/toast' import { Button } from '@/components/button/button' -import Modal from '@/components/modal' +import { Modal } from '@/components/ui' import { safeAwait } from '@/services/api' import { useUpdateSearchAutocomplete } from '@/services/hooks/extension/update-setting.hook' diff --git a/src/layouts/search/voice/voice-search.button.tsx b/src/layouts/search/voice/voice-search.button.tsx index f801dcfd..86d0531f 100644 --- a/src/layouts/search/voice/voice-search.button.tsx +++ b/src/layouts/search/voice/voice-search.button.tsx @@ -1,5 +1,5 @@ import Analytics from '@/analytics' -import Tooltip from '@/components/tool-tip' +import { Tooltip } from '@/components/ui' export function VoiceSearchButton({ onClick }: { onClick: () => void }) { const onClickHandle = () => { diff --git a/src/layouts/search/voice/voice-search.portal.tsx b/src/layouts/search/voice/voice-search.portal.tsx index 7bf6f8b2..a46a3edf 100644 --- a/src/layouts/search/voice/voice-search.portal.tsx +++ b/src/layouts/search/voice/voice-search.portal.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' import { useVoiceSearch } from './use-voice-search' -import { Portal } from '@/components/portal/portal' +import { Portal } from '@/components/ui' import { Icon } from '@/src/icons' interface VoiceSearchPortalProps { diff --git a/src/layouts/setting/setting-modal.tsx b/src/layouts/setting/setting-modal.tsx index 9b57d268..22c6ade3 100644 --- a/src/layouts/setting/setting-modal.tsx +++ b/src/layouts/setting/setting-modal.tsx @@ -1,6 +1,6 @@ import Analytics from '@/analytics' import { callEvent } from '@/common/utils/call-event' -import Modal from '@/components/modal' +import { Modal } from '@/components/ui' import { type TabItem, TabManager } from '@/components/tab-manager' import { UpdateReleaseNotesModal } from '@/components/update-release-notes-modal' import { AboutUsTab } from './tabs/about-us/about-us' diff --git a/src/layouts/setting/tabs/about-us/about-us.tsx b/src/layouts/setting/tabs/about-us/about-us.tsx index 64f2c905..eaedc275 100644 --- a/src/layouts/setting/tabs/about-us/about-us.tsx +++ b/src/layouts/setting/tabs/about-us/about-us.tsx @@ -1,4 +1,4 @@ -import { SectionPanel } from '@/components/section-panel' +import { SectionPanel } from '@/components/ui' import { ConfigKey } from '../../../../common/constant/config.key' import { Icon } from '@/src/icons' @@ -8,7 +8,7 @@ export function AboutUsTab() {

ویجتیفای diff --git a/src/layouts/setting/tabs/account/auth-form/components/input-text-error.tsx b/src/layouts/setting/tabs/account/auth-form/components/input-text-error.tsx index ddb36cda..83048623 100644 --- a/src/layouts/setting/tabs/account/auth-form/components/input-text-error.tsx +++ b/src/layouts/setting/tabs/account/auth-form/components/input-text-error.tsx @@ -9,7 +9,7 @@ const InputTextError: React.FC = ({ message, className }) = const hasError = Boolean(message) const classes = twMerge( - 'text-xs text-red-500 flex items-center gap-1.5 transition-all duration-300 ease-in-out overflow-hidden', + 'text-xs text-error flex items-center gap-1.5 transition-all duration-300 ease-in-out overflow-hidden', hasError ? 'opacity-100 translate-y-0 mt-1 min-h-[20px]' : 'opacity-0 -translate-y-1 pointer-events-none', @@ -27,7 +27,7 @@ const InputTextError: React.FC = ({ message, className }) = <>