@djangocfg/ui-core 2.1.227 → 2.1.228

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -45,12 +45,14 @@ pnpm add @djangocfg/ui-core
45
45
  ### Specialized (13)
46
46
  `ButtonGroup` `Empty` `Spinner` `Preloader` `Kbd` `TokenIcon` `InputGroup` `Item` `ImageWithFallback` `OgImage` `CopyButton` `CopyField` `StaticPagination`
47
47
 
48
- ## Hooks (13)
48
+ ## Hooks (16)
49
49
 
50
50
  | Hook | Description |
51
51
  |------|-------------|
52
- | `useMediaQuery` | Responsive breakpoints |
53
- | `useIsMobile` | Mobile detection |
52
+ | `useMediaQuery(query)` | Raw media query — pass any CSS query string. Exports `BREAKPOINTS` constants (Tailwind v4 defaults) |
53
+ | `useIsPhone()` | `< 640px` — phones only |
54
+ | `useIsMobile()` | `< 768px` — phones + small tablets |
55
+ | `useIsTabletOrBelow()` | `< 1024px` — phones + tablets |
54
56
  | `useCopy` | Copy to clipboard |
55
57
  | `useCountdown` | Countdown timer |
56
58
  | `useDebounce` | Debounce values |
@@ -62,6 +64,19 @@ pnpm add @djangocfg/ui-core
62
64
  | `useHotkey` | Keyboard shortcuts (react-hotkeys-hook) |
63
65
  | `useBrowserDetect` | Browser detection (Chrome, Safari, in-app browsers, etc.) |
64
66
  | `useDeviceDetect` | Device detection (mobile, tablet, desktop, OS, etc.) |
67
+ | `useResolvedTheme` | Current resolved theme (light/dark/system) |
68
+
69
+ ```tsx
70
+ import { useMediaQuery, useIsPhone, useIsMobile, BREAKPOINTS } from '@djangocfg/ui-core/hooks'
71
+
72
+ // semantic
73
+ const isPhone = useIsPhone() // < 640px
74
+ const isMobile = useIsMobile() // < 768px
75
+
76
+ // custom with constants
77
+ const isNarrow = useMediaQuery(`(max-width: ${BREAKPOINTS.sm - 1}px)`)
78
+ const isDark = useMediaQuery('(prefers-color-scheme: dark)')
79
+ ```
65
80
 
66
81
  ## Theme Palette Hooks
67
82
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-core",
3
- "version": "2.1.227",
3
+ "version": "2.1.228",
4
4
  "description": "Pure React UI component library without Next.js dependencies - for Electron, Vite, CRA apps",
5
5
  "keywords": [
6
6
  "ui-components",
@@ -81,12 +81,12 @@
81
81
  "playground": "playground dev"
82
82
  },
83
83
  "peerDependencies": {
84
- "@djangocfg/i18n": "^2.1.227",
85
- "react-device-detect": "^2.2.3",
84
+ "@djangocfg/i18n": "^2.1.228",
86
85
  "consola": "^3.4.2",
87
86
  "lucide-react": "^0.545.0",
88
87
  "moment": "^2.30.1",
89
88
  "react": "^19.1.0",
89
+ "react-device-detect": "^2.2.3",
90
90
  "react-dom": "^19.1.0",
91
91
  "react-hook-form": "^7.69.0",
92
92
  "tailwindcss": "^4.1.18",
@@ -134,18 +134,18 @@
134
134
  "input-otp": "1.4.2",
135
135
  "libphonenumber-js": "^1.12.24",
136
136
  "react-day-picker": "9.11.1",
137
+ "react-hotkeys-hook": "^4.6.1",
137
138
  "react-resizable-panels": "3.0.6",
138
139
  "react-sticky-box": "^2.0.5",
139
140
  "recharts": "2.15.4",
140
141
  "sonner": "2.0.7",
141
- "react-hotkeys-hook": "^4.6.1",
142
142
  "tailwind-merge": "^3.3.1",
143
143
  "vaul": "1.1.2"
144
144
  },
145
145
  "devDependencies": {
146
- "@djangocfg/i18n": "^2.1.227",
146
+ "@djangocfg/i18n": "^2.1.228",
147
147
  "@djangocfg/playground": "workspace:*",
148
- "@djangocfg/typescript-config": "^2.1.227",
148
+ "@djangocfg/typescript-config": "^2.1.228",
149
149
  "@types/node": "^24.7.2",
150
150
  "@types/react": "^19.1.0",
151
151
  "@types/react-dom": "^19.1.0",
@@ -4,7 +4,7 @@ import { format } from 'date-fns';
4
4
  import { CalendarIcon } from 'lucide-react';
5
5
  import * as React from 'react';
6
6
 
7
- import { useTypedT, type I18nTranslations } from '@djangocfg/i18n';
7
+ import { useAppT } from '@djangocfg/i18n';
8
8
  import { cn } from '../../lib/utils';
9
9
  import { Button } from '../button';
10
10
  import { Popover, PopoverContent, PopoverTrigger } from '../popover';
@@ -55,7 +55,7 @@ const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(
55
55
  },
56
56
  ref
57
57
  ) => {
58
- const t = useTypedT<I18nTranslations>()
58
+ const t = useAppT()
59
59
  const [open, setOpen] = React.useState(false)
60
60
 
61
61
  const resolvedPlaceholder = placeholder ?? t('ui.datetime.pickDate')
@@ -148,7 +148,7 @@ const DateRangePicker = React.forwardRef<HTMLButtonElement, DateRangePickerProps
148
148
  },
