From b9c7ad8fa0d421ccf90b3c3bfd1e124cffc01ca1 Mon Sep 17 00:00:00 2001 From: myusername Date: Thu, 21 May 2026 12:17:36 +0530 Subject: [PATCH] feat: Modal primitive component created and added to ReportCard and Topbar. Also created new SearchBar composite component to replace Topbar search variant, included .github in eslintconfig GLobalIgnores --- eslint.config.js | 2 +- src/layouts/AdminLayout.tsx | 8 +- src/shared/composites/ListCard/ListCard.tsx | 53 ++-- .../composites/ReportCard/ReportCard.tsx | 234 +++++++++------- .../SearchBar/SearchBar.stories.tsx | 140 ++++++++++ src/shared/composites/SearchBar/SearchBar.tsx | 87 ++++++ src/shared/composites/SearchBar/index.ts | 3 + .../composites/Topbar/Topbar.stories.tsx | 14 +- src/shared/composites/Topbar/Topbar.tsx | 112 ++++---- src/shared/primitives/Input/Input.tsx | 2 + src/shared/primitives/Modal/Modal.stories.tsx | 257 ++++++++++++++++++ src/shared/primitives/Modal/Modal.tsx | 169 ++++++++++++ src/shared/primitives/Modal/index.ts | 3 + src/stories/Button.tsx | 3 +- src/views/LoginPage.tsx | 2 +- 15 files changed, 905 insertions(+), 184 deletions(-) create mode 100644 src/shared/composites/SearchBar/SearchBar.stories.tsx create mode 100644 src/shared/composites/SearchBar/SearchBar.tsx create mode 100644 src/shared/composites/SearchBar/index.ts create mode 100644 src/shared/primitives/Modal/Modal.stories.tsx create mode 100644 src/shared/primitives/Modal/Modal.tsx create mode 100644 src/shared/primitives/Modal/index.ts diff --git a/eslint.config.js b/eslint.config.js index 4d3f803..3c36b8e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -11,7 +11,7 @@ import { defineConfig, globalIgnores } from 'eslint/config' import eslintConfigPrettier from 'eslint-config-prettier' export default defineConfig([ - globalIgnores(['dist', 'storybook-static']), + globalIgnores(['dist', 'storybook-static', '.github']), { files: ['**/*.{ts,tsx}'], diff --git a/src/layouts/AdminLayout.tsx b/src/layouts/AdminLayout.tsx index 29a4e21..dedcbab 100644 --- a/src/layouts/AdminLayout.tsx +++ b/src/layouts/AdminLayout.tsx @@ -25,7 +25,13 @@ const AdminLayout = () => { const [loading, setLoading] = useState(true) useEffect(() => { - setTimeout(() => setLoading(!loading), 5000) + const timer = setTimeout(() => { + setLoading(false) + }, 5000) + + return () => { + clearTimeout(timer) + } }, []) return ( diff --git a/src/shared/composites/ListCard/ListCard.tsx b/src/shared/composites/ListCard/ListCard.tsx index 45a7bf1..2186009 100644 --- a/src/shared/composites/ListCard/ListCard.tsx +++ b/src/shared/composites/ListCard/ListCard.tsx @@ -1,6 +1,7 @@ import React from 'react' import { BaseCard } from '@/shared/primitives/BaseCard' +// import { Button } from '@/stories/Button' export interface ListCardProps { children: React.ReactNode @@ -44,35 +45,29 @@ export function ListCard({ } return ( - { - if (isClickable && (e.key === 'Enter' || e.key === ' ')) { - e.preventDefault() - - handleToggle() - } - }} - onClick={handleToggle} - className={className} - > -
{header}
- - {isExpanded && ( - <> -
{children}
- - {footer && ( -
e.stopPropagation()} - className="border-border-tertiary flex flex-wrap gap-2 border-t p-4" - > - {footer} -
- )} - - )} + +
{ + if (isClickable && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault() + + handleToggle() + } + }} + className="outline-none" + > +
{header}
+ + {isExpanded && ( + <> +
{children}
+
{footer}
+ + )} +
) } diff --git a/src/shared/composites/ReportCard/ReportCard.tsx b/src/shared/composites/ReportCard/ReportCard.tsx index d17d5ab..b0f0e7f 100644 --- a/src/shared/composites/ReportCard/ReportCard.tsx +++ b/src/shared/composites/ReportCard/ReportCard.tsx @@ -1,5 +1,4 @@ -import React from 'react' - +import React, { useState } from 'react' import { Badge } from '@/shared/primitives/Badge' import { Button } from '@/shared/primitives/Button' @@ -10,6 +9,7 @@ import { ListCard } from '@/shared/composites/ListCard' import { reports, deriveAvailableActions } from './dummyData' import { LockClosedIcon } from '@heroicons/react/24/solid' +import { Modal } from '@/shared/primitives/Modal' export type ReportStatus = 'PENDING' | 'ESCALATED_TO_HUMAN' | 'RESOLVED' | 'DISMISSED' @@ -197,8 +197,11 @@ export function ReportCard({ claimedBy, + className, + useDummyData = false, }: ReportCardProps) { + const [selectedAction, setSelectedAction] = useState(null) const resolvedReport = useDummyData ? reports[0] : report if (!resolvedReport) { @@ -208,114 +211,163 @@ export function ReportCard({ const availableActions = deriveAvailableActions(resolvedReport, isClaimed) return ( - -
-
-
- {resolvedReport.id} - - - {formatStatus(resolvedReport.status)} - - - {resolvedReport.targetType} + <> + +
+
+
+ + {resolvedReport.id} + + + + {formatStatus(resolvedReport.status)} + + + {resolvedReport.targetType} +
+ +
+ + {resolvedReport.reportReason} + +
-
- - {resolvedReport.reportReason} - +
+ +
+ Reported by {resolvedReport.reporter.name} · AI score:{' '} + {resolvedReport.aiConfidenceScore} +
-
- -
- Reported by {resolvedReport.reporter.name} · AI score:{' '} - {resolvedReport.aiConfidenceScore} -
+
+ + Click to view {isExpanded ? 'report summary' : 'detailed report'} +
+ } + footer={ + isClaimed ? ( +
+ Being reviewed by {claimedBy} +
+ ) : ( +
+ {availableActions.map((action) => ( + + ))} +
+ ) + } + > +
+ + +
+
+ Reporter: {resolvedReport.reporter.name} +
-
- - Click to view {isExpanded ? 'report summary' : 'detailed report'} - -
-
- } - footer={ - isClaimed ? ( -
- Being reviewed by {claimedBy} -
- ) : ( - <> - {availableActions.map((action) => ( - - ))} - - ) - } - > -
- - -
-
- Reporter: {resolvedReport.reporter.name} -
+
+ Author: {resolvedReport.author.name} +
-
- Author: {resolvedReport.author.name} +
+ Prior reports:{' '} + {resolvedReport.author.priorReportCount} +
-
- Prior reports:{' '} - {resolvedReport.author.priorReportCount} +
+
Content
+ +
+ "{resolvedReport.description}" +
-
-
-
Content
+
+
Audit Trail
-
- "{resolvedReport.description}" +
+ {resolvedReport.auditTrail.map((entry) => ( +
+ {entry.timestamp} · {entry.action} +
+ ))} +
+ + { + setSelectedAction(null) + }} + title="Confirm Moderation Action" + footer={ + <> + + + + + } + > +
+

Are you sure you want to:

-
-
Audit Trail
- -
- {resolvedReport.auditTrail.map((entry) => ( -
- {entry.timestamp} · {entry.action} -
- ))} +
+ {selectedAction && formatActionLabel(selectedAction)}
+ +

+ This moderation action may affect platform visibility and user access. +

-
- + + ) } diff --git a/src/shared/composites/SearchBar/SearchBar.stories.tsx b/src/shared/composites/SearchBar/SearchBar.stories.tsx new file mode 100644 index 0000000..fb0ff50 --- /dev/null +++ b/src/shared/composites/SearchBar/SearchBar.stories.tsx @@ -0,0 +1,140 @@ +import { useState } from 'react' + +import type { Meta, StoryObj } from '@storybook/react-vite' + +import SearchBar from './SearchBar' + +const meta = { + title: 'Shared/Composites/SearchBar', + + component: SearchBar, + + tags: ['autodocs'], + + parameters: { + layout: 'padded', + }, + + decorators: [ + (Story) => ( +
+
+ +
+
+ ), + ], +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Default: Story = { + render: (args) => { + return + }, + + args: { + label: 'Search reports', + placeholder: 'Search reports...', + }, +} + +export const WithInitialValue: Story = { + render: (args) => { + return + }, + + args: { + label: 'Search reports', + value: 'Misinformation', + + placeholder: 'Search reports...', + }, +} + +export const Debounced: Story = { + render: (args) => { + const [query, setQuery] = useState('') + + return ( +
+ { + setQuery(value) + }} + /> + +
+ Debounced query: {query || '—'} +
+
+ ) + }, + + args: { + label: 'Search reports', + + debounceMs: 600, + + placeholder: 'Type slowly...', + }, +} + +export const AutoFocus: Story = { + render: (args) => { + return + }, + + args: { + label: 'Search reports', + + autoFocus: true, + + placeholder: 'Focus starts here', + }, +} + +export const AccessibilityPreview: Story = { + render: (args) => { + const [query, setQuery] = useState('') + + return ( +
+ + + + +
+ Current query: {query || 'empty'} +
+
+ ) + }, +} + +export const Mobile: Story = { + parameters: { + viewport: { + defaultViewport: 'mobile1', + }, + }, + + render: (args) => { + return + }, + + args: { + label: 'Search reports', + placeholder: 'Search reports...', + }, +} diff --git a/src/shared/composites/SearchBar/SearchBar.tsx b/src/shared/composites/SearchBar/SearchBar.tsx new file mode 100644 index 0000000..bb9eac8 --- /dev/null +++ b/src/shared/composites/SearchBar/SearchBar.tsx @@ -0,0 +1,87 @@ +import { useEffect, useState } from 'react' + +import { MagnifyingGlassIcon, XMarkIcon } from '@heroicons/react/24/outline' + +import { Input } from '@/shared/primitives/Input' + +SearchBar.Icon = function SearchIcon() { + return +} +export interface SearchBarProps { + value?: string + + onSearch?: (query: string) => void + + placeholder?: string + + debounceMs?: number + + autoFocus?: boolean + + className?: string + + label?: string +} + +export function SearchBar({ + value = '', + + onSearch, + + placeholder = 'Search...', + + debounceMs = 300, + + // autoFocus = false, + + className = '', + + label, + // onSearch={fetchReports} in case API call se search karte hai + + // useSearchParams() in case search param use karte hai + + // filtersSlot?: ReactNode filters ke liye +}: SearchBarProps) { + const [query, setQuery] = useState(value) + + useEffect(() => { + const timer = setTimeout(() => { + onSearch?.(query) + }, debounceMs) + + return () => { + clearTimeout(timer) + } + }, [query, debounceMs, onSearch]) + + return ( + } + suffixIcon={ + query ? ( + + ) : null + } + /> + ) +} + +export default SearchBar diff --git a/src/shared/composites/SearchBar/index.ts b/src/shared/composites/SearchBar/index.ts new file mode 100644 index 0000000..79fc112 --- /dev/null +++ b/src/shared/composites/SearchBar/index.ts @@ -0,0 +1,3 @@ +export * from './SearchBar' + +export { default } from './SearchBar' diff --git a/src/shared/composites/Topbar/Topbar.stories.tsx b/src/shared/composites/Topbar/Topbar.stories.tsx index 4705708..07cb831 100644 --- a/src/shared/composites/Topbar/Topbar.stories.tsx +++ b/src/shared/composites/Topbar/Topbar.stories.tsx @@ -1,13 +1,12 @@ import type { Meta, StoryObj } from '@storybook/react-vite' -import { MagnifyingGlassIcon, BellIcon } from '@heroicons/react/24/outline' +import { BellIcon } from '@heroicons/react/24/outline' import { Topbar } from './Topbar' import { AvatarMenu } from '@/shared/composites/AvatarMenu/AvatarMenu' import { Button } from '@/shared/primitives/Button' -import { Input } from '@/shared/primitives/Input' const meta = { title: 'Shared/Composites/Topbar', @@ -41,16 +40,7 @@ export const WithSearch: Story = { args: { title: 'Moderation Queue', - searchSlot: ( - {}} - placeholder={'Search...'} - prefixIcon={} - className="py-2" - /> - ), + showSearch: true, actionsSlot: , }, diff --git a/src/shared/composites/Topbar/Topbar.tsx b/src/shared/composites/Topbar/Topbar.tsx index 8b0fb4e..8700b51 100644 --- a/src/shared/composites/Topbar/Topbar.tsx +++ b/src/shared/composites/Topbar/Topbar.tsx @@ -1,7 +1,6 @@ import React, { useState } from 'react' - -import { MagnifyingGlassIcon, XMarkIcon } from '@heroicons/react/24/solid' -import { Input } from '@/shared/primitives/Input' +import { Modal } from '@/shared/primitives/Modal' +import SearchBar from '../SearchBar/SearchBar' export interface TopbarProps { title: string @@ -28,60 +27,77 @@ export function Topbar({ searchPlaceholder, className = '', }: TopbarProps) { - const [searchQuery, overwriteSearchQuery] = useState('') + const [mobileSearchOpen, setMobileSearchOpen] = useState(false) return ( -
- {/* Left */} -
- {/* In case logo chahiye */} - {/* */} - {/* Title */} -

{title}

-
+ {/* Title */} +

{title}

+
- {/* Desktop Search */} - {(showSearch || searchSlot) && ( -
-
- {searchSlot || ( - { - overwriteSearchQuery(e) - }} - placeholder={searchPlaceholder || 'Search...'} - prefixIcon={} - suffixIcon={ - searchQuery ? ( - - ) : null - } - /> - )} + {/* Desktop Search */} + {(showSearch || searchSlot) && ( +
+
+ {searchSlot || ( + { + console.log(query) + }} + /> + )} +
-
- )} + )} + {/* Right Actions */} +
+ {(showSearch || searchSlot) && ( + + )} - {/* Right Actions */} -
{actionsSlot}
- + {actionsSlot} +
+ + { + setMobileSearchOpen(false) + }} + title="Search" + size="sm" + > +
+ { + console.log(query) + }} + /> +
+
+ ) } diff --git a/src/shared/primitives/Input/Input.tsx b/src/shared/primitives/Input/Input.tsx index 148426b..dc52379 100644 --- a/src/shared/primitives/Input/Input.tsx +++ b/src/shared/primitives/Input/Input.tsx @@ -30,6 +30,8 @@ export interface InputProps { testId?: string className?: string + + autoFocus?: boolean } const inputStyles = cva( diff --git a/src/shared/primitives/Modal/Modal.stories.tsx b/src/shared/primitives/Modal/Modal.stories.tsx new file mode 100644 index 0000000..e4a08c5 --- /dev/null +++ b/src/shared/primitives/Modal/Modal.stories.tsx @@ -0,0 +1,257 @@ +import { useState } from 'react' + +import type { Meta, StoryObj } from '@storybook/react-vite' + +import { MagnifyingGlassIcon } from '@heroicons/react/24/outline' + +import { Modal } from './Modal' + +import { Button } from '@/shared/primitives/Button' + +import { Input } from '@/shared/primitives/Input' + +const meta = { + title: 'Shared/Primitives/Modal', + + component: Modal, + + tags: ['autodocs'], + + parameters: { + layout: 'centered', + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Default: Story = { + render: () => { + const [open, setOpen] = useState(false) + + return ( + <> + + + { + setOpen(false) + }} + title="Confirmation" + > +
+
+

Are you sure you want to proceed?

+
+
+ + + +
+
+
+ + ) + }, +} + +export const WithFooter: Story = { + render: () => { + const [open, setOpen] = useState(false) + + return ( +
+ { + setOpen(false) + }} + title="Delete Report" + footer={ + <> + + + + + } + > +

This action cannot be undone.

+
+ +
+ ) + }, +} + +export const SearchModal: Story = { + render: () => { + const [open, setOpen] = useState(false) + + const [search, setSearch] = useState('') + + return ( + <> + + + { + setOpen(false) + }} + title="Search Reports" + size="sm" + > + } + helperText="Search by report ID, author, or reason" + /> + + + ) + }, +} + +export const Large: Story = { + render: () => { + const [open, setOpen] = useState(false) + + return ( +
+ { + setOpen(false) + }} + title="Large Modal" + size="lg" + > +
+

Large modal content

+ +

+ Used for moderation details, advanced filters, or analytics previews. +

+
+
+ +
+ ) + }, +} + +export const NoBackdropClose: Story = { + render: () => { + const [open, setOpen] = useState(false) + + return ( +
+ { + setOpen(false) + }} + title="Protected Action" + closeOnBackdrop={false} + > +

Clicking outside will not close this modal.

+
+ +
+ ) + }, +} + +export const AccessibilityPreview: Story = { + render: () => { + const [open, setOpen] = useState(false) + + const [email, setEmail] = useState('') + const [role, setRole] = useState('Senior Moderator') + + return ( +
+ { + setOpen(false) + }} + title="Invite Moderator" + footer={} + > +
+ + + +
+
+ +
+ ) + }, +} diff --git a/src/shared/primitives/Modal/Modal.tsx b/src/shared/primitives/Modal/Modal.tsx new file mode 100644 index 0000000..52afcd9 --- /dev/null +++ b/src/shared/primitives/Modal/Modal.tsx @@ -0,0 +1,169 @@ +import { useEffect, useRef } from 'react' + +import { createPortal } from 'react-dom' + +import { XMarkIcon } from '@heroicons/react/24/outline' + +import { cva } from 'class-variance-authority' + +export interface ModalProps { + isOpen: boolean + + onClose: () => void + + title?: string + + children: React.ReactNode + + footer?: React.ReactNode + + size?: 'sm' | 'md' | 'lg' + + closeOnBackdrop?: boolean + + className?: string + + testId?: string +} + +const modalStyles = cva( + ` + bg-bg-secondary + border-border-secondary + text-text-primary + relative + w-full + rounded-xl + border + shadow-xl + transition-all + duration-200 + animate-in + fade-in + zoom-in-95 + `, + { + variants: { + size: { + sm: 'max-w-sm', + + md: 'max-w-lg', + + lg: 'max-w-2xl', + }, + }, + + defaultVariants: { + size: 'md', + }, + } +) + +export function Modal({ + isOpen, + + onClose, + + title, + + children, + + footer, + + size = 'md', + + closeOnBackdrop = true, + + className = '', + + testId, +}: ModalProps) { + const dialogRef = useRef(null) + + useEffect(() => { + if (!isOpen) return + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose() + } + } + + document.addEventListener('keydown', handleEscape) + + document.body.style.overflow = 'hidden' + + dialogRef.current?.focus() + + return () => { + document.removeEventListener('keydown', handleEscape) + + document.body.style.overflow = '' + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen]) + + if (!isOpen) return null + + return createPortal( +
+ +
+ + {/* Body */} +
{children}
+ + {/* Footer */} + {footer && ( +
+ {footer} +
+ )} +
+
, + document.body + ) +} + +export default Modal diff --git a/src/shared/primitives/Modal/index.ts b/src/shared/primitives/Modal/index.ts new file mode 100644 index 0000000..c0f6cb9 --- /dev/null +++ b/src/shared/primitives/Modal/index.ts @@ -0,0 +1,3 @@ +export { Modal } from './Modal' + +export type { ModalProps } from './Modal' diff --git a/src/stories/Button.tsx b/src/stories/Button.tsx index 76d11db..f9a78c5 100644 --- a/src/stories/Button.tsx +++ b/src/stories/Button.tsx @@ -12,7 +12,7 @@ export interface ButtonProps { /** Button contents */ label: string /** Optional click handler */ - onClick?: () => void + onClick?: (e?: React.MouseEvent) => void } /** Primary UI component for user interaction */ @@ -21,6 +21,7 @@ export const Button = ({ size = 'medium', backgroundColor, label, + // onClick, ...props }: ButtonProps) => { const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary' diff --git a/src/views/LoginPage.tsx b/src/views/LoginPage.tsx index f144730..cb08f07 100644 --- a/src/views/LoginPage.tsx +++ b/src/views/LoginPage.tsx @@ -111,7 +111,7 @@ const LoginPage = () => { onAction={(reportId, action) => { console.log('Action:', action, 'on', reportId) }} - className="bg-bg-secondary" + className="bg-bg-secondary cursor-pointer" /> ))}