149
149
  ref
150
150
  ) => {
151
- const t = useTypedT<I18nTranslations>()
151
+ const t = useAppT()
152
152
  const [open, setOpen] = React.useState(false)
153
153
 
154
154
  const resolvedPlaceholder = placeholder ?? t('ui.datetime.pickDateRange')
@@ -5,7 +5,7 @@ import * as React from 'react';
5
5
 
6
6
  import { ArrowLeftIcon, ArrowRightIcon } from '@radix-ui/react-icons';
7
7
 
8
- import { useTypedT, type I18nTranslations } from '@djangocfg/i18n';
8
+ import { useAppT } from '@djangocfg/i18n';
9
9
  import { cn } from '../lib/utils';
10
10
  import { Button } from './button';
11
11
 
@@ -198,7 +198,7 @@ const CarouselPrevious = React.forwardRef<
198
198
  HTMLButtonElement,
199
199
  React.ComponentProps<typeof Button>
200
200
  >(({ className, variant = "outline", size = "icon", ...props }, ref) => {
201
- const t = useTypedT<I18nTranslations>()
201
+ const t = useAppT()
202
202
  const { orientation, scrollPrev, canScrollPrev } = useCarousel()
203
203
  const previousLabel = t('ui.actions.previousSlide')
204
204
 
@@ -229,7 +229,7 @@ const CarouselNext = React.forwardRef<
229
229
  HTMLButtonElement,
230
230
  React.ComponentProps<typeof Button>
231
231
  >(({ className, variant = "outline", size = "icon", ...props }, ref) => {
232
- const t = useTypedT<I18nTranslations>()
232
+ const t = useAppT()
233
233
  const { orientation, scrollNext, canScrollNext } = useCarousel()
234
234
  const nextLabel = t('ui.actions.nextSlide')
235
235
 
@@ -14,7 +14,11 @@ const Checkbox = React.forwardRef<
14
14
  <CheckboxPrimitive.Root
15
15
  ref={ref}
16
16
  className={cn(
17
- "peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
17
+ "peer h-[1.125rem] w-[1.125rem] shrink-0 rounded-[4px] border border-input bg-background shadow-none",
18
+ "transition-colors duration-150",
19
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
20
+ "disabled:cursor-not-allowed disabled:opacity-40",
21
+ "data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
18
22
  className
19
23
  )}
20
24
  {...props}
@@ -22,7 +26,7 @@ const Checkbox = React.forwardRef<
22
26
  <CheckboxPrimitive.Indicator
23
27
  className={cn("flex items-center justify-center text-current")}
24
28
  >
25
- <CheckIcon className="h-4 w-4" />
29
+ <CheckIcon className="h-3 w-3" />
26
30
  </CheckboxPrimitive.Indicator>
27
31
  </CheckboxPrimitive.Root>
28
32
  ))
@@ -3,7 +3,7 @@
3
3
  import { Check, ChevronsUpDown } from 'lucide-react';
4
4
  import * as React from 'react';
5
5
 
6
- import { useTypedT, type I18nTranslations } from '@djangocfg/i18n';
6
+ import { useAppT } from '@djangocfg/i18n';
7
7
  import { useStoredValue, type StorageType, type UseStoredValueOptions } from '../hooks/useStoredValue';
8
8
  import { cn } from '../lib/utils';
9
9
  import { Button } from './button';
@@ -78,7 +78,7 @@ export function Combobox({
78
78
  storageTtl,
79
79
  autoSelectFromOptions = false,
80
80
  }: ComboboxProps) {
81
- const t = useTypedT<I18nTranslations>()
81
+ const t = useAppT()
82
82
  const [open, setOpen] = React.useState(false)
83
83
  const [search, setSearch] = React.useState("")
84
84
  const scrollRef = React.useRef<HTMLDivElement>(null)
@@ -3,7 +3,7 @@
3
3
  import { Check, Copy } from 'lucide-react';
4
4
  import * as React from 'react';
5
5
 
6
- import { useTypedT, type I18nTranslations } from '@djangocfg/i18n';
6
+ import { useAppT } from '@djangocfg/i18n';
7
7
  import { cn } from '../lib/utils';
8
8
  import { Button } from './button';
9
9
 
@@ -40,7 +40,7 @@ const CopyButton = React.forwardRef<HTMLButtonElement, CopyButtonProps>(
40
40
  iconClassName = 'h-4 w-4',
41
41
  ...props
42
42
  }, ref) => {
43
- const t = useTypedT<I18nTranslations>()
43
+ const t = useAppT()
44
44
  const [copied, setCopied] = React.useState(false)
45
45
 
46
46
  const handleCopy = async () => {
@@ -0,0 +1,78 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+
5
+ export interface GlowBackgroundProps {
6
+ className?: string;
7
+ }
8
+
9
+ /**
10
+ * GlowBackground — animated mesh gradient backdrop.
11
+ *
12
+ * Renders as `position: absolute inset-0` — must be placed inside
13
+ * a `position: relative` container. Content goes on top with `position: relative`.
14
+ *
15
+ * @example
16
+ * ```tsx
17
+ * <div style={{ position: 'relative', minHeight: '100vh' }}>
18
+ * <GlowBackground />
19
+ * <div style={{ position: 'relative' }}>{content}</div>
20
+ * </div>
21
+ * ```
22
+ */
23
+ export const GlowBackground = React.memo(({ className }: GlowBackgroundProps) => (
24
+ <div
25
+ aria-hidden="true"
26
+ style={{
27
+ position: 'absolute',
28
+ inset: 0,
29
+ overflow: 'hidden',
30
+ pointerEvents: 'none',
31
+ zIndex: 0,
32
+ }}
33
+ className={className}
34
+ >
35
+ {/* Blue blob — top left */}
36
+ <div style={{
37
+ position: 'absolute',
38
+ top: '-10%',
39
+ left: '-10%',
40
+ width: 700,
41
+ height: 700,
42
+ borderRadius: '50%',
43
+ background: 'radial-gradient(circle, hsl(217 91% 60% / 0.5) 0%, transparent 70%)',
44
+ filter: 'blur(80px)',
45
+ animation: 'blob 10s ease-in-out infinite',
46
+ }} />
47
+
48
+ {/* Purple blob — bottom right */}
49
+ <div style={{
50
+ position: 'absolute',
51
+ bottom: '-10%',
52
+ right: '-10%',
53
+ width: 600,
54
+ height: 600,
55
+ borderRadius: '50%',
56
+ background: 'radial-gradient(circle, hsl(262 83% 65% / 0.4) 0%, transparent 70%)',
57
+ filter: 'blur(80px)',
58
+ animation: 'blob 10s ease-in-out infinite',
59
+ animationDelay: '3s',
60
+ }} />
61
+
62
+ {/* Indigo blob — center top */}
63
+ <div style={{
64
+ position: 'absolute',
65
+ top: '10%',
66
+ right: '20%',
67
+ width: 400,
68
+ height: 400,
69
+ borderRadius: '50%',
70
+ background: 'radial-gradient(circle, hsl(234 89% 74% / 0.35) 0%, transparent 70%)',
71
+ filter: 'blur(60px)',
72
+ animation: 'blob 12s ease-in-out infinite',
73
+ animationDelay: '6s',
74
+ }} />
75
+ </div>
76
+ ));
77
+
78
+ GlowBackground.displayName = 'GlowBackground';
@@ -0,0 +1,2 @@
1
+ export { GlowBackground } from './GlowBackground';
2
+ export type { GlowBackgroundProps } from './GlowBackground';
@@ -96,6 +96,10 @@ export { InputGroup, InputGroupAddon, InputGroupButton, InputGroupText, InputGro
96
96
  export { Item, ItemMedia, ItemContent, ItemActions, ItemGroup, ItemSeparator, ItemTitle, ItemDescription, ItemHeader, ItemFooter } from './item';
97
97
  export { Field, FieldLabel, FieldDescription, FieldError, FieldGroup, FieldLegend, FieldSeparator, FieldSet, FieldContent, FieldTitle } from './field';
98
98
 
99
+ // Effects / Background
100
+ export { GlowBackground } from './effects';
101
+ export type { GlowBackgroundProps } from './effects';
102
+
99
103
  export { CopyButton, CopyField } from './copy';
100
104
  export type { CopyButtonProps, CopyFieldProps } from './copy';
101
105
  export { DownloadButton } from './button-download';
@@ -53,7 +53,7 @@ const InputOTPSlot = React.forwardRef<
53
53
  <div
54
54
  ref={ref}
55
55
  className={cn(
56
- "relative flex h-9 w-9 items-center justify-center border-y border-r border-input text-sm shadow-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
56
+ "relative flex items-center justify-center border-y border-r border-input text-sm shadow-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
57
57
  isActive && "z-10 ring-1 ring-ring",
58
58
  className
59
59
  )}
@@ -17,7 +17,7 @@ import { cva } from 'class-variance-authority';
17
17
  import { Check, ChevronsUpDown, Loader2, X, XCircle } from 'lucide-react';
18
18
  import * as React from 'react';
19
19
 
20
- import { useTypedT, type I18nTranslations } from '@djangocfg/i18n';
20
+ import { useAppT } from '@djangocfg/i18n';
21
21
  import { Badge } from '../badge';
22
22
  import { Button } from '../button';
23
23
  import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator } from '../command';
@@ -197,7 +197,7 @@ export const MultiSelectProAsync = React.forwardRef<MultiSelectProAsyncRef, Mult
197
197
  },
198
198
  ref
199
199
  ) => {
200
- const t = useTypedT<I18nTranslations>()
200
+ const t = useAppT()
201
201
  const [open, setOpen] = React.useState(false)
202
202
  const [selectedValues, setSelectedValues] = React.useState<string[]>(defaultValue)
203
203
  const buttonRef = React.useRef<HTMLButtonElement>(null)
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from 'class-variance-authority';
4
4
  import { Check, ChevronsUpDown, X, XCircle } from 'lucide-react';
5
5
  import * as React from 'react';
6
6
 
7
- import { useTypedT, type I18nTranslations } from '@djangocfg/i18n';
7
+ import { useAppT } from '@djangocfg/i18n';
8
8
  import { Badge } from '../badge';
9
9
  import { Button } from '../button';
10
10
  import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator } from '../command';
@@ -174,7 +174,7 @@ export const MultiSelectPro = React.forwardRef<MultiSelectProRef, MultiSelectPro
174
174
  },
175
175
  ref
176
176
  ) => {
177
- const t = useTypedT<I18nTranslations>()
177
+ const t = useAppT()
178
178
  const [open, setOpen] = React.useState(false)
179
179
  const [selectedValues, setSelectedValues] = React.useState<string[]>(defaultValue)
180
180
  const [search, setSearch] = React.useState("")
@@ -3,7 +3,7 @@
3
3
  import { Check, ChevronsUpDown, X } from 'lucide-react';
4
4
  import * as React from 'react';
5
5
 
6
- import { useTypedT, type I18nTranslations } from '@djangocfg/i18n';
6
+ import { useAppT } from '@djangocfg/i18n';
7
7
  import { useStoredValue, type StorageType, type UseStoredValueOptions } from '../hooks/useStoredValue';
8
8
  import { cn } from '../lib/utils';
9
9
  import { Badge } from './badge';
@@ -75,7 +75,7 @@ export function MultiSelect({
75
75
  storageTtl,
76
76
  autoSelectFromOptions = false,
77
77
  }: MultiSelectProps) {
78
- const t = useTypedT<I18nTranslations>()
78
+ const t = useAppT()
79
79
  const [open, setOpen] = React.useState(false)
80
80
  const [search, setSearch] = React.useState("")
81
81
  const scrollRef = React.useRef<HTMLDivElement>(null)
@@ -5,13 +5,16 @@ import * as React from 'react';
5
5
 
6
6
  import { InputOTP, InputOTPGroup, InputOTPSlot } from '../input-otp';
7
7
  import { cn } from '../../lib';
8
+ import { useIsMobile } from '../../hooks/useMobile';
8
9
 
9
10
  import { createPasteHandler, useSmartOTP } from './use-otp-input';
10
11
 
11
12
  import type { SmartOTPProps } from './types'
12
13
 
13
14
  /**
14
- * Size variants for OTP slots
15
+ * Size variants for OTP slots.
16
+ * In fluid mode these only set font-size — width/height are driven by the container.
17
+ * In fixed mode they set explicit w/h dimensions.
15
18
  */
16
19
  const sizeVariants = {
17
20
  sm: 'h-8 w-8 text-sm',
@@ -19,6 +22,12 @@ const sizeVariants = {
19
22
  lg: 'h-14 w-14 text-2xl',
20
23
  }
21
24
 
25
+ const sizeTextVariants = {
26
+ sm: 'text-sm',
27
+ default: 'text-base',
28
+ lg: 'text-2xl',
29
+ }
30
+
22
31
  /**
23
32
  * OTP Separator Component
24
33
  */
@@ -85,13 +94,17 @@ export const OTPInput = React.forwardRef<
85
94
  slotClassName,
86
95
  separatorClassName,
87
96
  autoFocus = true,
88
- size = 'default',
97
+ size,
89
98
  error = false,
90
99
  success = false,
100
+ fluid = false,
91
101
  ...props
92
102
  },
93
103
  ref
94
104
  ) => {
105
+ const isMobile = useIsMobile()
106
+ const resolvedSize = size ?? (isMobile ? 'default' : 'lg')
107
+
95
108
  const {
96
109
  value: otpValue,
97
110
  handleChange,
@@ -143,7 +156,9 @@ export const OTPInput = React.forwardRef<
143
156
  key={i}
144
157
  index={i}
145
158
  className={cn(
146
- sizeVariants[size],
159
+ fluid
160
+ ? `flex-1 min-w-0 aspect-square ${sizeTextVariants[resolvedSize]}`
161
+ : sizeVariants[resolvedSize],
147
162
  error && 'border-destructive ring-destructive/20',
148
163
  success && 'border-green-500 ring-green-500/20',
149
164
  slotClassName
@@ -153,7 +168,7 @@ export const OTPInput = React.forwardRef<
153
168
  }
154
169
 
155
170
  return slotElements
156
- }, [length, showSeparator, separatorPosition, separatorClassName, size, error, success, slotClassName])
171
+ }, [length, showSeparator, separatorPosition, separatorClassName, resolvedSize, fluid, error, success, slotClassName])
157
172
 
158
173
  return (
159
174
  <InputOTP
@@ -163,12 +178,12 @@ export const OTPInput = React.forwardRef<
163
178
  onChange={handleChange}
164
179
  onComplete={handleComplete}
165
180
  disabled={disabled}
166
- containerClassName={cn('gap-2', containerClassName)}
181
+ containerClassName={cn('gap-2', fluid && 'w-full', containerClassName)}
167
182
  autoFocus={autoFocus}
168
183
  onPaste={pasteHandler}
169
184
  {...props}
170
185
  >
171
- <InputOTPGroup>{slots}</InputOTPGroup>
186
+ <InputOTPGroup className={cn(fluid && 'w-full')}>{slots}</InputOTPGroup>
172
187
  </InputOTP>
173
188
  )
174
189
  }
@@ -105,8 +105,7 @@ export interface SmartOTPProps {
105
105
  autoFocus?: boolean
106
106
 
107
107
  /**
108
- * Slot size variant
109
- * @default 'default'
108
+ * Slot size variant. When omitted, auto-detects: 'default' on mobile, 'lg' on desktop.
110
109
  */
111
110
  size?: 'sm' | 'default' | 'lg'
112
111
 
@@ -119,6 +118,13 @@ export interface SmartOTPProps {
119
118
  * Success state
120
119
  */
121
120
  success?: boolean
121
+
122
+ /**
123
+ * Fluid mode — slots stretch to fill the full container width.
124
+ * Useful for responsive layouts where fixed slot widths would overflow.
125
+ * @default false
126
+ */
127
+ fluid?: boolean
122
128
  }
123
129
 
124
130
  /**
@@ -6,7 +6,7 @@ import {
6
6
  import { ChevronDown, Search } from 'lucide-react';
7
7
  import * as React from 'react';
8
8
 
9
- import { useTypedT, type I18nTranslations } from '@djangocfg/i18n';
9
+ import { useAppT } from '@djangocfg/i18n';
10
10
  import { Button } from './button';
11
11
  import { Input } from './input';
12
12
  import { cn } from '../lib';
@@ -63,7 +63,7 @@ const PhoneInput = React.forwardRef<HTMLInputElement, PhoneInputProps>(
63
63
  disabled = false,
64
64
  ...props
65
65
  }, ref) => {
66
- const t = useTypedT<I18nTranslations>()
66
+ const t = useAppT()
67
67
  const [selectedCountry, setSelectedCountry] = React.useState<CountryCode>(defaultCountry)
68
68
  const [inputValue, setInputValue] = React.useState('')
69
69
  const [isDropdownOpen, setIsDropdownOpen] = React.useState(false)
@@ -10,7 +10,7 @@
10
10
  import { Loader2 } from 'lucide-react';
11
11
  import React from 'react';
12
12
 
13
- import { useTypedT, type I18nTranslations } from '@djangocfg/i18n';
13
+ import { useAppT } from '@djangocfg/i18n';
14
14
  import { cn } from '../lib/utils';
15
15
  // Spinner is available for future use
16
16
  // import { Spinner } from './spinner';
@@ -104,7 +104,7 @@ export function Preloader({
104
104
  className,
105
105
  spinnerClassName,
106
106
  }: PreloaderProps) {
107
- const t = useTypedT<I18nTranslations>()
107
+ const t = useAppT()
108
108
  const loadingLabel = text || t('ui.states.loading')
109
109
  const spinnerSize = sizeMap[size];
110
110
 
@@ -1,10 +1,10 @@
1
1
  import { Loader2Icon } from 'lucide-react';
2
2
 
3
- import { useTypedT, type I18nTranslations } from '@djangocfg/i18n';
3
+ import { useAppT } from '@djangocfg/i18n';
4
4
  import { cn } from '../lib/utils';
5
5
 
6
6
  function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
7
- const t = useTypedT<I18nTranslations>()
7
+ const t = useAppT()
8
8
  const loadingLabel = t('ui.states.loading')
9
9
 
10
10
  return (
@@ -7,7 +7,7 @@ import * as TabsPrimitive from '@radix-ui/react-tabs';
7
7
 
8
8
  import { useIsMobile } from '../hooks';
9
9
  import { useStoredValue, type StorageType } from '../hooks/useStoredValue';
10
- import { useTypedT, type I18nTranslations } from '@djangocfg/i18n';
10
+ import { useAppT } from '@djangocfg/i18n';
11
11
  import { cn } from '../lib/utils';
12
12
  import { Button } from './button';
13
13
  import { ScrollArea, ScrollBar } from './scroll-area';
@@ -54,7 +54,7 @@ const Tabs = React.forwardRef<
54
54
  React.ElementRef<typeof TabsPrimitive.Root>,
55
55
  TabsProps
56
56
  >(({ mobileSheet = false, mobileSheetTitle, mobileTitleText, sticky = false, storageKey, storageType, children, ...props }, ref) => {
57
- const t = useTypedT<I18nTranslations>()
57
+ const t = useAppT()
58
58
  const resolvedMobileSheetTitle = mobileSheetTitle ?? t('ui.navigation.title')
59
59
  const isMobile = useIsMobile()
60
60
  const [open, setOpen] = React.useState(false)
@@ -5,13 +5,15 @@
5
5
 
6
6
  'use client';
7
7
 
8
- export { useCountdown } from './useCountdown';
8
+ export { useCountdown, useCountdownFromSeconds } from './useCountdown';
9
+ export type { CountdownState } from './useCountdown';
9
10
  export { useDebouncedCallback } from './useDebouncedCallback';
10
11
  export { useDebounce } from './useDebounce';
11
12
  export { useDebugTools } from './useDebugTools';
12
13
  export { useEventListener, events } from './useEventsBus';
13
- export { useIsMobile } from './useMobile';
14
- export { useMediaQuery } from './useMediaQuery';
14
+ export { useIsMobile, useIsPhone, useIsTabletOrBelow, BREAKPOINTS } from './useMobile';
15
+ export type { Breakpoint } from './useMobile';
16
+ export { useMediaQuery, BREAKPOINTS as MEDIA_BREAKPOINTS } from './useMediaQuery';
15
17
  export { useCopy } from './useCopy';
16
18
  export { useImageLoader } from './useImageLoader';
17
19
  export { useToast, toast } from './useToast';
@@ -1,73 +1,113 @@
1
1
  'use client';
2
2
 
3
3
  import moment from 'moment';
4
- import { useEffect, useState } from 'react';
4
+ import { useCallback, useEffect, useRef, useState } from 'react';
5
5
 
6
- interface CountdownState {
6
+ export interface CountdownState {
7
7
  days: number;
8
8
  hours: number;
9
9
  minutes: number;
10
10
  seconds: number;
11
11
  isExpired: boolean;
12
12
  totalSeconds: number;
13
+ /** Formatted MM:SS label, e.g. "19:00" */
14
+ label: string;
13
15
  }
14
16
 
15
- export const useCountdown = (targetDate: string | null): CountdownState => {
16
- const [countdown, setCountdown] = useState<CountdownState>({
17
- days: 0,
18
- hours: 0,
19
- minutes: 0,
20
- seconds: 0,
21
- isExpired: false,
22
- totalSeconds: 0,
23
- });
17
+ const ZERO_STATE: CountdownState = {
18
+ days: 0,
19
+ hours: 0,
20
+ minutes: 0,
21
+ seconds: 0,
22
+ isExpired: true,
23
+ totalSeconds: 0,
24
+ label: '',
25
+ };
24
26
 
25
- useEffect(() => {
26
- if (!targetDate) {
27
- return;
28
- }
27
+ function formatLabel(diff: number): string {
28
+ const m = Math.floor(diff / 60);
29
+ const s = diff % 60;
30
+ return m > 0
31
+ ? `${m}:${String(s).padStart(2, '0')}`
32
+ : `${s}s`;
33
+ }
29
34
 
30
- const target = moment.utc(targetDate);
35
+ function secondsToState(diff: number): CountdownState {
36
+ if (diff <= 0) return ZERO_STATE;
37
+ return {
38
+ days: Math.floor(diff / (24 * 60 * 60)),
39
+ hours: Math.floor((diff % (24 * 60 * 60)) / (60 * 60)),
40
+ minutes: Math.floor((diff % (60 * 60)) / 60),
41
+ seconds: diff % 60,
42
+ isExpired: false,
43
+ totalSeconds: diff,
44
+ label: formatLabel(diff),
45
+ };
46
+ }
31
47
 
32
- const updateCountdown = () => {
33
- const now = moment.utc();
34
- const diff = target.diff(now, 'seconds');
48
+ /**
49
+ * Date-based countdown (existing API).
50
+ * Re-calculates every second against a UTC date string, converted to local time.
51
+ */
52
+ export const useCountdown = (targetDate: string | null): CountdownState => {
53
+ const [countdown, setCountdown] = useState<CountdownState>(ZERO_STATE);
35
54
 
36
- if (diff <= 0) {
37
- setCountdown({
38
- days: 0,
39
- hours: 0,
40
- minutes: 0,
41
- seconds: 0,
42
- isExpired: true,
43
- totalSeconds: 0,
44
- });
45
- return;
46
- }
47
-
48
- const days = Math.floor(diff / (24 * 60 * 60));
49
- const hours = Math.floor((diff % (24 * 60 * 60)) / (60 * 60));
50
- const minutes = Math.floor((diff % (60 * 60)) / 60);
51
- const seconds = diff % 60;
52
-
53
- setCountdown({
54
- days,
55
- hours,
56
- minutes,
57
- seconds,
58
- isExpired: false,
59
- totalSeconds: diff,
60
- });
61
- };
55
+ useEffect(() => {
56
+ if (!targetDate) return;
62
57
 
63
- // Update immediately
64
- updateCountdown();
58
+ const target = moment.utc(targetDate).local();
65
59
 
66
- // Update every second
67
- const interval = setInterval(updateCountdown, 1000);
60
+ const tick = () => {
61
+ const diff = target.diff(moment(), 'seconds');
62
+ setCountdown(secondsToState(diff));
63
+ };
68
64
 
69
- return () => clearInterval(interval);
65
+ tick();
66
+ const id = setInterval(tick, 1000);
67
+ return () => clearInterval(id);
70
68
  }, [targetDate]);
71
69
 
72
70
  return countdown;
73
71
  };
72
+
73
+ /**
74
+ * Imperative seconds-based countdown with full state breakdown.
75
+ *
76
+ * @returns `[state, start]`
77
+ * - `state` — current countdown state (days/hours/minutes/seconds/isExpired/totalSeconds/label)
78
+ * - `start(n)` — begin/restart countdown from n seconds
79
+ *
80
+ * Alias: `useSecondsCountdown` (simple `[seconds, start]`)
81
+ */
82
+ export const useCountdownFromSeconds = (): [CountdownState, (seconds: number) => void] => {
83
+ const [state, setState] = useState<CountdownState>(ZERO_STATE);
84
+ const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
85
+
86
+ const clear = useCallback(() => {
87
+ if (intervalRef.current !== null) {
88
+ clearInterval(intervalRef.current);
89
+ intervalRef.current = null;
90
+ }
91
+ }, []);
92
+
93
+ const start = useCallback(
94
+ (initial: number) => {
95
+ clear();
96
+ setState(secondsToState(initial));
97
+ intervalRef.current = setInterval(() => {
98
+ setState((prev) => {
99
+ if (prev.totalSeconds <= 1) {
100
+ clear();
101
+ return ZERO_STATE;
102
+ }
103
+ return secondsToState(prev.totalSeconds - 1);
104
+ });
105
+ }, 1000);
106
+ },
107
+ [clear],
108
+ );
109
+
110
+ useEffect(() => clear, [clear]);
111
+
112
+ return [state, start];
113
+ };
@@ -3,38 +3,41 @@
3
3
  import { useEffect, useState } from 'react';
4
4
 
5
5
  /**
6
- * Hook to check if a media query matches
6
+ * Tailwind v4 default breakpoints (rem px at 16px base).
7
+ * In v4 these are CSS variables: var(--breakpoint-sm), var(--breakpoint-md), etc.
7
8
  *
8
- * @param query - CSS media query string
9
- * @returns boolean indicating if the query matches
9
+ * Usage with useMediaQuery:
10
+ * useMediaQuery(`(max-width: ${BREAKPOINTS.md - 1}px)`) // < 768px
11
+ * useMediaQuery(`(min-width: ${BREAKPOINTS.lg}px)`) // >= 1024px
12
+ */
13
+ export const BREAKPOINTS = {
14
+ sm: 640, // 40rem — phones landscape
15
+ md: 768, // 48rem — tablets portrait
16
+ lg: 1024, // 64rem — tablets landscape / small laptops
17
+ xl: 1280, // 80rem — desktops
18
+ '2xl': 1536, // 96rem
19
+ } as const
20
+
21
+ export type Breakpoint = keyof typeof BREAKPOINTS
22
+
23
+ /**
24
+ * Reactive media query hook.
10
25
  *
11
26
  * @example
12
- * const isMobile = useMediaQuery('(max-width: 768px)');
13
- * const prefersDark = useMediaQuery('(prefers-color-scheme: dark)');
14
- * const isLandscape = useMediaQuery('(orientation: landscape)');
27
+ * const isPhone = useMediaQuery('(max-width: 639px)')
28
+ * const isDark = useMediaQuery('(prefers-color-scheme: dark)')
29
+ * const isLandscape = useMediaQuery('(orientation: landscape)')
15
30
  */
16
31
  export function useMediaQuery(query: string): boolean {
17
- const [matches, setMatches] = useState<boolean>(false);
32
+ const [matches, setMatches] = useState<boolean>(false)
18
33
 
19
34
  useEffect(() => {
20
- const mediaQuery = window.matchMedia(query);
21
-
22
- // Set initial value
23
- setMatches(mediaQuery.matches);
24
-
25
- // Create event listener
26
- const handler = (event: MediaQueryListEvent) => {
27
- setMatches(event.matches);
28
- };
29
-
30
- // Add listener
31
- mediaQuery.addEventListener('change', handler);
32
-
33
- // Cleanup
34
- return () => {
35
- mediaQuery.removeEventListener('change', handler);
36
- };
37
- }, [query]);
38
-
39
- return matches;
35
+ const mediaQuery = window.matchMedia(query)
36
+ setMatches(mediaQuery.matches)
37
+ const handler = (event: MediaQueryListEvent) => setMatches(event.matches)
38
+ mediaQuery.addEventListener('change', handler)
39
+ return () => mediaQuery.removeEventListener('change', handler)
40
+ }, [query])
41
+
42
+ return matches
40
43
  }
@@ -1,22 +1,35 @@
1
1
  'use client';
2
2
 
3
- import * as React from 'react';
3
+ /**
4
+ * Semantic viewport breakpoint hooks.
5
+ *
6
+ * Built on top of useMediaQuery — see useMediaQuery.ts for
7
+ * BREAKPOINTS constants and raw media query usage.
8
+ *
9
+ * @example
10
+ * import { useIsPhone, useIsMobile, useIsTabletOrBelow } from '@djangocfg/ui-core/hooks'
11
+ *
12
+ * const isPhone = useIsPhone() // < 640px (sm)
13
+ * const isMobile = useIsMobile() // < 768px (md)
14
+ * const isTabletOrBelow = useIsTabletOrBelow() // < 1024px (lg)
15
+ */
4
16
 
5
- // Увеличил breakpoint до 1024px чтобы включить планшеты
6
- const MOBILE_BREAKPOINT = 1024
17
+ import { BREAKPOINTS, useMediaQuery } from './useMediaQuery';
7
18
 
8
- export function useIsMobile() {
9
- const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
19
+ export type { Breakpoint } from './useMediaQuery';
20
+ export { BREAKPOINTS };
10
21
 
11
- React.useEffect(() => {
12
- const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
13
- const onChange = () => {
14
- setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
15
- }
16
- mql.addEventListener("change", onChange)
17
- setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
18
- return () => mql.removeEventListener("change", onChange)
19
- }, [])
22
+ /** True when viewport < 640px (Tailwind sm) phones only. */
23
+ export function useIsPhone(): boolean {
24
+ return useMediaQuery(`(max-width: ${BREAKPOINTS.sm - 1}px)`)
25
+ }
26
+
27
+ /** True when viewport < 768px (Tailwind md) — phones + small tablets. */
28
+ export function useIsMobile(): boolean {
29
+ return useMediaQuery(`(max-width: ${BREAKPOINTS.md - 1}px)`)
30
+ }
20
31
 
21
- return !!isMobile
32
+ /** True when viewport < 1024px (Tailwind lg) — phones + tablets. */
33
+ export function useIsTabletOrBelow(): boolean {
34
+ return useMediaQuery(`(max-width: ${BREAKPOINTS.lg - 1}px)`)
22
35
  }
@@ -52,6 +52,13 @@
52
52
  }
53
53
  }
54
54
 
55
+ /* Blob - Organic drifting movement for mesh gradient orbs */
56
+ @keyframes blob {
57
+ 0%, 100% { transform: translate(0, 0) scale(1); }
58
+ 33% { transform: translate(40px, -60px) scale(1.1); }
59
+ 66% { transform: translate(-30px, 30px) scale(0.9); }
60
+ }
61
+
55
62
  /* Gradient Shift - Color animation with hue rotation */
56
63
  @keyframes gradient-shift {
57
64
  0%, 100% {
@@ -132,4 +132,5 @@
132
132
  --animate-float-diagonal: float-diagonal 22s ease-in-out infinite;
133
133
  --animate-morph: morph 15s ease-in-out infinite;
134
134
  --animate-gradient-shift: gradient-shift 10s ease-in-out infinite;
135
+ --animate-blob: blob 10s ease-in-out infinite;
135
136
  }